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];
33
34#[derive(Debug, Clone)]
35pub struct ParsedPrompt {
36    template: DisplayTemplate,
37}
38
39impl ParsedPrompt {
40    /// Parse a prompt template. Validates that all `<field>` placeholders
41    /// are known prompt fields. Returns the parse error on failure.
42    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    /// Render the prompt against a context. Missing fields render as empty.
50    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/// All data the prompt template can resolve. Populated by the viewport
60/// once per frame and passed to `ParsedPrompt::render`.
61#[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        // `\<` is the escape for a literal `<`; `>` outside a field is always literal.
152        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}