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    #[expect(clippy::string_slice, reason = "test input is ASCII")]
71    fn blocks(src: &str) -> Vec<(OutlineBlockKind, &str)> {
72        parse(src)
73            .expect("template parses")
74            .into_iter()
75            .map(|block| (block.kind, &src[block.start..block.end]))
76            .collect()
77    }
78
79    #[test]
80    fn every_branch_of_a_chain_closes_at_the_shared_end() {
81        let src = "{{ if a }}A{{ elif b }}B{{ else }}C{{ end }}";
82        assert_eq!(
83            blocks(src),
84            vec![
85                (OutlineBlockKind::If, src),
86                (OutlineBlockKind::Elif, "{{ elif b }}B{{ else }}C{{ end }}"),
87                (OutlineBlockKind::Else, "{{ else }}C{{ end }}"),
88            ]
89        );
90    }
91
92    #[test]
93    fn a_loop_covers_its_empty_fallback() {
94        let src = "{{ for x in xs }}{{ x }}{{ else }}none{{ end }}";
95        assert_eq!(
96            blocks(src),
97            vec![
98                (OutlineBlockKind::For, src),
99                (OutlineBlockKind::Else, "{{ else }}none{{ end }}"),
100            ]
101        );
102    }
103
104    #[test]
105    fn sections_raw_blocks_and_comments_are_all_located() {
106        let src = "{{# note #}}{{ section \"task\" }}{{ raw }}{{x}}{{ endraw }}{{ endsection }}";
107        assert_eq!(
108            blocks(src),
109            vec![
110                (OutlineBlockKind::Comment, "{{# note #}}"),
111                (
112                    OutlineBlockKind::Section,
113                    "{{ section \"task\" }}{{ raw }}{{x}}{{ endraw }}{{ endsection }}"
114                ),
115                (OutlineBlockKind::Raw, "{{ raw }}{{x}}{{ endraw }}"),
116            ]
117        );
118    }
119
120    #[test]
121    fn nested_blocks_come_back_in_source_order() {
122        let src = "{{ for x in xs }}{{ if x }}y{{ end }}{{ end }}";
123        assert_eq!(
124            blocks(src),
125            vec![
126                (OutlineBlockKind::For, src),
127                (OutlineBlockKind::If, "{{ if x }}y{{ end }}"),
128            ]
129        );
130    }
131
132    #[test]
133    fn loops_report_the_names_they_bind() {
134        let bindings = |src: &str| {
135            parse(src)
136                .expect("parses")
137                .into_iter()
138                .filter(|block| block.kind == OutlineBlockKind::For)
139                .map(|block| block.bindings)
140                .collect::<Vec<_>>()
141        };
142        assert_eq!(
143            bindings("{{ for item in items }}x{{ end }}"),
144            vec![vec!["item".to_string()]]
145        );
146        assert_eq!(
147            bindings("{{ for key, value in dict }}x{{ end }}"),
148            vec![vec!["key".to_string(), "value".to_string()]]
149        );
150        // The `{{ else }}` form still reports the loop's bindings.
151        assert_eq!(
152            bindings("{{ for x in xs }}a{{ else }}b{{ end }}"),
153            vec![vec!["x".to_string()]]
154        );
155    }
156
157    #[test]
158    fn only_loops_bind_names() {
159        for block in
160            parse("{{ if a }}x{{ end }}{{ section \"task\" }}y{{ endsection }}").expect("parses")
161        {
162            assert!(
163                block.bindings.is_empty(),
164                "{:?} should bind nothing",
165                block.kind
166            );
167        }
168    }
169
170    #[test]
171    fn a_template_without_blocks_has_an_empty_outline() {
172        assert_eq!(blocks("Hello {{ name }}, welcome."), Vec::new());
173    }
174
175    #[test]
176    fn an_unclosed_block_reports_where_it_opened() {
177        let error = parse("intro\n{{ if a }}\nbody\n").expect_err("unclosed `{{ if }}`");
178        assert_eq!((error.line, error.col), (2, 1));
179        assert!(
180            error.message.contains("missing matching `{{ end }}`"),
181            "unexpected message: {}",
182            error.message
183        );
184    }
185
186    #[test]
187    fn trim_markers_do_not_shift_a_block_off_its_directive() {
188        let src = "{{- if a -}}A{{- end -}}";
189        assert_eq!(blocks(src), vec![(OutlineBlockKind::If, src)]);
190    }
191}