1use crate::format::DisplayTemplate;
7
8const PROMPT_FIELDS: &[&str] = &[
12 "label",
13 "top",
14 "bottom",
15 "total",
16 "pct",
17 "rec-top",
18 "rec-bottom",
19 "rec-total",
20 "rec-block",
21 "wrap-offset",
22 "format-tag",
23 "filter-tag",
24 "grep-tag",
25 "hide-tag",
26 "search-tag",
27 "pretty-tag",
28 "live-tag",
29 "follow-tag",
30 "preprocess-failed-tag",
31 "file-index-tag",
32];
33
34#[derive(Debug, Clone)]
35pub struct ParsedPrompt {
36 template: DisplayTemplate,
37}
38
39impl ParsedPrompt {
40 pub fn parse(source: &str) -> Result<Self, String> {
43 let field_names: Vec<String> =
44 PROMPT_FIELDS.iter().map(|s| s.to_string()).collect();
45 let template = DisplayTemplate::compile(source, &field_names)?;
46 Ok(Self { template })
47 }
48
49 pub fn render(&self, ctx: &PromptContext) -> String {
51 self.template.render(|name| ctx.lookup(name))
52 }
53
54 pub fn source(&self) -> &str {
55 self.template.source()
56 }
57}
58
59#[derive(Debug, Default)]
62pub struct PromptContext {
63 pub label: String,
64 pub top: usize,
65 pub bottom: usize,
66 pub total: usize,
67 pub pct: u8,
68 pub rec_top: usize,
69 pub rec_bottom: usize,
70 pub rec_total: usize,
71 pub records_mode: bool,
72 pub wrap_offset: String,
73 pub format_tag: String,
74 pub filter_tag: String,
75 pub grep_tag: String,
76 pub hide_tag: String,
77 pub search_tag: String,
78 pub pretty_tag: String,
79 pub live_tag: String,
80 pub follow_tag: String,
81 pub preprocess_failed_tag: String,
82 pub file_index_tag: String,
83}
84
85impl PromptContext {
86 fn lookup(&self, name: &str) -> Option<String> {
87 match name {
88 "label" => Some(self.label.clone()),
89 "top" => Some(self.top.to_string()),
90 "bottom" => Some(self.bottom.to_string()),
91 "total" => Some(self.total.to_string()),
92 "pct" => Some(self.pct.to_string()),
93 "rec-top" => Some(self.rec_top.to_string()),
94 "rec-bottom" => Some(self.rec_bottom.to_string()),
95 "rec-total" => Some(self.rec_total.to_string()),
96 "rec-block" => Some(if self.records_mode {
97 format!(
98 "L{}-{}/{} R{}-{}/{}",
99 self.top, self.bottom, self.total,
100 self.rec_top, self.rec_bottom, self.rec_total,
101 )
102 } else {
103 format!("{}-{}/{}", self.top, self.bottom, self.total)
104 }),
105 "wrap-offset" => Some(self.wrap_offset.clone()),
106 "format-tag" => Some(self.format_tag.clone()),
107 "filter-tag" => Some(self.filter_tag.clone()),
108 "grep-tag" => Some(self.grep_tag.clone()),
109 "hide-tag" => Some(self.hide_tag.clone()),
110 "search-tag" => Some(self.search_tag.clone()),
111 "pretty-tag" => Some(self.pretty_tag.clone()),
112 "live-tag" => Some(self.live_tag.clone()),
113 "follow-tag" => Some(self.follow_tag.clone()),
114 "preprocess-failed-tag" => Some(self.preprocess_failed_tag.clone()),
115 "file-index-tag" => Some(self.file_index_tag.clone()),
116 _ => None,
117 }
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn parse_literal_only_template() {
127 let p = ParsedPrompt::parse("hello").unwrap();
128 let ctx = PromptContext::default();
129 assert_eq!(p.render(&ctx), "hello");
130 }
131
132 #[test]
133 fn parse_field_template() {
134 let p = ParsedPrompt::parse("<label> <pct>%").unwrap();
135 let ctx = PromptContext {
136 label: "file.log".into(),
137 pct: 42,
138 ..Default::default()
139 };
140 assert_eq!(p.render(&ctx), "file.log 42%");
141 }
142
143 #[test]
144 fn parse_rejects_unknown_field() {
145 let err = ParsedPrompt::parse("<bogus>").unwrap_err();
146 assert!(err.contains("bogus"), "error mentions field name: {err}");
147 }
148
149 #[test]
150 fn parse_handles_escaped_left_angle() {
151 let p = ParsedPrompt::parse(r"\<not a field>").unwrap();
153 let ctx = PromptContext::default();
154 assert_eq!(p.render(&ctx), "<not a field>");
155 }
156
157 #[test]
158 fn parse_handles_escaped_backslash() {
159 let p = ParsedPrompt::parse(r"a\\b").unwrap();
160 let ctx = PromptContext::default();
161 assert_eq!(p.render(&ctx), "a\\b");
162 }
163
164 #[test]
165 fn render_resolves_empty_tags_to_nothing() {
166 let p = ParsedPrompt::parse("<label><filter-tag><grep-tag>").unwrap();
167 let ctx = PromptContext { label: "x".into(), ..Default::default() };
168 assert_eq!(p.render(&ctx), "x");
169 }
170
171 #[test]
172 fn render_resolves_populated_tags() {
173 let p = ParsedPrompt::parse("<grep-tag><hide-tag>").unwrap();
174 let ctx = PromptContext {
175 grep_tag: " [grep]".into(),
176 hide_tag: " [hide]".into(),
177 ..Default::default()
178 };
179 assert_eq!(p.render(&ctx), " [grep] [hide]");
180 }
181
182 #[test]
183 fn rec_block_renders_records_mode_form() {
184 let p = ParsedPrompt::parse("<rec-block>").unwrap();
185 let ctx = PromptContext {
186 top: 1, bottom: 3, total: 3,
187 rec_top: 1, rec_bottom: 2, rec_total: 2,
188 records_mode: true,
189 ..Default::default()
190 };
191 assert_eq!(p.render(&ctx), "L1-3/3 R1-2/2");
192 }
193
194 #[test]
195 fn rec_block_renders_line_mode_form() {
196 let p = ParsedPrompt::parse("<rec-block>").unwrap();
197 let ctx = PromptContext {
198 top: 1, bottom: 3, total: 3,
199 records_mode: false,
200 ..Default::default()
201 };
202 assert_eq!(p.render(&ctx), "1-3/3");
203 }
204
205 #[test]
206 fn render_preprocess_failed_tag_resolves_when_populated() {
207 let p = ParsedPrompt::parse("<preprocess-failed-tag>").unwrap();
208 let ctx = PromptContext {
209 preprocess_failed_tag: " [preprocess-failed: bad cmd]".into(),
210 ..Default::default()
211 };
212 assert_eq!(p.render(&ctx), " [preprocess-failed: bad cmd]");
213 }
214
215 #[test]
216 fn render_file_index_tag_resolves_when_populated() {
217 let p = ParsedPrompt::parse("<file-index-tag>").unwrap();
218 let ctx = PromptContext {
219 file_index_tag: " [2/3]".into(),
220 ..Default::default()
221 };
222 assert_eq!(p.render(&ctx), " [2/3]");
223 }
224
225 #[test]
226 fn render_file_index_tag_empty_when_unset() {
227 let p = ParsedPrompt::parse("<file-index-tag>").unwrap();
228 let ctx = PromptContext::default();
229 assert_eq!(p.render(&ctx), "");
230 }
231}