Skip to main content

tess/
prompt.rs

1//! Status-line prompt customization. Wraps the existing `DisplayTemplate`
2//! parser from `format.rs`, validating against a fixed set of prompt-only
3//! placeholder names. Rendered against a `PromptContext` populated by the
4//! viewport on every frame.
5
6use crate::format::DisplayTemplate;
7
8/// All placeholders that resolve in a prompt template. Validation against
9/// this list happens at parse time; unknown placeholders produce a clear
10/// startup error.
11const 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    "tag-tag",
33];
34
35#[derive(Debug, Clone)]
36pub struct ParsedPrompt {
37    template: DisplayTemplate,
38}
39
40impl ParsedPrompt {
41    /// Parse a prompt template. Validates that all `<field>` placeholders
42    /// are known prompt fields. Returns the parse error on failure.
43    pub fn parse(source: &str) -> Result<Self, String> {
44        let field_names: Vec<String> =
45            PROMPT_FIELDS.iter().map(|s| s.to_string()).collect();
46        let template = DisplayTemplate::compile(source, &field_names)?;
47        Ok(Self { template })
48    }
49
50    /// Render the prompt against a context. Missing fields render as empty.
51    pub fn render(&self, ctx: &PromptContext) -> String {
52        self.template.render(|name| ctx.lookup(name))
53    }
54
55    pub fn source(&self) -> &str {
56        self.template.source()
57    }
58}
59
60/// All data the prompt template can resolve. Populated by the viewport
61/// once per frame and passed to `ParsedPrompt::render`.
62#[derive(Debug, Default)]
63pub struct PromptContext {
64    pub label: String,
65    pub top: usize,
66    pub bottom: usize,
67    pub total: usize,
68    pub pct: u8,
69    pub rec_top: usize,
70    pub rec_bottom: usize,
71    pub rec_total: usize,
72    pub records_mode: bool,
73    pub wrap_offset: String,
74    pub format_tag: String,
75    pub filter_tag: String,
76    pub grep_tag: String,
77    pub hide_tag: String,
78    pub search_tag: String,
79    pub pretty_tag: String,
80    pub live_tag: String,
81    pub follow_tag: String,
82    pub preprocess_failed_tag: String,
83    pub file_index_tag: String,
84    pub tag_tag: String,
85}
86
87impl PromptContext {
88    fn lookup(&self, name: &str) -> Option<String> {
89        match name {
90            "label" => Some(self.label.clone()),
91            "top" => Some(self.top.to_string()),
92            "bottom" => Some(self.bottom.to_string()),
93            "total" => Some(self.total.to_string()),
94            "pct" => Some(self.pct.to_string()),
95            "rec-top" => Some(self.rec_top.to_string()),
96            "rec-bottom" => Some(self.rec_bottom.to_string()),
97            "rec-total" => Some(self.rec_total.to_string()),
98            "rec-block" => Some(if self.records_mode {
99                format!(
100                    "L{}-{}/{}  R{}-{}/{}",
101                    self.top, self.bottom, self.total,
102                    self.rec_top, self.rec_bottom, self.rec_total,
103                )
104            } else {
105                format!("{}-{}/{}", self.top, self.bottom, self.total)
106            }),
107            "wrap-offset" => Some(self.wrap_offset.clone()),
108            "format-tag" => Some(self.format_tag.clone()),
109            "filter-tag" => Some(self.filter_tag.clone()),
110            "grep-tag" => Some(self.grep_tag.clone()),
111            "hide-tag" => Some(self.hide_tag.clone()),
112            "search-tag" => Some(self.search_tag.clone()),
113            "pretty-tag" => Some(self.pretty_tag.clone()),
114            "live-tag" => Some(self.live_tag.clone()),
115            "follow-tag" => Some(self.follow_tag.clone()),
116            "preprocess-failed-tag" => Some(self.preprocess_failed_tag.clone()),
117            "file-index-tag" => Some(self.file_index_tag.clone()),
118            "tag-tag" => Some(self.tag_tag.clone()),
119            _ => None,
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parse_literal_only_template() {
130        let p = ParsedPrompt::parse("hello").unwrap();
131        let ctx = PromptContext::default();
132        assert_eq!(p.render(&ctx), "hello");
133    }
134
135    #[test]
136    fn parse_field_template() {
137        let p = ParsedPrompt::parse("<label> <pct>%").unwrap();
138        let ctx = PromptContext {
139            label: "file.log".into(),
140            pct: 42,
141            ..Default::default()
142        };
143        assert_eq!(p.render(&ctx), "file.log 42%");
144    }
145
146    #[test]
147    fn parse_rejects_unknown_field() {
148        let err = ParsedPrompt::parse("<bogus>").unwrap_err();
149        assert!(err.contains("bogus"), "error mentions field name: {err}");
150    }
151
152    #[test]
153    fn parse_handles_escaped_left_angle() {
154        // `\<` is the escape for a literal `<`; `>` outside a field is always literal.
155        let p = ParsedPrompt::parse(r"\<not a field>").unwrap();
156        let ctx = PromptContext::default();
157        assert_eq!(p.render(&ctx), "<not a field>");
158    }
159
160    #[test]
161    fn parse_handles_escaped_backslash() {
162        let p = ParsedPrompt::parse(r"a\\b").unwrap();
163        let ctx = PromptContext::default();
164        assert_eq!(p.render(&ctx), "a\\b");
165    }
166
167    #[test]
168    fn render_resolves_empty_tags_to_nothing() {
169        let p = ParsedPrompt::parse("<label><filter-tag><grep-tag>").unwrap();
170        let ctx = PromptContext { label: "x".into(), ..Default::default() };
171        assert_eq!(p.render(&ctx), "x");
172    }
173
174    #[test]
175    fn render_resolves_populated_tags() {
176        let p = ParsedPrompt::parse("<grep-tag><hide-tag>").unwrap();
177        let ctx = PromptContext {
178            grep_tag: "  [grep]".into(),
179            hide_tag: "  [hide]".into(),
180            ..Default::default()
181        };
182        assert_eq!(p.render(&ctx), "  [grep]  [hide]");
183    }
184
185    #[test]
186    fn rec_block_renders_records_mode_form() {
187        let p = ParsedPrompt::parse("<rec-block>").unwrap();
188        let ctx = PromptContext {
189            top: 1, bottom: 3, total: 3,
190            rec_top: 1, rec_bottom: 2, rec_total: 2,
191            records_mode: true,
192            ..Default::default()
193        };
194        assert_eq!(p.render(&ctx), "L1-3/3  R1-2/2");
195    }
196
197    #[test]
198    fn rec_block_renders_line_mode_form() {
199        let p = ParsedPrompt::parse("<rec-block>").unwrap();
200        let ctx = PromptContext {
201            top: 1, bottom: 3, total: 3,
202            records_mode: false,
203            ..Default::default()
204        };
205        assert_eq!(p.render(&ctx), "1-3/3");
206    }
207
208    #[test]
209    fn render_preprocess_failed_tag_resolves_when_populated() {
210        let p = ParsedPrompt::parse("<preprocess-failed-tag>").unwrap();
211        let ctx = PromptContext {
212            preprocess_failed_tag: "  [preprocess-failed: bad cmd]".into(),
213            ..Default::default()
214        };
215        assert_eq!(p.render(&ctx), "  [preprocess-failed: bad cmd]");
216    }
217
218    #[test]
219    fn render_file_index_tag_resolves_when_populated() {
220        let p = ParsedPrompt::parse("<file-index-tag>").unwrap();
221        let ctx = PromptContext {
222            file_index_tag: "  [2/3]".into(),
223            ..Default::default()
224        };
225        assert_eq!(p.render(&ctx), "  [2/3]");
226    }
227
228    #[test]
229    fn render_file_index_tag_empty_when_unset() {
230        let p = ParsedPrompt::parse("<file-index-tag>").unwrap();
231        let ctx = PromptContext::default();
232        assert_eq!(p.render(&ctx), "");
233    }
234
235    #[test]
236    fn render_tag_tag_resolves_when_populated() {
237        let p = ParsedPrompt::parse("<tag-tag>").unwrap();
238        let ctx = PromptContext {
239            tag_tag: "  [tag: foo (2/3)]".into(),
240            ..Default::default()
241        };
242        assert_eq!(p.render(&ctx), "  [tag: foo (2/3)]");
243    }
244
245    #[test]
246    fn render_tag_tag_empty_when_unset() {
247        let p = ParsedPrompt::parse("<tag-tag>").unwrap();
248        let ctx = PromptContext::default();
249        assert_eq!(p.render(&ctx), "");
250    }
251}