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