Skip to main content

carta_core/template/
parse.rs

1//! Parsing the `$`-delimited template language into a [`Template`] tree.
2//!
3//! Three passes: a lexer splits the source into literal text and directive tokens (handling `$$`
4//! escapes and `$-- …` comments inline); a whitespace pass strips the lines occupied by the
5//! directives of a block construct; and a tree builder folds the flat token list into nested
6//! `$if$`/`$for$` nodes.
7//!
8//! ## Comments
9//!
10//! `$-- …` runs to the end of its line. When the comment begins at the very start of a line (column
11//! zero, no preceding character on the line) the line's newline is swallowed with it; otherwise the
12//! preceding content and the newline survive.
13//!
14//! ## Block control directives
15//!
16//! Whether a `$if$…$endif$` or `$for$…$endfor$` construct is laid out as a block is decided by its
17//! opening directive: if `$if$`/`$for$` is the last non-whitespace on its line, the construct is a
18//! block. In a block construct, the opening's trailing newline is swallowed, and every other
19//! directive of that same construct (`$elseif$`/`$else$`/`$sep$` and the closing `$endif$`/
20//! `$endfor$`) that likewise ends its own line has its trailing newline removed. Any indentation
21//! *before* the directive survives and prefixes onto the following content, so an indented control
22//! line shifts its body rightward. When the opening shares its line with other content the construct
23//! is inline: every one of its directives — even a closing one alone on its line — keeps its
24//! surrounding whitespace and newline verbatim, so an inline `$for$` whose `$endfor$` sits on its
25//! own line emits the blank line that follows it.
26
27use super::TemplateError;
28use super::node::{Align, Expr, Node, Pipe, Template};
29
30impl Template {
31    /// Parse template source into a tree.
32    ///
33    /// # Errors
34    /// [`TemplateError`] on an unterminated directive, an unmatched `$if$`/`$for$`, a dangling
35    /// `$endif$`/`$endfor$`/`$else$`, or an unknown pipe.
36    pub fn parse(source: &str) -> Result<Template, TemplateError> {
37        let mut tokens = lex(source)?;
38        trim_standalone(&mut tokens);
39        let mut builder = Builder {
40            tokens: &tokens,
41            pos: 0,
42        };
43        let nodes = builder.sequence()?;
44        if builder.pos != tokens.len() {
45            return Err(TemplateError::new("unexpected control directive"));
46        }
47        Ok(Template { nodes })
48    }
49}
50
51/// A lexer token: literal text, or a single directive.
52#[derive(Debug, Clone)]
53enum Token {
54    Text(String),
55    Var(Expr),
56    Partial {
57        name: String,
58        map_over: Option<Expr>,
59        sep: Option<String>,
60    },
61    If(Expr),
62    ElseIf(Expr),
63    Else,
64    EndIf,
65    For(Expr),
66    Sep,
67    EndFor,
68}
69
70/// Horizontal whitespace for the standalone-line and comment rules (a newline is never "blank").
71fn is_blank(c: char) -> bool {
72    c == ' ' || c == '\t' || c == '\r'
73}
74
75fn lex(source: &str) -> Result<Vec<Token>, TemplateError> {
76    let chars: Vec<char> = source.chars().collect();
77    let mut tokens: Vec<Token> = Vec::new();
78    let mut text = String::new();
79    let mut i = 0;
80    // True at the start of a line before any character or directive on it — used to decide whether a
81    // `$-- …` comment swallows its newline.
82    let mut col_clean = true;
83
84    while let Some(&c) = chars.get(i) {
85        if c != '$' {
86            text.push(c);
87            col_clean = c == '\n';
88            i += 1;
89            continue;
90        }
91
92        match chars.get(i + 1) {
93            Some('$') => {
94                text.push('$');
95                col_clean = false;
96                i += 2;
97            }
98            Some('-') if chars.get(i + 2) == Some(&'-') => {
99                let mut j = i + 3;
100                while let Some(&d) = chars.get(j) {
101                    if d == '\n' {
102                        break;
103                    }
104                    j += 1;
105                }
106                if col_clean {
107                    // Column-zero comment: drop the trailing newline too, keeping the line clean.
108                    if chars.get(j) == Some(&'\n') {
109                        j += 1;
110                    }
111                } // otherwise the newline (if any) is read as ordinary text next.
112                i = j;
113            }
114            _ => {
115                if !text.is_empty() {
116                    tokens.push(Token::Text(std::mem::take(&mut text)));
117                }
118                let (token, next) = directive(&chars, i + 1)?;
119                tokens.push(token);
120                col_clean = false;
121                i = next;
122            }
123        }
124    }
125    if !text.is_empty() {
126        tokens.push(Token::Text(text));
127    }
128    Ok(tokens)
129}
130
131/// Parse one `$…$` directive whose interior begins at `start`. Returns the token and the index just
132/// past the closing `$`.
133fn directive(chars: &[char], start: usize) -> Result<(Token, usize), TemplateError> {
134    let close = close_index(chars, start)
135        .ok_or_else(|| TemplateError::new("unterminated directive (missing closing `$`)"))?;
136    let interior: String = chars.get(start..close).unwrap_or_default().iter().collect();
137    Ok((interior_token(&interior)?, close + 1))
138}
139
140/// Index of the `$` that closes a directive opened at `start`, skipping `$` that fall inside `[…]`
141/// separator literals or `"…"` pipe arguments. A newline before the close means the directive is
142/// unterminated.
143fn close_index(chars: &[char], start: usize) -> Option<usize> {
144    let mut i = start;
145    let mut in_bracket = false;
146    let mut in_quote = false;
147    while let Some(&c) = chars.get(i) {
148        match c {
149            '\n' => return None,
150            '"' if !in_bracket => in_quote = !in_quote,
151            '[' if !in_quote => in_bracket = true,
152            ']' if !in_quote => in_bracket = false,
153            '$' if !in_quote && !in_bracket => return Some(i),
154            _ => {}
155        }
156        i += 1;
157    }
158    None
159}
160
161/// Classify a directive's interior text into a token.
162fn interior_token(interior: &str) -> Result<Token, TemplateError> {
163    let trimmed = interior.trim();
164    match trimmed {
165        "else" => return Ok(Token::Else),
166        "endif" => return Ok(Token::EndIf),
167        "sep" => return Ok(Token::Sep),
168        "endfor" => return Ok(Token::EndFor),
169        _ => {}
170    }
171    if let Some(arg) = keyword_arg(trimmed, "if") {
172        return Ok(Token::If(parse_expr(arg)?));
173    }
174    if let Some(arg) = keyword_arg(trimmed, "elseif") {
175        return Ok(Token::ElseIf(parse_expr(arg)?));
176    }
177    if let Some(arg) = keyword_arg(trimmed, "for") {
178        return Ok(Token::For(parse_expr(arg)?));
179    }
180    value_token(trimmed)
181}
182
183/// If `text` is `keyword(<arg>)`, return the trimmed `<arg>`.
184fn keyword_arg<'a>(text: &'a str, keyword: &str) -> Option<&'a str> {
185    let rest = text.strip_prefix(keyword)?.trim_start();
186    let inner = rest.strip_prefix('(')?.strip_suffix(')')?;
187    Some(inner.trim())
188}
189
190/// Parse a non-keyword directive: a mapped partial, a plain partial, or a variable interpolation.
191fn value_token(text: &str) -> Result<Token, TemplateError> {
192    if let Some((target, rest)) = text.split_once(':') {
193        let (name, sep) = partial_parts(rest)?;
194        return Ok(Token::Partial {
195            name,
196            map_over: Some(parse_expr(target.trim())?),
197            sep,
198        });
199    }
200    if text.contains("()") {
201        let (name, sep) = partial_parts(text)?;
202        return Ok(Token::Partial {
203            name,
204            map_over: None,
205            sep,
206        });
207    }
208    Ok(Token::Var(parse_expr(text)?))
209}
210
211/// Split a partial reference `name()` or `name()[sep]` into its name and optional separator.
212fn partial_parts(text: &str) -> Result<(String, Option<String>), TemplateError> {
213    let (name, after) = text
214        .trim()
215        .split_once("()")
216        .ok_or_else(|| TemplateError::new("malformed partial (expected `name()`)"))?;
217    let after = after.trim();
218    let sep = if after.is_empty() {
219        None
220    } else {
221        Some(
222            after
223                .strip_prefix('[')
224                .and_then(|s| s.strip_suffix(']'))
225                .ok_or_else(|| TemplateError::new("malformed partial separator (expected `[…]`)"))?
226                .to_string(),
227        )
228    };
229    Ok((name.trim().to_string(), sep))
230}
231
232/// Parse a variable expression: a dotted path followed by `/pipe` filters.
233fn parse_expr(text: &str) -> Result<Expr, TemplateError> {
234    let mut parts = text.split('/');
235    let head = parts.next().unwrap_or("").trim();
236    let path: Vec<String> = head
237        .split('.')
238        .map(|s| s.trim().to_string())
239        .filter(|s| !s.is_empty())
240        .collect();
241    let mut pipes = Vec::new();
242    for part in parts {
243        pipes.push(parse_pipe(part.trim())?);
244    }
245    Ok(Expr { path, pipes })
246}
247
248fn parse_pipe(text: &str) -> Result<Pipe, TemplateError> {
249    let args = pipe_args(text);
250    let name = args.first().map_or("", String::as_str);
251    let pipe = match name {
252        "uppercase" => Pipe::Uppercase,
253        "lowercase" => Pipe::Lowercase,
254        "length" => Pipe::Length,
255        "reverse" => Pipe::Reverse,
256        "first" => Pipe::First,
257        "last" => Pipe::Last,
258        "rest" => Pipe::Rest,
259        "allbutlast" => Pipe::AllButLast,
260        "pairs" => Pipe::Pairs,
261        "alpha" => Pipe::Alpha,
262        "roman" => Pipe::Roman,
263        "chomp" => Pipe::Chomp,
264        "nowrap" => Pipe::Nowrap,
265        "left" | "right" | "center" => {
266            let align = match name {
267                "right" => Align::Right,
268                "center" => Align::Center,
269                _ => Align::Left,
270            };
271            let width = args
272                .get(1)
273                .and_then(|w| w.parse::<usize>().ok())
274                .ok_or_else(|| TemplateError::new("block pipe requires a width"))?;
275            Pipe::Block {
276                align,
277                width,
278                left: args.get(2).cloned().unwrap_or_default(),
279                right: args.get(3).cloned().unwrap_or_default(),
280            }
281        }
282        other => return Err(TemplateError::new(format!("unknown pipe: {other}"))),
283    };
284    Ok(pipe)
285}
286
287/// Tokenize a pipe's whitespace-separated arguments, honoring `"…"` so a border may contain spaces.
288fn pipe_args(text: &str) -> Vec<String> {
289    let mut out = Vec::new();
290    let mut chars = text.chars().peekable();
291    while let Some(&c) = chars.peek() {
292        if c.is_whitespace() {
293            chars.next();
294            continue;
295        }
296        let mut buf = String::new();
297        if c == '"' {
298            chars.next();
299            for d in chars.by_ref() {
300                if d == '"' {
301                    break;
302                }
303                buf.push(d);
304            }
305        } else {
306            while let Some(&d) = chars.peek() {
307                if d.is_whitespace() {
308                    break;
309                }
310                buf.push(d);
311                chars.next();
312            }
313        }
314        out.push(buf);
315    }
316    out
317}
318
319/// Strip the trailing newline on the line occupied by each block construct's directives.
320///
321/// A `$if$`/`$for$` whose opening ends its line opens a block: its trailing newline is swallowed,
322/// and each later directive of that construct (`$elseif$`/`$else$`/`$sep$`, the closing) that ends
323/// its own line is likewise dropped. The block flag rides a nesting stack, so each construct's
324/// interior directives consult the construct they belong to, not whichever directive came last.
325///
326/// Any indentation *preceding* the directive on its line is left in place: it then sits directly
327/// in front of the following content, so an indented control line shifts its body rightward.
328///
329/// A plain `$name()$` partial is treated as standalone under a stricter rule: when only blanks
330/// precede it back to the previous newline and a newline immediately follows it, that single
331/// newline is absorbed (trailing blanks before the newline, or any non-blank, leave it in place).
332///
333/// All decisions are taken over the original token text in a first pass, then applied — so trimming
334/// one directive's line never perturbs the line analysis of its neighbours.
335fn trim_standalone(tokens: &mut [Token]) {
336    // First pass over the original tokens. `drop_newline` swallows a block construct directive's
337    // whole trailing line; `absorb_partial` swallows just the newline glued to a standalone partial.
338    // Both are computed before any text is trimmed so neighbours never perturb one another.
339    let mut blocks: Vec<bool> = Vec::new();
340    let drop_newline: Vec<bool> = tokens
341        .iter()
342        .enumerate()
343        .map(|(i, token)| match token {
344            Token::If(_) | Token::For(_) => {
345                let block = forward_blank(tokens, i);
346                blocks.push(block);
347                block
348            }
349            Token::ElseIf(_) | Token::Else | Token::Sep => {
350                blocks.last().copied().unwrap_or(false) && forward_blank(tokens, i)
351            }
352            Token::EndIf | Token::EndFor => {
353                blocks.pop().unwrap_or(false) && forward_blank(tokens, i)
354            }
355            _ => false,
356        })
357        .collect();
358    let absorb_partial: Vec<bool> = tokens
359        .iter()
360        .enumerate()
361        .map(|(i, token)| {
362            matches!(token, Token::Partial { map_over: None, .. })
363                && blank_before(tokens, i)
364                && matches!(tokens.get(i + 1), Some(Token::Text(t)) if t.starts_with('\n'))
365        })
366        .collect();
367    // Second pass applies the decisions. The two sets never target the same token: each acts on the
368    // text that follows its own directive, and a token is a control directive or a partial, not both.
369    for (i, (&drop_nl, &absorb)) in drop_newline.iter().zip(&absorb_partial).enumerate() {
370        if drop_nl {
371            if let Some(Token::Text(t)) = tokens.get_mut(i + 1) {
372                trim_leading_line(t);
373            }
374        } else if absorb
375            && let Some(Token::Text(t)) = tokens.get_mut(i + 1)
376            && let Some(rest) = t.strip_prefix('\n')
377        {
378            *t = rest.to_string();
379        }
380    }
381}
382
383/// Whether everything before token `i` back to the previous newline is whitespace.
384fn blank_before(tokens: &[Token], i: usize) -> bool {
385    match i.checked_sub(1) {
386        None => true,
387        Some(prev) => match tokens.get(prev) {
388            Some(Token::Text(t)) => match t.rfind('\n') {
389                Some(k) => t.get(k + 1..).unwrap_or("").chars().all(is_blank),
390                None => prev == 0 && t.chars().all(is_blank),
391            },
392            _ => false,
393        },
394    }
395}
396
397/// Whether everything after token `i` through the next newline is whitespace.
398fn forward_blank(tokens: &[Token], i: usize) -> bool {
399    match tokens.get(i + 1) {
400        None => true,
401        Some(Token::Text(t)) => match t.find('\n') {
402            Some(k) => t.get(..k).unwrap_or("").chars().all(is_blank),
403            None => i + 1 == tokens.len() - 1 && t.chars().all(is_blank),
404        },
405        _ => false,
406    }
407}
408
409/// Drop the leading blanks and the first newline (or the whole string if it has none).
410fn trim_leading_line(text: &mut String) {
411    match text.find('\n') {
412        Some(k) => *text = text.get(k + 1..).unwrap_or("").to_string(),
413        None => text.clear(),
414    }
415}
416
417/// Folds the flat token list into a node tree, matching `$if$`/`$for$` with their terminators.
418struct Builder<'a> {
419    tokens: &'a [Token],
420    pos: usize,
421}
422
423impl Builder<'_> {
424    fn peek(&self) -> Option<Token> {
425        self.tokens.get(self.pos).cloned()
426    }
427
428    /// Parse nodes until a terminator (`elseif`/`else`/`endif`/`sep`/`endfor`) or end of input; the
429    /// terminator is left unconsumed for the caller.
430    fn sequence(&mut self) -> Result<Vec<Node>, TemplateError> {
431        let mut nodes = Vec::new();
432        while let Some(token) = self.peek() {
433            match token {
434                Token::Text(s) => {
435                    self.pos += 1;
436                    nodes.push(Node::Literal(s));
437                }
438                Token::Var(expr) => {
439                    self.pos += 1;
440                    nodes.push(Node::Var(expr));
441                }
442                Token::Partial {
443                    name,
444                    map_over,
445                    sep,
446                } => {
447                    self.pos += 1;
448                    nodes.push(Node::Partial {
449                        name,
450                        map_over,
451                        sep,
452                    });
453                }
454                Token::If(_) => nodes.push(self.conditional()?),
455                Token::For(_) => nodes.push(self.loop_node()?),
456                Token::ElseIf(_) | Token::Else | Token::EndIf | Token::Sep | Token::EndFor => break,
457            }
458        }
459        Ok(nodes)
460    }
461
462    fn conditional(&mut self) -> Result<Node, TemplateError> {
463        let Some(Token::If(cond)) = self.peek() else {
464            return Err(TemplateError::new("expected `if`"));
465        };
466        self.pos += 1;
467        let mut branches = vec![(cond, self.sequence()?)];
468        loop {
469            match self.peek() {
470                Some(Token::ElseIf(cond)) => {
471                    self.pos += 1;
472                    branches.push((cond, self.sequence()?));
473                }
474                Some(Token::Else) => {
475                    self.pos += 1;
476                    let otherwise = self.sequence()?;
477                    self.expect(&Token::EndIf, "endif")?;
478                    return Ok(Node::If {
479                        branches,
480                        otherwise,
481                    });
482                }
483                Some(Token::EndIf) => {
484                    self.pos += 1;
485                    return Ok(Node::If {
486                        branches,
487                        otherwise: Vec::new(),
488                    });
489                }
490                _ => return Err(TemplateError::new("unterminated `if` (missing `endif`)")),
491            }
492        }
493    }
494
495    fn loop_node(&mut self) -> Result<Node, TemplateError> {
496        let Some(Token::For(expr)) = self.peek() else {
497            return Err(TemplateError::new("expected `for`"));
498        };
499        self.pos += 1;
500        // A single-segment loop expression also binds that name to the current element (so
501        // `$for(xs)$…$xs$` works, as does `$for(m/pairs)$…$m.key$`); a pipe on the segment does not
502        // change the name. `$it$` always refers to the element regardless.
503        let bind = match expr.path.as_slice() {
504            [only] => Some(only.clone()),
505            _ => None,
506        };
507        let body = self.sequence()?;
508        let mut sep = Vec::new();
509        match self.peek() {
510            Some(Token::Sep) => {
511                self.pos += 1;
512                sep = self.sequence()?;
513                self.expect(&Token::EndFor, "endfor")?;
514            }
515            Some(Token::EndFor) => {
516                self.pos += 1;
517            }
518            _ => return Err(TemplateError::new("unterminated `for` (missing `endfor`)")),
519        }
520        Ok(Node::For {
521            expr,
522            bind,
523            body,
524            sep,
525        })
526    }
527
528    fn expect(&mut self, want: &Token, label: &str) -> Result<(), TemplateError> {
529        match self.peek() {
530            Some(ref got) if std::mem::discriminant(got) == std::mem::discriminant(want) => {
531                self.pos += 1;
532                Ok(())
533            }
534            _ => Err(TemplateError::new(format!("expected `{label}`"))),
535        }
536    }
537}