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