harn_vm/stdlib/template/
outline.rs1use super::error::TemplateParseError;
15use super::parser::parse_outline;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct OutlineBlock {
27 pub kind: OutlineBlockKind,
28 pub start: usize,
29 pub end: usize,
30 pub bindings: Vec<String>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum OutlineBlockKind {
38 If,
40 Elif,
42 Else,
45 For,
47 Section,
49 Raw,
51 Comment,
53}
54
55pub 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 #[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 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}