Skip to main content

webui_parser/
handlebars_parser.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use crate::Result;
5use webui_protocol::WebUIFragment;
6
7/// Parser for handlebars-style template syntax.
8pub struct HandlebarsParser;
9
10impl HandlebarsParser {
11    /// Create a new handlebars parser.
12    pub fn new() -> Self {
13        Self
14    }
15
16    /// Parse handlebars expressions from text, handling edge cases with
17    /// triple/double brace disambiguation.
18    ///
19    /// Rules for consecutive opening braces (N) without matching `}}}`:
20    /// - N >= 5: first (N-2) braces are raw, remaining `{{…}}` is a valid double
21    /// - N == 3 or 4: entire sequence through `}}` is treated as raw text
22    pub fn parse(&self, text: &str) -> Result<Vec<WebUIFragment>> {
23        let bytes = text.as_bytes();
24        let len = bytes.len();
25        let mut fragments = Vec::new();
26        let mut raw_buf = String::new();
27        let mut pos = 0;
28
29        while pos < len {
30            let remaining = &text[pos..];
31            let Some(offset) = remaining.find("{{") else {
32                raw_buf.push_str(&text[pos..]);
33                break;
34            };
35
36            let start = pos + offset;
37            if start > pos {
38                raw_buf.push_str(&text[pos..start]);
39            }
40
41            // Count consecutive opening braces
42            let mut brace_count = 0;
43            let mut i = start;
44            while i < len && bytes[i] == b'{' {
45                brace_count += 1;
46                i += 1;
47            }
48
49            if brace_count >= 3 {
50                // Try triple brace: look for }}}
51                if let Some(end_offset) = text[i..].find("}}}") {
52                    let var_end = i + end_offset;
53                    let var_name = text[i..var_end].trim();
54                    if !var_name.is_empty() {
55                        // Extra braces beyond 3 become raw prefix
56                        for _ in 0..(brace_count - 3) {
57                            raw_buf.push('{');
58                        }
59                        if !raw_buf.is_empty() {
60                            fragments.push(WebUIFragment::raw(std::mem::take(&mut raw_buf)));
61                        }
62                        fragments.push(WebUIFragment::signal(var_name.to_string(), true));
63                        pos = var_end + 3;
64                        continue;
65                    }
66                }
67
68                // }}} not found. If N >= 5 we can extract a valid {{…}} from the tail.
69                if brace_count >= 5 {
70                    if let Some(end_offset) = text[i..].find("}}") {
71                        let var_end = i + end_offset;
72                        let var_name = text[i..var_end].trim();
73                        if !var_name.is_empty() {
74                            for _ in 0..(brace_count - 2) {
75                                raw_buf.push('{');
76                            }
77                            if !raw_buf.is_empty() {
78                                fragments.push(WebUIFragment::raw(std::mem::take(&mut raw_buf)));
79                            }
80                            fragments.push(WebUIFragment::signal(var_name.to_string(), false));
81                            pos = var_end + 2;
82                            continue;
83                        }
84                    }
85                }
86
87                // Failed triple (N < 5): consume everything through }} as raw
88                if let Some(end_offset) = text[i..].find("}}") {
89                    let raw_end = i + end_offset + 2;
90                    raw_buf.push_str(&text[start..raw_end]);
91                    pos = raw_end;
92                } else {
93                    raw_buf.push_str(&text[start..]);
94                    pos = len;
95                }
96                continue;
97            }
98
99            // Double brace: look for }}
100            if let Some(end_offset) = text[i..].find("}}") {
101                let var_end = i + end_offset;
102                let var_name = text[i..var_end].trim();
103                if !var_name.is_empty() {
104                    if !raw_buf.is_empty() {
105                        fragments.push(WebUIFragment::raw(std::mem::take(&mut raw_buf)));
106                    }
107                    fragments.push(WebUIFragment::signal(var_name.to_string(), false));
108                    pos = var_end + 2;
109                    continue;
110                }
111            }
112
113            // No valid closing — rest is raw
114            raw_buf.push_str(&text[start..]);
115            pos = len;
116        }
117
118        if !raw_buf.is_empty() {
119            fragments.push(WebUIFragment::raw(raw_buf));
120        }
121
122        Ok(fragments)
123    }
124}
125
126impl Default for HandlebarsParser {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use webui_protocol::web_ui_fragment::Fragment;
136
137    #[test]
138    fn test_parse_plain_text() {
139        let parser = HandlebarsParser::new();
140        let result = parser
141            .parse("Hello, World!")
142            .expect("Failed to parse plain text");
143
144        assert_eq!(result.len(), 1);
145        match result[0].fragment.as_ref() {
146            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "Hello, World!"),
147            _ => panic!("Expected Raw fragment"),
148        }
149    }
150
151    #[test]
152    fn test_parse_double_brace() {
153        let parser = HandlebarsParser::new();
154        let result = parser
155            .parse("Hello, {{name}}!")
156            .expect("Failed to parse double brace syntax");
157
158        assert_eq!(result.len(), 3);
159
160        match result[0].fragment.as_ref() {
161            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "Hello, "),
162            _ => panic!("Expected Raw fragment"),
163        }
164
165        match result[1].fragment.as_ref() {
166            Some(Fragment::Signal(signal)) => {
167                assert_eq!(signal.value, "name");
168                assert!(!signal.raw);
169            }
170            _ => panic!("Expected Signal fragment"),
171        }
172
173        match result[2].fragment.as_ref() {
174            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "!"),
175            _ => panic!("Expected Raw fragment"),
176        }
177    }
178
179    #[test]
180    fn test_parse_triple_brace() {
181        let parser = HandlebarsParser::new();
182        let result = parser
183            .parse("Content: {{{html_content}}}")
184            .expect("Failed to parse triple brace syntax");
185
186        assert_eq!(result.len(), 2);
187
188        match result[0].fragment.as_ref() {
189            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "Content: "),
190            _ => panic!("Expected Raw fragment"),
191        }
192
193        match result[1].fragment.as_ref() {
194            Some(Fragment::Signal(signal)) => {
195                assert_eq!(signal.value, "html_content");
196                assert!(signal.raw);
197            }
198            _ => panic!("Expected Signal fragment"),
199        }
200    }
201
202    #[test]
203    fn test_mixed_braces() {
204        let parser = HandlebarsParser::new();
205        let result = parser
206            .parse("Hello, {{name}}! {{{html_content}}}")
207            .expect("Failed to parse mixed brace syntax");
208
209        assert_eq!(result.len(), 4);
210
211        match result[0].fragment.as_ref() {
212            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "Hello, "),
213            _ => panic!("Expected Raw fragment"),
214        }
215
216        match result[1].fragment.as_ref() {
217            Some(Fragment::Signal(signal)) => {
218                assert_eq!(signal.value, "name");
219                assert!(!signal.raw);
220            }
221            _ => panic!("Expected Signal fragment"),
222        }
223
224        match result[2].fragment.as_ref() {
225            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "! "),
226            _ => panic!("Expected Raw fragment"),
227        }
228
229        match result[3].fragment.as_ref() {
230            Some(Fragment::Signal(signal)) => {
231                assert_eq!(signal.value, "html_content");
232                assert!(signal.raw);
233            }
234            _ => panic!("Expected Signal fragment"),
235        }
236    }
237
238    #[test]
239    fn test_invalid_triple_open() {
240        let parser = HandlebarsParser::new();
241        let result = parser.parse("{{{invalid}}").expect("parse failed");
242        assert_eq!(result.len(), 1);
243        match result[0].fragment.as_ref() {
244            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "{{{invalid}}"),
245            _ => panic!("Expected Raw fragment"),
246        }
247    }
248
249    #[test]
250    fn test_four_open_braces() {
251        let parser = HandlebarsParser::new();
252        let result = parser.parse("{{{{invalid}}").expect("parse failed");
253        assert_eq!(result.len(), 1);
254        match result[0].fragment.as_ref() {
255            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "{{{{invalid}}"),
256            _ => panic!("Expected Raw fragment"),
257        }
258    }
259
260    #[test]
261    fn test_five_braces_with_valid_double() {
262        let parser = HandlebarsParser::new();
263        let result = parser.parse("{{{{{invalid}}").expect("parse failed");
264        assert_eq!(result.len(), 2);
265        match result[0].fragment.as_ref() {
266            Some(Fragment::Raw(raw)) => assert_eq!(raw.value, "{{{"),
267            _ => panic!("Expected Raw fragment for prefix"),
268        }
269        match result[1].fragment.as_ref() {
270            Some(Fragment::Signal(s)) => {
271                assert_eq!(s.value, "invalid");
272                assert!(!s.raw);
273            }
274            _ => panic!("Expected Signal fragment"),
275        }
276    }
277}