Skip to main content

rustlavel_view/
lexer.rs

1//! Splitting template source into literal text, echoes, and directives.
2//!
3//! The lexer knows the *shape* of the syntax but nothing about its meaning:
4//! `@endif` is just a directive here, and whether it has a matching `@if` is
5//! the parser's problem.
6
7use crate::source::syntax_error;
8use rustlavel_core::Result;
9
10/// Directives that take a parenthesised argument list.
11const WITH_ARGUMENTS: [&str; 9] =
12    ["if", "elseif", "foreach", "extends", "section", "yield", "include", "lang", "route"];
13
14/// Directives that stand alone.
15const WITHOUT_ARGUMENTS: [&str; 4] = ["else", "endif", "endforeach", "endsection"];
16
17/// Directives that structure a template rather than emit anything.
18///
19/// When one of these sits alone on a line, the line disappears with it —
20/// otherwise every `@if` would leave a blank line in the response body and the
21/// rendered HTML would be unreadable in a browser's view-source.
22const STRUCTURAL: [&str; 9] = [
23    "if",
24    "elseif",
25    "else",
26    "endif",
27    "foreach",
28    "endforeach",
29    "section",
30    "endsection",
31    "extends",
32];
33
34/// One piece of a template, still unparsed.
35#[derive(Debug, Clone, PartialEq)]
36pub enum Token {
37    /// Literal markup, copied to the output verbatim.
38    Text(String),
39    /// `{{ expr }}` (escaped) or `{!! expr !!}` (raw).
40    Echo { source: String, offset: usize, escaped: bool },
41    /// `@name` plus the raw text between its parentheses, empty when it had none.
42    Directive { name: String, arguments: String, arguments_offset: usize, offset: usize },
43}
44
45/// Split `source` into tokens, dropping `{{-- comments --}}` entirely.
46pub fn tokenize(file: &str, source: &str) -> Result<Vec<Token>> {
47    let mut lexer = Lexer { file, source, pos: 0, text: String::new(), tokens: Vec::new() };
48    lexer.run()?;
49    Ok(lexer.tokens)
50}
51
52struct Lexer<'a> {
53    file: &'a str,
54    source: &'a str,
55    pos: usize,
56    /// Literal text accumulated since the last token was emitted.
57    text: String,
58    tokens: Vec<Token>,
59}
60
61impl Lexer<'_> {
62    fn run(&mut self) -> Result<()> {
63        // Copied out so the slices below borrow the source, not `self`.
64        let source = self.source;
65
66        while self.pos < source.len() {
67            let rest = &source[self.pos..];
68
69            // Nothing interesting can start except at a `{` or an `@`, so skip
70            // straight to the next one and copy everything before it.
71            match rest.find(['{', '@']) {
72                None => {
73                    self.text.push_str(rest);
74                    self.pos = source.len();
75                    continue;
76                }
77                Some(0) => {}
78                Some(index) => {
79                    self.text.push_str(&rest[..index]);
80                    self.pos += index;
81                    continue;
82                }
83            }
84
85            let rest = &source[self.pos..];
86            if rest.starts_with("{{--") {
87                let end = self.find_close("--}}", self.pos + 4, false, "{{--")?;
88                self.pos = end + 4;
89            } else if rest.starts_with("{!!") {
90                self.echo(3, "!!}", false)?;
91            } else if rest.starts_with("{{") {
92                self.echo(2, "}}", true)?;
93            } else if rest.starts_with("@@") {
94                // `@@if` is how a template writes a literal `@if`.
95                self.text.push('@');
96                self.pos += 2;
97            } else if rest.starts_with("@{{") {
98                // `@{{ x }}` hands the braces to a client-side framework.
99                self.text.push_str("{{");
100                self.pos += 3;
101            } else if rest.starts_with('@') && self.directive()? {
102                continue;
103            } else {
104                // A lone `{`, or an `@` that starts no known directive — an
105                // email address or a CSS `@media` block. Text, not syntax.
106                self.text.push(source.as_bytes()[self.pos] as char);
107                self.pos += 1;
108            }
109        }
110
111        self.flush();
112        Ok(())
113    }
114
115    /// Emit the pending literal text, if any.
116    fn flush(&mut self) {
117        if !self.text.is_empty() {
118            self.tokens.push(Token::Text(std::mem::take(&mut self.text)));
119        }
120    }
121
122    fn echo(&mut self, open: usize, close: &str, escaped: bool) -> Result<()> {
123        let source = self.source;
124        let start = self.pos + open;
125        let end = self.find_close(close, start, true, &source[self.pos..start])?;
126        self.flush();
127        self.tokens.push(Token::Echo {
128            source: source[start..end].to_string(),
129            offset: start,
130            escaped,
131        });
132        self.pos = end + close.len();
133        Ok(())
134    }
135
136    /// Try to read a directive at the current `@`.
137    ///
138    /// Returns `false` when the name is not one we know, leaving the caller to
139    /// treat the `@` as text.
140    fn directive(&mut self) -> Result<bool> {
141        let source = self.source;
142        let start = self.pos;
143        let bytes = source.as_bytes();
144        let mut end = start + 1;
145        while matches!(bytes.get(end), Some(byte) if byte.is_ascii_alphabetic()) {
146            end += 1;
147        }
148        let name = &source[start + 1..end];
149
150        // `@php` is not supported and never will be: a template that can run
151        // code stops being reviewable as markup, and the compiler cannot see
152        // inside it. Say so instead of silently rendering `@php` as text.
153        if name == "php" || name == "endphp" {
154            return Err(syntax_error(
155                self.file,
156                self.source,
157                start,
158                "`@php` is not supported — compute the value in Rust and pass it to the view",
159            ));
160        }
161
162        let takes_arguments = WITH_ARGUMENTS.contains(&name);
163        if !takes_arguments && !WITHOUT_ARGUMENTS.contains(&name) {
164            return Ok(false);
165        }
166
167        let (arguments, arguments_offset, mut after) = if takes_arguments {
168            if bytes.get(end) != Some(&b'(') {
169                return Err(syntax_error(
170                    self.file,
171                    self.source,
172                    end,
173                    format!("expected `(` after `@{name}`"),
174                ));
175            }
176            let close = self.matching_paren(end)?;
177            (source[end + 1..close].to_string(), end + 1, close + 1)
178        } else {
179            (String::new(), end, end)
180        };
181
182        // Swallow the line when the directive owns it by itself.
183        if STRUCTURAL.contains(&name)
184            && self.at_line_start(start)
185            && let Some(next_line) = self.line_end(after)
186        {
187            self.text.truncate(self.text.trim_end_matches([' ', '\t']).len());
188            after = next_line;
189        }
190
191        let name = name.to_string();
192        self.flush();
193        self.tokens.push(Token::Directive { name, arguments, arguments_offset, offset: start });
194        self.pos = after;
195        Ok(true)
196    }
197
198    /// Find `close` at or after `from`, optionally stepping over string
199    /// literals so that `{{ "}}" }}` is not cut in half.
200    fn find_close(
201        &self,
202        close: &str,
203        from: usize,
204        quoted: bool,
205        opener: &str,
206    ) -> Result<usize> {
207        let bytes = self.source.as_bytes();
208        let mut i = from;
209        while i < bytes.len() {
210            if quoted && matches!(bytes[i], b'"' | b'\'') {
211                i = self.skip_string(i);
212                continue;
213            }
214            if bytes[i..].starts_with(close.as_bytes()) {
215                return Ok(i);
216            }
217            i += 1;
218        }
219        Err(syntax_error(
220            self.file,
221            self.source,
222            from - opener.len(),
223            format!("`{opener}` is never closed — add a matching `{close}`"),
224        ))
225    }
226
227    /// The offset just past a string literal starting at `start`.
228    fn skip_string(&self, start: usize) -> usize {
229        let bytes = self.source.as_bytes();
230        let quote = bytes[start];
231        let mut i = start + 1;
232        while i < bytes.len() {
233            match bytes[i] {
234                b'\\' => i += 2,
235                byte if byte == quote => return i + 1,
236                _ => i += 1,
237            }
238        }
239        i
240    }
241
242    /// The offset of the `)` matching the `(` at `open`.
243    fn matching_paren(&self, open: usize) -> Result<usize> {
244        let bytes = self.source.as_bytes();
245        let mut depth = 0usize;
246        let mut i = open;
247        while i < bytes.len() {
248            match bytes[i] {
249                b'"' | b'\'' => {
250                    i = self.skip_string(i);
251                    continue;
252                }
253                b'(' => depth += 1,
254                b')' => {
255                    depth -= 1;
256                    if depth == 0 {
257                        return Ok(i);
258                    }
259                }
260                _ => {}
261            }
262            i += 1;
263        }
264        Err(syntax_error(self.file, self.source, open, "`(` is never closed"))
265    }
266
267    /// True when only spaces and tabs sit between `offset` and its line start.
268    fn at_line_start(&self, offset: usize) -> bool {
269        self.source[..offset]
270            .bytes()
271            .rev()
272            .find(|byte| !matches!(byte, b' ' | b'\t'))
273            .is_none_or(|byte| byte == b'\n')
274    }
275
276    /// The offset of the next line, when only spaces and tabs follow `offset`.
277    fn line_end(&self, offset: usize) -> Option<usize> {
278        let bytes = self.source.as_bytes();
279        let mut i = offset;
280        while matches!(bytes.get(i), Some(b' ' | b'\t')) {
281            i += 1;
282        }
283        match bytes.get(i) {
284            Some(b'\n') => Some(i + 1),
285            Some(b'\r') if bytes.get(i + 1) == Some(&b'\n') => Some(i + 2),
286            None => Some(i),
287            _ => None,
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use rustlavel_core::Error;
296
297    fn tokens(source: &str) -> Vec<Token> {
298        tokenize("test", source).unwrap()
299    }
300
301    fn text_of(source: &str) -> String {
302        tokens(source)
303            .into_iter()
304            .filter_map(|token| match token {
305                Token::Text(text) => Some(text),
306                _ => None,
307            })
308            .collect()
309    }
310
311    #[test]
312    fn separates_text_from_echoes() {
313        assert_eq!(
314            tokens("<h1>{{ title }}</h1>"),
315            vec![
316                Token::Text("<h1>".into()),
317                Token::Echo { source: " title ".into(), offset: 6, escaped: true },
318                Token::Text("</h1>".into()),
319            ]
320        );
321    }
322
323    #[test]
324    fn a_raw_echo_is_marked_unescaped() {
325        assert_eq!(
326            tokens("{!! body !!}"),
327            vec![Token::Echo { source: " body ".into(), offset: 3, escaped: false }]
328        );
329    }
330
331    #[test]
332    fn comments_are_stripped_entirely() {
333        assert_eq!(text_of("a{{-- secret {{ x }} --}}b"), "ab");
334    }
335
336    #[test]
337    fn reads_a_directive_with_its_arguments() {
338        assert_eq!(
339            tokens("@if(a == \")\")x@endif"),
340            vec![
341                Token::Directive {
342                    name: "if".into(),
343                    arguments: "a == \")\"".into(),
344                    arguments_offset: 4,
345                    offset: 0,
346                },
347                Token::Text("x".into()),
348                Token::Directive {
349                    name: "endif".into(),
350                    arguments: String::new(),
351                    arguments_offset: 20,
352                    offset: 14,
353                },
354            ]
355        );
356    }
357
358    #[test]
359    fn an_unknown_at_sign_stays_text() {
360        assert_eq!(text_of("@media print { a { color: #000 } } ada@example.com"),
361                   "@media print { a { color: #000 } } ada@example.com");
362        assert_eq!(text_of("@@if(x)"), "@if(x)");
363        assert_eq!(text_of("@{{ vue }}"), "{{ vue }}");
364    }
365
366    #[test]
367    fn a_structural_directive_alone_on_a_line_takes_the_line_with_it() {
368        let source = "<ul>\n  @foreach(items as item)\n    <li></li>\n  @endforeach\n</ul>\n";
369
370        assert_eq!(text_of(source), "<ul>\n    <li></li>\n</ul>\n");
371    }
372
373    #[test]
374    fn an_inline_directive_keeps_the_surrounding_spacing() {
375        assert_eq!(text_of("a @if(x)b@endif c"), "a b c");
376    }
377
378    #[test]
379    fn php_blocks_are_rejected_by_name() {
380        let error = tokenize("test", "<p>\n@php $x = 1; @endphp\n").unwrap_err();
381
382        match error {
383            Error::Template { line, message, .. } => {
384                assert_eq!(line, 2);
385                assert!(message.contains("@php"), "{message}");
386            }
387            other => panic!("expected a template error, got {other:?}"),
388        }
389    }
390
391    #[test]
392    fn an_unclosed_echo_is_reported_where_it_opened() {
393        match tokenize("test", "ok\n{{ title\n").unwrap_err() {
394            Error::Template { line, column, .. } => assert_eq!((line, column), (2, 1)),
395            other => panic!("expected a template error, got {other:?}"),
396        }
397    }
398}