Skip to main content

harn_vm/stdlib/template/
outline.rs

1//! Source-geometry projection of a prompt template: where every block
2//! construct opens and where it closes.
3//!
4//! The render AST deliberately drops geometry — it only needs enough
5//! position to attribute output and errors back to a directive. Editors
6//! need the opposite: the byte range a construct covers, so a folding
7//! range or a block-aware selection lands on real boundaries.
8//!
9//! This is a read-only view produced by the one template parser (the
10//! same call `render` makes), so it can never disagree with what the
11//! engine actually parsed. It is the sibling of [`super::lint`], which
12//! projects the same parse for lint rules.
13
14use super::error::TemplateParseError;
15use super::parser::parse_outline;
16
17/// One foldable block in a template, as a byte range over the source
18/// that was parsed.
19///
20/// `start` is the first byte of the directive that opens the block;
21/// `end` is one past the last byte of the directive that closes it. A
22/// branch inside an `{{ if }}` chain opens at its own `{{ elif }}` /
23/// `{{ else }}` and closes at the chain's shared `{{ end }}`, so nested
24/// branches produce nested ranges.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct OutlineBlock {
27    pub kind: OutlineBlockKind,
28    pub start: usize,
29    pub end: usize,
30    /// Names the block binds inside its body: the value variable, and
31    /// the key variable when present, of `{{ for k, v in dict }}`.
32    /// Empty for every other kind.
33    pub bindings: Vec<String>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum OutlineBlockKind {
38    /// `{{ if .. }}` through the chain's `{{ end }}`.
39    If,
40    /// `{{ elif .. }}` through the chain's `{{ end }}`.
41    Elif,
42    /// `{{ else }}` through the enclosing `{{ end }}`. Covers both the
43    /// `{{ if }}` and the `{{ for }}` (empty-iterable) forms.
44    Else,
45    /// `{{ for .. }}` through its `{{ end }}`.
46    For,
47    /// `{{ section ".." }}` through its `{{ endsection }}`.
48    Section,
49    /// `{{ raw }}` through its `{{ endraw }}`.
50    Raw,
51    /// `{{# .. #}}`.
52    Comment,
53}
54
55/// Parse `src` and return its block outline in source order.
56///
57/// Returns `Err` when the template doesn't parse — the same failure
58/// `render` would report, so a caller that already surfaced a parse
59/// diagnostic can simply drop the outline.
60pub fn parse(src: &str) -> Result<Vec<OutlineBlock>, TemplateParseError> {
61    parse_outline(src).map_err(TemplateParseError::from)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::{parse, OutlineBlockKind};
67
68    /// Every block as `(kind, the source text it covers)`, so an
69    /// expectation reads as the template it describes.
70    fn blocks(src: &str) -> Vec<(OutlineBlockKind, &str)> {
71        parse(src)
72            .expect("template parses")
73            .into_iter()
74            .map(|block| (block.kind, &src[block.start..block.end]))
75            .collect()
76    }
77
78    #[test]
79    fn every_branch_of_a_chain_closes_at_the_shared_end() {
80        let src = "{{ if a }}A{{ elif b }}B{{ else }}C{{ end }}";
81        assert_eq!(
82            blocks(src),
83            vec![
84                (OutlineBlockKind::If, src),
85                (OutlineBlockKind::Elif, "{{ elif b }}B{{ else }}C{{ end }}"),
86                (OutlineBlockKind::Else, "{{ else }}C{{ end }}"),
87            ]
88        );
89    }
90
91    #[test]
92    fn a_loop_covers_its_empty_fallback() {
93        let src = "{{ for x in xs }}{{ x }}{{ else }}none{{ end }}";
94        assert_eq!(
95            blocks(src),
96            vec![
97                (OutlineBlockKind::For, src),
98                (OutlineBlockKind::Else, "{{ else }}none{{ end }}"),
99            ]
100        );
101    }
102
103    #[test]
104    fn sections_raw_blocks_and_comments_are_all_located() {
105        let src = "{{# note #}}{{ section \"task\" }}{{ raw }}{{x}}{{ endraw }}{{ endsection }}";
106        assert_eq!(
107            blocks(src),
108            vec![
109                (OutlineBlockKind::Comment, "{{# note #}}"),
110                (
111                    OutlineBlockKind::Section,
112                    "{{ section \"task\" }}{{ raw }}{{x}}{{ endraw }}{{ endsection }}"
113                ),
114                (OutlineBlockKind::Raw, "{{ raw }}{{x}}{{ endraw }}"),
115            ]
116        );
117    }
118
119    #[test]
120    fn nested_blocks_come_back_in_source_order() {
121        let src = "{{ for x in xs }}{{ if x }}y{{ end }}{{ end }}";
122        assert_eq!(
123            blocks(src),
124            vec![
125                (OutlineBlockKind::For, src),
126                (OutlineBlockKind::If, "{{ if x }}y{{ end }}"),
127            ]
128        );
129    }
130
131    #[test]
132    fn loops_report_the_names_they_bind() {
133        let bindings = |src: &str| {
134            parse(src)
135                .expect("parses")
136                .into_iter()
137                .filter(|block| block.kind == OutlineBlockKind::For)
138                .map(|block| block.bindings)
139                .collect::<Vec<_>>()
140        };
141        assert_eq!(
142            bindings("{{ for item in items }}x{{ end }}"),
143            vec![vec!["item".to_string()]]
144        );
145        assert_eq!(
146            bindings("{{ for key, value in dict }}x{{ end }}"),
147            vec![vec!["key".to_string(), "value".to_string()]]
148        );
149        // The `{{ else }}` form still reports the loop's bindings.
150        assert_eq!(
151            bindings("{{ for x in xs }}a{{ else }}b{{ end }}"),
152            vec![vec!["x".to_string()]]
153        );
154    }
155
156    #[test]
157    fn only_loops_bind_names() {
158        for block in
159            parse("{{ if a }}x{{ end }}{{ section \"task\" }}y{{ endsection }}").expect("parses")
160        {
161            assert!(
162                block.bindings.is_empty(),
163                "{:?} should bind nothing",
164                block.kind
165            );
166        }
167    }
168
169    #[test]
170    fn a_template_without_blocks_has_an_empty_outline() {
171        assert_eq!(blocks("Hello {{ name }}, welcome."), Vec::new());
172    }
173
174    #[test]
175    fn an_unclosed_block_reports_where_it_opened() {
176        let error = parse("intro\n{{ if a }}\nbody\n").expect_err("unclosed `{{ if }}`");
177        assert_eq!((error.line, error.col), (2, 1));
178        assert!(
179            error.message.contains("missing matching `{{ end }}`"),
180            "unexpected message: {}",
181            error.message
182        );
183    }
184
185    #[test]
186    fn trim_markers_do_not_shift_a_block_off_its_directive() {
187        let src = "{{- if a -}}A{{- end -}}";
188        assert_eq!(blocks(src), vec![(OutlineBlockKind::If, src)]);
189    }
190}