Skip to main content

logicaffeine_language/lgx/
template.rs

1//! The LGX template parser — HTML tokens plus Jinja-style `{…}` tags into a template tree.
2//!
3//! Pure and grammar-agnostic about LOGOS: every expression (a hole, a condition, an
4//! iterable, a filter argument) comes out as [`TExpr`] — its text and byte offset — for the
5//! LOGOS parser to sub-lex with the real expression parser, so a mistake inside a hole is
6//! reported at the hole. This module owns everything structural instead:
7//!
8//! - nesting: elements close with their end tag, tags close with `{end}`, and the two may
9//!   not cross (`{if a:}<p>{end}</p>` is an error at the `{end}`);
10//! - the tags: `{if c:}`/`{else if c:}`/`{else:}`, `{for each x in xs[, keyed by k][, at index
11//!   i]:}` with an `{else:}` arm for an empty collection, `{inspect v:}`/`{when Pat:}`,
12//!   `{let x be e}`, `{extends Layout}` (first, or nowhere), `{block name:}…{end}` /
13//!   `{block name}`, `{children}`, `{raw e}`, `{# a comment #}`, and `{end}`;
14//! - holes with filters: `{price | money | pad 8}` — a filter is a function name followed by
15//!   its extra arguments, split at the top level (a `|` inside a string or parens is not a
16//!   filter);
17//! - components: a Capitalized tag is a component use, its attributes are props;
18//! - directives: `on-<event>[.mod…]={…}`, `bind={field}`, `class:name={…}`;
19//! - whitespace: text that is only whitespace AND contains a newline is formatting and is
20//!   dropped; other whitespace runs collapse to one space; `<pre>`/`<textarea>` are verbatim.
21//!
22//! A misspelled tag word can only look like a hole, so a hole whose text ends in `:` is
23//! reported as a tag-like hole with the likely fix.
24
25use super::html::{
26    self, AttrPart, AttrValue, EventModifier, HtmlAttr, HtmlErrorKind, HtmlToken, ModifierError,
27};
28use crate::token::Span;
29
30/// A LOGOS expression kept as text, with the byte offset of `text` in the template source.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TExpr {
33    pub text: String,
34    pub offset: usize,
35}
36
37/// One filter application: `| name arg arg…`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct TFilter {
40    pub name: String,
41    pub args: Vec<TExpr>,
42}
43
44/// An attribute or directive on an element / a prop on a component.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum TAttr {
47    Static {
48        name: String,
49        value: Option<String>,
50        span: Span,
51    },
52    Dynamic {
53        name: String,
54        parts: Vec<AttrPart>,
55        span: Span,
56    },
57    /// `on-<event>[.<qualifier>…]={…}`, its qualifiers classified by the event-modifier catalogue.
58    On {
59        event: String,
60        modifiers: Vec<EventModifier>,
61        expr: TExpr,
62        span: Span,
63    },
64    /// `bind={field}`; `at` is the byte offset of `field` in the template source.
65    Bind {
66        field: String,
67        at: usize,
68        span: Span,
69    },
70    Class {
71        name: String,
72        expr: TExpr,
73        span: Span,
74    },
75}
76
77/// One node of the template tree.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum TNode {
80    Element {
81        tag: String,
82        attrs: Vec<TAttr>,
83        children: Vec<TNode>,
84        span: Span,
85    },
86    Text {
87        text: String,
88        span: Span,
89    },
90    Hole {
91        expr: TExpr,
92        filters: Vec<TFilter>,
93        raw: bool,
94        span: Span,
95    },
96    If {
97        arms: Vec<(TExpr, Vec<TNode>)>,
98        otherwise: Option<Vec<TNode>>,
99        span: Span,
100    },
101    For {
102        pattern: String,
103        iterable: TExpr,
104        key: Option<TExpr>,
105        index: Option<String>,
106        body: Vec<TNode>,
107        empty: Option<Vec<TNode>>,
108        span: Span,
109    },
110    Inspect {
111        scrutinee: TExpr,
112        arms: Vec<(String, Vec<TNode>)>,
113        span: Span,
114    },
115    Let {
116        name: String,
117        expr: TExpr,
118        span: Span,
119    },
120    Extends {
121        name: String,
122        span: Span,
123    },
124    Block {
125        name: String,
126        default: Option<Vec<TNode>>,
127        span: Span,
128    },
129    Children {
130        span: Span,
131    },
132    Component {
133        name: String,
134        props: Vec<TAttr>,
135        children: Vec<TNode>,
136        span: Span,
137    },
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum TemplateErrorKind {
142    Html(HtmlErrorKind),
143    /// `{end}` with nothing open.
144    UnexpectedEnd,
145    /// A template tag (`if`, `for each`, `inspect`, `block`) still open at the end, or closed
146    /// by an element's end tag.
147    UnclosedTag(String),
148    /// `</found>` where `<expected>` is the innermost open element.
149    MismatchedEndTag {
150        expected: String,
151        found: String,
152    },
153    /// An element still open at the end of the template, or at an `{end}`.
154    UnclosedElement(String),
155    /// `</name>` with no open element.
156    StrayEndTag(String),
157    /// A void element (`<input>`) given an end tag.
158    UnexpectedEndTagOfVoid(String),
159    /// `{extends …}` anywhere but first.
160    ExtendsNotFirst,
161    /// `{else:}` / `{else if …:}` outside an `if` (or `else:` outside `if`/`for each`).
162    StrayElse,
163    /// A second `{else:}` in the same `if` / `for each`.
164    DuplicateElse,
165    /// `{when …:}` outside an `{inspect …:}`.
166    StrayWhen,
167    /// A hole whose text ends in `:` — almost certainly a misspelled tag.
168    TagLikeHole(String),
169    /// A tag that is missing a part (`{if:}`, `{for each x:}`, `{let x}`, `{block}`).
170    MalformedTag(String),
171    /// `{}` or `{   }`.
172    EmptyHole,
173    /// A directive with the wrong value shape (`on-click="add"`, `bind="x"`).
174    MalformedDirective(String),
175    /// A tag spelled the way another template language spells it (`{elif c:}`, `{otherwise:}`,
176    /// `{endif}`), with the LGX spelling: `(written, canonical)`.
177    NonCanonicalTag {
178        written: String,
179        canonical: String,
180    },
181    /// An `on-<event>.<qualifier>` whose qualifier the event-modifier catalogue refuses.
182    EventModifier(ModifierError),
183    /// A line written in the retired `Render:` dialect ([`retired_view_form`]).
184    RetiredViewForm(RetiredForm),
185}
186
187/// A line of the retired `Render:` dialect, which a view no longer reads.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum RetiredForm {
190    /// An element named by its tag and a colon: `div:`, `p: "hi"`, `div: card("Hello")`,
191    /// `div [class="x"]:`.
192    Element(String),
193    /// A stylesheet written into the view: `css "…"` or `css r"…"`.
194    Stylesheet,
195}
196
197/// The retired form `line` is written in, read from the WHOLE line and only in the dialect's exact
198/// shapes, so text that merely mentions a tag word (`a: first option`, `Note: see below`) is not
199/// one: an HTML element name, optionally a bracketed attribute list, then `:` and nothing, a string
200/// literal, or a call; or `css` followed by a string literal.
201pub fn retired_view_form(line: &str) -> Option<RetiredForm> {
202    let t = line.trim();
203    if let Some(rest) = t.strip_prefix("css") {
204        if rest.starts_with(char::is_whitespace) {
205            let rest = rest.trim_start();
206            if rest.starts_with('"') || rest.starts_with("r\"") {
207                return Some(RetiredForm::Stylesheet);
208            }
209        }
210    }
211    let tag_end = t
212        .find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit()))
213        .unwrap_or(t.len());
214    let tag = &t[..tag_end];
215    if tag.is_empty() || !html::is_html_element(tag) {
216        return None;
217    }
218    let mut rest = &t[tag_end..];
219    if let Some(attrs) = rest.trim_start().strip_prefix('[') {
220        rest = &attrs[attrs.find(']')? + 1..];
221    }
222    let value = rest.trim_start().strip_prefix(':')?.trim();
223    let is_call = value.ends_with(')')
224        && value.split_once('(').is_some_and(|(name, _)| {
225            !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
226        });
227    (value.is_empty() || value.starts_with('"') || is_call)
228        .then(|| RetiredForm::Element(tag.to_string()))
229}
230
231/// The first line that STARTS inside `span` of `src` and is written in a retired form, with the
232/// span of its words. The line is read whole from `src`, past the end of `span` when a tag or a
233/// brace ends the text there.
234fn retired_line_in(src: &str, span: Span) -> Option<(RetiredForm, Span)> {
235    (span.start..span.end)
236        .filter(|&at| at == 0 || src.as_bytes()[at - 1] == b'\n')
237        .find_map(|start| {
238            let end = src[start..].find('\n').map_or(src.len(), |n| start + n);
239            let line = &src[start..end];
240            let form = retired_view_form(line)?;
241            let words = start + (line.len() - line.trim_start().len());
242            Some((form, Span::new(words, start + line.trim_end().len())))
243        })
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct TemplateError {
248    pub kind: TemplateErrorKind,
249    pub span: Span,
250}
251
252impl TemplateError {
253    /// The Socratic explanation: what went wrong and what to write instead.
254    pub fn message(&self) -> String {
255        use TemplateErrorKind::*;
256        match &self.kind {
257            Html(k) => html::HtmlError { kind: k.clone(), span: self.span }.message(),
258            UnexpectedEnd => "This `{end}` closes nothing — there is no open `{if …:}`, `{for each …:}`, `{inspect …:}` or `{block …:}` here.".into(),
259            UnclosedTag(t) => format!("This `{{{t} …:}}` is never closed. Every template tag that opens a body ends with `{{end}}` — and it must close before the element that contains it does."),
260            MismatchedEndTag { expected, found } => format!("`</{found}>` closes the wrong element: the innermost open element is `<{expected}>`. Close `<{expected}>` first, or fix the tag name."),
261            UnclosedElement(t) => format!("`<{t}>` is never closed. Add `</{t}>` (or write it as `<{t} …/>` if it has no children)."),
262            StrayEndTag(t) => format!("`</{t}>` closes nothing — no `<{t}>` is open here."),
263            UnexpectedEndTagOfVoid(t) => format!("`<{t}>` is a void element: it never has children or an end tag. Remove `</{t}>`."),
264            ExtendsNotFirst => "`{extends …}` must be the very first thing in a view: the layout is chosen before any content.".into(),
265            StrayElse => "This `{else…}` has no `{if …:}` (or `{for each …:}`) to belong to — it must sit between the opener and its `{end}`.".into(),
266            DuplicateElse => "An `{if …:}` or `{for each …:}` can have only one `{else:}` arm.".into(),
267            StrayWhen => "`{when …:}` only belongs inside `{inspect …:} … {end}`.".into(),
268            TagLikeHole(t) => format!("`{{{t}}}` ends in `:` like a template tag, but `{}` is not one. The tags are `if`, `else if`, `else`, `for each`, `inspect`, `when`, `let`, `extends`, `block`, `children`, `raw`, `end` — or drop the `:` to make it a hole.", t.split_whitespace().next().unwrap_or("")),
269            MalformedTag(t) => format!("This `{{{t} …}}` tag is missing a part. Shapes: `{{if condition:}}`, `{{else if condition:}}`, `{{else:}}`, `{{for each item in items[, keyed by key][, at index i]:}}`, `{{inspect value:}}`, `{{when Pattern:}}`, `{{let name be expression}}`, `{{extends Layout}}`, `{{block name:}}` / `{{block name}}`, `{{children}}`, `{{raw expression}}`, `{{end}}`."),
270            EmptyHole => "This `{}` is empty. A hole holds a LOGOS expression: `{name}`, `{length of todos}`, `{price | money}`.".into(),
271            MalformedDirective(d) => format!("`{d}` needs a `{{…}}` value: `on-click={{add}}`, `on-click={{remove(todo's id)}}`, `bind={{draft}}` (a state field), `class:done={{todo's done}}`."),
272            EventModifier(e) => e.message(),
273            NonCanonicalTag { written, canonical } => format!(
274                "`{written}` is written `{canonical}` in an LGX template. The tags are `{{if c:}}`, \
275                 `{{else if c:}}`, `{{else:}}`, `{{for each x in xs:}}`, `{{inspect v:}}`, \
276                 `{{when P:}}` and `{{end}}`, which closes any of them."
277            ),
278            RetiredViewForm(RetiredForm::Element(tag)) => format!(
279                "`{tag}: …` is the retired `Render:` way to write an element. In a `View:` an element \
280                 is markup — `<{tag}>…</{tag}>` — and a component is placed by its name: \
281                 `<Card title=\"Hello\"/>`."
282            ),
283            RetiredViewForm(RetiredForm::Stylesheet) => {
284                "`css \"…\"` is the retired `Render:` way to write a stylesheet. A component's CSS goes \
285                 in its own `Style:` section, beside `View:`, scoped to the component."
286                    .into()
287            }
288        }
289    }
290}
291
292const TAG_WORDS: &[&str] = &[
293    "if", "else", "end", "for", "inspect", "when", "let", "extends", "block", "children", "raw",
294];
295
296/// Tag words other template languages use, and the LGX tag each one means. Each is refused with
297/// the LGX spelling rather than read as a hole naming a variable called `elif`.
298pub const TAG_ALIASES: &[(&str, &str)] = &[
299    ("elif", "else if"),
300    ("elsif", "else if"),
301    ("elseif", "else if"),
302    ("otherwise", "else"),
303    ("endif", "end"),
304    ("endfor", "end"),
305];
306
307enum Frame {
308    Element {
309        tag: String,
310        attrs: Vec<TAttr>,
311        children: Vec<TNode>,
312        start: usize,
313        verbatim: bool,
314    },
315    Component {
316        name: String,
317        props: Vec<TAttr>,
318        children: Vec<TNode>,
319        start: usize,
320    },
321    If {
322        arms: Vec<(TExpr, Vec<TNode>)>,
323        cond: TExpr,
324        current: Vec<TNode>,
325        otherwise: Option<Vec<TNode>>,
326        in_else: bool,
327        start: usize,
328    },
329    For {
330        pattern: String,
331        iterable: TExpr,
332        key: Option<TExpr>,
333        index: Option<String>,
334        body: Vec<TNode>,
335        current: Vec<TNode>,
336        in_else: bool,
337        start: usize,
338    },
339    Inspect {
340        scrutinee: TExpr,
341        arms: Vec<(String, Vec<TNode>)>,
342        current: Option<(String, Vec<TNode>)>,
343        start: usize,
344    },
345    Block {
346        name: String,
347        children: Vec<TNode>,
348        start: usize,
349    },
350}
351
352impl Frame {
353    fn tag_word(&self) -> &'static str {
354        match self {
355            Frame::If { .. } => "if",
356            Frame::For { .. } => "for each",
357            Frame::Inspect { .. } => "inspect",
358            Frame::Block { .. } => "block",
359            Frame::Element { .. } | Frame::Component { .. } => "",
360        }
361    }
362    fn start(&self) -> usize {
363        match self {
364            Frame::Element { start, .. }
365            | Frame::Component { start, .. }
366            | Frame::If { start, .. }
367            | Frame::For { start, .. }
368            | Frame::Inspect { start, .. }
369            | Frame::Block { start, .. } => *start,
370        }
371    }
372}
373
374struct P {
375    stack: Vec<Frame>,
376    root: Vec<TNode>,
377    saw_content: bool,
378}
379
380impl P {
381    fn push_node(&mut self, node: TNode, at: usize) -> Result<(), TemplateError> {
382        match self.stack.last_mut() {
383            None => {
384                if !matches!(node, TNode::Extends { .. }) {
385                    self.saw_content = true;
386                }
387                self.root.push(node);
388                Ok(())
389            }
390            Some(Frame::Element { children, .. })
391            | Some(Frame::Component { children, .. })
392            | Some(Frame::Block { children, .. }) => {
393                children.push(node);
394                Ok(())
395            }
396            Some(Frame::If { current, .. }) | Some(Frame::For { current, .. }) => {
397                current.push(node);
398                Ok(())
399            }
400            Some(Frame::Inspect { current, .. }) => match current {
401                Some((_, body)) => {
402                    body.push(node);
403                    Ok(())
404                }
405                None => {
406                    // Only whitespace may sit between `{inspect v:}` and its first `{when …:}`.
407                    if let TNode::Text { text, .. } = &node {
408                        if text.trim().is_empty() {
409                            return Ok(());
410                        }
411                    }
412                    Err(TemplateError {
413                        kind: TemplateErrorKind::MalformedTag("inspect".into()),
414                        span: Span::new(at, at),
415                    })
416                }
417            },
418        }
419    }
420
421    fn verbatim(&self) -> bool {
422        self.stack
423            .iter()
424            .any(|f| matches!(f, Frame::Element { verbatim: true, .. }))
425    }
426}
427
428/// Parse a raw view body into template nodes.
429pub fn parse(src: &str) -> Result<Vec<TNode>, TemplateError> {
430    let tokens = html::tokenize(src).map_err(|e| TemplateError {
431        kind: TemplateErrorKind::Html(e.kind),
432        span: e.span,
433    })?;
434    let mut p = P {
435        stack: Vec::new(),
436        root: Vec::new(),
437        saw_content: false,
438    };
439    for tok in tokens {
440        match tok {
441            HtmlToken::Text { text, span } => {
442                if p.verbatim() {
443                    p.push_node(TNode::Text { text, span }, span.start)?;
444                    continue;
445                }
446                if let Some((form, at)) = retired_line_in(src, span) {
447                    return Err(TemplateError {
448                        kind: TemplateErrorKind::RetiredViewForm(form),
449                        span: at,
450                    });
451                }
452                if let Some(t) = collapse(&text) {
453                    p.push_node(TNode::Text { text: t, span }, span.start)?;
454                }
455            }
456            HtmlToken::StartTag {
457                name,
458                attrs,
459                self_closing,
460                span,
461            } => {
462                let converted = convert_attrs(&attrs)?;
463                if html::is_component_name(&name) {
464                    if self_closing {
465                        p.push_node(
466                            TNode::Component {
467                                name,
468                                props: converted,
469                                children: Vec::new(),
470                                span,
471                            },
472                            span.start,
473                        )?;
474                    } else {
475                        p.stack.push(Frame::Component {
476                            name,
477                            props: converted,
478                            children: Vec::new(),
479                            start: span.start,
480                        });
481                    }
482                } else if self_closing {
483                    p.push_node(
484                        TNode::Element {
485                            tag: name,
486                            attrs: converted,
487                            children: Vec::new(),
488                            span,
489                        },
490                        span.start,
491                    )?;
492                } else {
493                    let verbatim =
494                        name.eq_ignore_ascii_case("pre") || name.eq_ignore_ascii_case("textarea");
495                    p.stack.push(Frame::Element {
496                        tag: name,
497                        attrs: converted,
498                        children: Vec::new(),
499                        start: span.start,
500                        verbatim,
501                    });
502                }
503            }
504            HtmlToken::EndTag { name, span } => match p.stack.pop() {
505                None => {
506                    if html::is_void(&name) {
507                        return Err(TemplateError {
508                            kind: TemplateErrorKind::UnexpectedEndTagOfVoid(name),
509                            span,
510                        });
511                    }
512                    return Err(TemplateError {
513                        kind: TemplateErrorKind::StrayEndTag(name),
514                        span,
515                    });
516                }
517                Some(Frame::Element {
518                    tag,
519                    attrs,
520                    children,
521                    start,
522                    ..
523                }) => {
524                    if !tag.eq_ignore_ascii_case(&name) {
525                        if html::is_void(&name) {
526                            return Err(TemplateError {
527                                kind: TemplateErrorKind::UnexpectedEndTagOfVoid(name),
528                                span,
529                            });
530                        }
531                        return Err(TemplateError {
532                            kind: TemplateErrorKind::MismatchedEndTag {
533                                expected: tag,
534                                found: name,
535                            },
536                            span,
537                        });
538                    }
539                    p.push_node(
540                        TNode::Element {
541                            tag,
542                            attrs,
543                            children,
544                            span: Span::new(start, span.end),
545                        },
546                        start,
547                    )?;
548                }
549                Some(Frame::Component {
550                    name: cname,
551                    props,
552                    children,
553                    start,
554                }) => {
555                    if cname != name {
556                        return Err(TemplateError {
557                            kind: TemplateErrorKind::MismatchedEndTag {
558                                expected: cname,
559                                found: name,
560                            },
561                            span,
562                        });
563                    }
564                    p.push_node(
565                        TNode::Component {
566                            name: cname,
567                            props,
568                            children,
569                            span: Span::new(start, span.end),
570                        },
571                        start,
572                    )?;
573                }
574                Some(other) => {
575                    return Err(TemplateError {
576                        kind: TemplateErrorKind::UnclosedTag(other.tag_word().to_string()),
577                        span: Span::new(other.start(), span.end),
578                    });
579                }
580            },
581            HtmlToken::Brace { inner, span } => handle_brace(&mut p, &inner, span)?,
582        }
583    }
584    if let Some(top) = p.stack.pop() {
585        let start = top.start();
586        return Err(match top {
587            Frame::Element { tag, .. } => TemplateError {
588                kind: TemplateErrorKind::UnclosedElement(tag),
589                span: Span::new(start, src.len()),
590            },
591            Frame::Component { name, .. } => TemplateError {
592                kind: TemplateErrorKind::UnclosedElement(name),
593                span: Span::new(start, src.len()),
594            },
595            other => TemplateError {
596                kind: TemplateErrorKind::UnclosedTag(other.tag_word().to_string()),
597                span: Span::new(start, src.len()),
598            },
599        });
600    }
601    Ok(p.root)
602}
603
604/// Whitespace rule for non-verbatim text: whitespace-only text containing a newline is
605/// formatting (dropped); otherwise runs of whitespace collapse to one space.
606fn collapse(text: &str) -> Option<String> {
607    if text.trim().is_empty() {
608        return if text.contains('\n') {
609            None
610        } else {
611            Some(" ".to_string())
612        };
613    }
614    let mut out = String::with_capacity(text.len());
615    let mut in_ws = false;
616    for c in text.chars() {
617        if c.is_whitespace() {
618            if !in_ws {
619                out.push(' ');
620                in_ws = true;
621            }
622        } else {
623            out.push(c);
624            in_ws = false;
625        }
626    }
627    Some(out)
628}
629
630fn convert_attrs(attrs: &[HtmlAttr]) -> Result<Vec<TAttr>, TemplateError> {
631    let mut out = Vec::with_capacity(attrs.len());
632    for a in attrs {
633        let single_brace = |a: &HtmlAttr| -> Option<TExpr> {
634            match &a.value {
635                Some(AttrValue::Parts(parts)) if parts.len() == 1 => match &parts[0] {
636                    AttrPart::Brace { inner, span } => Some(texpr(inner, span.start + 1)),
637                    _ => None,
638                },
639                _ => None,
640            }
641        };
642        if let Some(rest) = a.name.strip_prefix("on-") {
643            let mut segs = rest.split('.');
644            let event = segs.next().unwrap_or("").to_string();
645            let words: Vec<&str> = segs.collect();
646            let expr = single_brace(a).ok_or(TemplateError {
647                kind: TemplateErrorKind::MalformedDirective(a.name.clone()),
648                span: a.span,
649            })?;
650            if event.is_empty() || expr.text.trim().is_empty() {
651                return Err(TemplateError {
652                    kind: TemplateErrorKind::MalformedDirective(a.name.clone()),
653                    span: a.span,
654                });
655            }
656            let modifiers = html::classify_modifiers(&event, &words).map_err(|(at, e)| {
657                // The caret covers the refused `.qualifier` inside the attribute name.
658                let start = a.span.start
659                    + "on-".len()
660                    + event.len()
661                    + words[..at].iter().map(|w| w.len() + 1).sum::<usize>();
662                TemplateError {
663                    kind: TemplateErrorKind::EventModifier(e),
664                    span: Span::new(start, start + 1 + words[at].len()),
665                }
666            })?;
667            out.push(TAttr::On {
668                event,
669                modifiers,
670                expr,
671                span: a.span,
672            });
673        } else if a.name == "bind" {
674            let expr = single_brace(a).ok_or(TemplateError {
675                kind: TemplateErrorKind::MalformedDirective("bind".into()),
676                span: a.span,
677            })?;
678            let field = expr.text.trim().to_string();
679            if field.is_empty() || !field.chars().all(|c| c.is_alphanumeric() || c == '_') {
680                return Err(TemplateError {
681                    kind: TemplateErrorKind::MalformedDirective("bind".into()),
682                    span: a.span,
683                });
684            }
685            out.push(TAttr::Bind {
686                field,
687                at: expr.offset,
688                span: a.span,
689            });
690        } else if let Some(class) = a.name.strip_prefix("class:") {
691            let expr = single_brace(a).ok_or(TemplateError {
692                kind: TemplateErrorKind::MalformedDirective(a.name.clone()),
693                span: a.span,
694            })?;
695            if class.is_empty() {
696                return Err(TemplateError {
697                    kind: TemplateErrorKind::MalformedDirective(a.name.clone()),
698                    span: a.span,
699                });
700            }
701            out.push(TAttr::Class {
702                name: class.to_string(),
703                expr,
704                span: a.span,
705            });
706        } else {
707            match &a.value {
708                None => out.push(TAttr::Static {
709                    name: a.name.clone(),
710                    value: None,
711                    span: a.span,
712                }),
713                Some(AttrValue::Static(v)) => out.push(TAttr::Static {
714                    name: a.name.clone(),
715                    value: Some(v.clone()),
716                    span: a.span,
717                }),
718                Some(AttrValue::Parts(parts)) => out.push(TAttr::Dynamic {
719                    name: a.name.clone(),
720                    parts: parts.clone(),
721                    span: a.span,
722                }),
723            }
724        }
725    }
726    Ok(out)
727}
728
729/// A `TExpr` for `text` whose first byte sits at `base` in the source (leading whitespace
730/// is trimmed and the offset advanced past it).
731fn texpr(text: &str, base: usize) -> TExpr {
732    let trimmed_start = text.len() - text.trim_start().len();
733    TExpr {
734        text: text.trim().to_string(),
735        offset: base + trimmed_start,
736    }
737}
738
739fn handle_brace(p: &mut P, inner: &str, span: Span) -> Result<(), TemplateError> {
740    let body = inner.trim();
741    let base = span.start + 1; // first byte after `{`
742    if body.is_empty() {
743        return Err(TemplateError {
744            kind: TemplateErrorKind::EmptyHole,
745            span,
746        });
747    }
748    if body.starts_with('#') && body.ends_with('#') && body.len() >= 2 {
749        return Ok(());
750    }
751    let first = body
752        .split(|c: char| c.is_whitespace() || c == ':')
753        .next()
754        .unwrap_or("");
755    if let Some((_, canonical)) = TAG_ALIASES.iter().find(|(alias, _)| *alias == first) {
756        let rest = body[first.len()..].trim_start();
757        let spelled = if rest.is_empty() || rest.starts_with(':') {
758            format!("{{{canonical}{rest}}}")
759        } else {
760            format!("{{{canonical} {rest}}}")
761        };
762        return Err(TemplateError {
763            kind: TemplateErrorKind::NonCanonicalTag {
764                written: format!("{{{body}}}"),
765                canonical: spelled,
766            },
767            span,
768        });
769    }
770    if !TAG_WORDS.contains(&first) {
771        if body.ends_with(':') {
772            return Err(TemplateError {
773                kind: TemplateErrorKind::TagLikeHole(body.to_string()),
774                span,
775            });
776        }
777        return hole(p, inner, base, span, false);
778    }
779    let rest_after_first = body[first.len()..].trim_start();
780    match first {
781        "end" => {
782            if !rest_after_first.is_empty() {
783                return Err(TemplateError {
784                    kind: TemplateErrorKind::MalformedTag("end".into()),
785                    span,
786                });
787            }
788            close_tag(p, span)
789        }
790        "if" => {
791            let cond = strip_colon(rest_after_first).ok_or(TemplateError {
792                kind: TemplateErrorKind::MalformedTag("if".into()),
793                span,
794            })?;
795            if cond.trim().is_empty() {
796                return Err(TemplateError {
797                    kind: TemplateErrorKind::MalformedTag("if".into()),
798                    span,
799                });
800            }
801            let cond = texpr(cond, base + offset_of(inner, cond));
802            p.stack.push(Frame::If {
803                arms: Vec::new(),
804                cond,
805                current: Vec::new(),
806                otherwise: None,
807                in_else: false,
808                start: span.start,
809            });
810            Ok(())
811        }
812        "else" => {
813            if let Some(rest) = rest_after_first.strip_prefix("if") {
814                let cond = strip_colon(rest.trim_start()).ok_or(TemplateError {
815                    kind: TemplateErrorKind::MalformedTag("else if".into()),
816                    span,
817                })?;
818                if cond.trim().is_empty() {
819                    return Err(TemplateError {
820                        kind: TemplateErrorKind::MalformedTag("else if".into()),
821                        span,
822                    });
823                }
824                let cond = texpr(cond, base + offset_of(inner, cond));
825                match p.stack.last_mut() {
826                    Some(Frame::If {
827                        arms,
828                        cond: cur_cond,
829                        current,
830                        in_else: false,
831                        ..
832                    }) => {
833                        let finished = std::mem::take(current);
834                        let prev = std::mem::replace(cur_cond, cond);
835                        arms.push((prev, finished));
836                        Ok(())
837                    }
838                    Some(Frame::If { in_else: true, .. }) => Err(TemplateError {
839                        kind: TemplateErrorKind::DuplicateElse,
840                        span,
841                    }),
842                    _ => Err(TemplateError {
843                        kind: TemplateErrorKind::StrayElse,
844                        span,
845                    }),
846                }
847            } else {
848                if strip_colon(rest_after_first).map(|s| s.trim().is_empty()) != Some(true) {
849                    return Err(TemplateError {
850                        kind: TemplateErrorKind::MalformedTag("else".into()),
851                        span,
852                    });
853                }
854                match p.stack.last_mut() {
855                    Some(Frame::If {
856                        arms,
857                        cond,
858                        current,
859                        otherwise,
860                        in_else,
861                        ..
862                    }) => {
863                        if *in_else {
864                            return Err(TemplateError {
865                                kind: TemplateErrorKind::DuplicateElse,
866                                span,
867                            });
868                        }
869                        let finished = std::mem::take(current);
870                        arms.push((cond.clone(), finished));
871                        *otherwise = Some(Vec::new());
872                        *in_else = true;
873                        Ok(())
874                    }
875                    Some(Frame::For {
876                        body,
877                        current,
878                        in_else,
879                        ..
880                    }) => {
881                        if *in_else {
882                            return Err(TemplateError {
883                                kind: TemplateErrorKind::DuplicateElse,
884                                span,
885                            });
886                        }
887                        *body = std::mem::take(current);
888                        *in_else = true;
889                        Ok(())
890                    }
891                    _ => Err(TemplateError {
892                        kind: TemplateErrorKind::StrayElse,
893                        span,
894                    }),
895                }
896            }
897        }
898        "for" => {
899            let rest = rest_after_first
900                .strip_prefix("each")
901                .map(str::trim_start)
902                .ok_or(TemplateError {
903                    kind: TemplateErrorKind::MalformedTag("for each".into()),
904                    span,
905                })?;
906            let rest = strip_colon(rest).ok_or(TemplateError {
907                kind: TemplateErrorKind::MalformedTag("for each".into()),
908                span,
909            })?;
910            let in_pos = find_top_level_word(rest, " in ").ok_or(TemplateError {
911                kind: TemplateErrorKind::MalformedTag("for each".into()),
912                span,
913            })?;
914            let pattern = rest[..in_pos].trim().to_string();
915            let after_in = &rest[in_pos + 4..];
916            let clauses = split_top_level(after_in, ',');
917            let iterable_text = clauses.first().copied().unwrap_or("").trim();
918            if pattern.is_empty() || iterable_text.is_empty() {
919                return Err(TemplateError {
920                    kind: TemplateErrorKind::MalformedTag("for each".into()),
921                    span,
922                });
923            }
924            let iterable = texpr(iterable_text, base + offset_of(inner, iterable_text));
925            let mut key = None;
926            let mut index = None;
927            for clause in clauses.iter().skip(1) {
928                let c = clause.trim();
929                if let Some(k) = c.strip_prefix("keyed by") {
930                    let k = k.trim();
931                    if k.is_empty() {
932                        return Err(TemplateError {
933                            kind: TemplateErrorKind::MalformedTag("for each".into()),
934                            span,
935                        });
936                    }
937                    key = Some(texpr(k, base + offset_of(inner, k)));
938                } else if let Some(i) = c.strip_prefix("at index") {
939                    let i = i.trim();
940                    if i.is_empty() {
941                        return Err(TemplateError {
942                            kind: TemplateErrorKind::MalformedTag("for each".into()),
943                            span,
944                        });
945                    }
946                    index = Some(i.to_string());
947                } else {
948                    return Err(TemplateError {
949                        kind: TemplateErrorKind::MalformedTag("for each".into()),
950                        span,
951                    });
952                }
953            }
954            p.stack.push(Frame::For {
955                pattern,
956                iterable,
957                key,
958                index,
959                body: Vec::new(),
960                current: Vec::new(),
961                in_else: false,
962                start: span.start,
963            });
964            Ok(())
965        }
966        "inspect" => {
967            let s = strip_colon(rest_after_first).ok_or(TemplateError {
968                kind: TemplateErrorKind::MalformedTag("inspect".into()),
969                span,
970            })?;
971            if s.trim().is_empty() {
972                return Err(TemplateError {
973                    kind: TemplateErrorKind::MalformedTag("inspect".into()),
974                    span,
975                });
976            }
977            let scrutinee = texpr(s, base + offset_of(inner, s));
978            p.stack.push(Frame::Inspect {
979                scrutinee,
980                arms: Vec::new(),
981                current: None,
982                start: span.start,
983            });
984            Ok(())
985        }
986        "when" => {
987            let pat = strip_colon(rest_after_first).ok_or(TemplateError {
988                kind: TemplateErrorKind::MalformedTag("when".into()),
989                span,
990            })?;
991            if pat.trim().is_empty() {
992                return Err(TemplateError {
993                    kind: TemplateErrorKind::MalformedTag("when".into()),
994                    span,
995                });
996            }
997            match p.stack.last_mut() {
998                Some(Frame::Inspect { arms, current, .. }) => {
999                    if let Some(done) = current.take() {
1000                        arms.push(done);
1001                    }
1002                    *current = Some((pat.trim().to_string(), Vec::new()));
1003                    Ok(())
1004                }
1005                _ => Err(TemplateError {
1006                    kind: TemplateErrorKind::StrayWhen,
1007                    span,
1008                }),
1009            }
1010        }
1011        "let" => {
1012            let be = find_top_level_word(rest_after_first, " be ").ok_or(TemplateError {
1013                kind: TemplateErrorKind::MalformedTag("let".into()),
1014                span,
1015            })?;
1016            let name = rest_after_first[..be].trim().to_string();
1017            let e = rest_after_first[be + 4..].trim();
1018            if name.is_empty() || e.is_empty() || e.ends_with(':') {
1019                return Err(TemplateError {
1020                    kind: TemplateErrorKind::MalformedTag("let".into()),
1021                    span,
1022                });
1023            }
1024            let expr = texpr(e, base + offset_of(inner, e));
1025            p.push_node(TNode::Let { name, expr, span }, span.start)
1026        }
1027        "extends" => {
1028            let name = rest_after_first.trim();
1029            if name.is_empty() || name.contains(char::is_whitespace) || name.ends_with(':') {
1030                return Err(TemplateError {
1031                    kind: TemplateErrorKind::MalformedTag("extends".into()),
1032                    span,
1033                });
1034            }
1035            if !p.stack.is_empty() || p.saw_content {
1036                return Err(TemplateError {
1037                    kind: TemplateErrorKind::ExtendsNotFirst,
1038                    span,
1039                });
1040            }
1041            p.push_node(
1042                TNode::Extends {
1043                    name: name.to_string(),
1044                    span,
1045                },
1046                span.start,
1047            )
1048        }
1049        "block" => {
1050            let has_colon = rest_after_first.ends_with(':');
1051            let name = rest_after_first.trim_end_matches(':').trim();
1052            if name.is_empty() || name.contains(char::is_whitespace) {
1053                return Err(TemplateError {
1054                    kind: TemplateErrorKind::MalformedTag("block".into()),
1055                    span,
1056                });
1057            }
1058            if has_colon {
1059                p.stack.push(Frame::Block {
1060                    name: name.to_string(),
1061                    children: Vec::new(),
1062                    start: span.start,
1063                });
1064                Ok(())
1065            } else {
1066                p.push_node(
1067                    TNode::Block {
1068                        name: name.to_string(),
1069                        default: None,
1070                        span,
1071                    },
1072                    span.start,
1073                )
1074            }
1075        }
1076        "children" => {
1077            if !rest_after_first.is_empty() {
1078                return Err(TemplateError {
1079                    kind: TemplateErrorKind::MalformedTag("children".into()),
1080                    span,
1081                });
1082            }
1083            p.push_node(TNode::Children { span }, span.start)
1084        }
1085        "raw" => {
1086            if rest_after_first.is_empty() || rest_after_first.ends_with(':') {
1087                return Err(TemplateError {
1088                    kind: TemplateErrorKind::MalformedTag("raw".into()),
1089                    span,
1090                });
1091            }
1092            hole(
1093                p,
1094                rest_after_first,
1095                base + offset_of(inner, rest_after_first),
1096                span,
1097                true,
1098            )
1099        }
1100        _ => unreachable!("every TAG_WORD is handled above"),
1101    }
1102}
1103
1104fn close_tag(p: &mut P, span: Span) -> Result<(), TemplateError> {
1105    // `{end}` with no template tag open anywhere closes nothing — even inside an element.
1106    let any_tag_open = p
1107        .stack
1108        .iter()
1109        .any(|f| !matches!(f, Frame::Element { .. } | Frame::Component { .. }));
1110    if !any_tag_open {
1111        return Err(TemplateError {
1112            kind: TemplateErrorKind::UnexpectedEnd,
1113            span,
1114        });
1115    }
1116    match p.stack.pop() {
1117        None => Err(TemplateError {
1118            kind: TemplateErrorKind::UnexpectedEnd,
1119            span,
1120        }),
1121        Some(Frame::If {
1122            mut arms,
1123            cond,
1124            current,
1125            otherwise,
1126            in_else,
1127            start,
1128        }) => {
1129            // In the else arm, `current` IS the else body (the last conditional arm was pushed at
1130            // the `{else:}`); otherwise `current` is the last conditional arm.
1131            let otherwise = if in_else {
1132                Some(current)
1133            } else {
1134                arms.push((cond, current));
1135                otherwise
1136            };
1137            let node = TNode::If {
1138                arms,
1139                otherwise,
1140                span: Span::new(start, span.end),
1141            };
1142            p.push_node(node, start)
1143        }
1144        Some(Frame::For {
1145            pattern,
1146            iterable,
1147            key,
1148            index,
1149            body,
1150            current,
1151            in_else,
1152            start,
1153        }) => {
1154            let (body, empty) = if in_else {
1155                (body, Some(current))
1156            } else {
1157                (current, None)
1158            };
1159            p.push_node(
1160                TNode::For {
1161                    pattern,
1162                    iterable,
1163                    key,
1164                    index,
1165                    body,
1166                    empty,
1167                    span: Span::new(start, span.end),
1168                },
1169                start,
1170            )
1171        }
1172        Some(Frame::Inspect {
1173            scrutinee,
1174            mut arms,
1175            current,
1176            start,
1177        }) => {
1178            if let Some(done) = current {
1179                arms.push(done);
1180            }
1181            p.push_node(
1182                TNode::Inspect {
1183                    scrutinee,
1184                    arms,
1185                    span: Span::new(start, span.end),
1186                },
1187                start,
1188            )
1189        }
1190        Some(Frame::Block {
1191            name,
1192            children,
1193            start,
1194        }) => p.push_node(
1195            TNode::Block {
1196                name,
1197                default: Some(children),
1198                span: Span::new(start, span.end),
1199            },
1200            start,
1201        ),
1202        Some(Frame::Element { tag, start, .. }) => Err(TemplateError {
1203            kind: TemplateErrorKind::UnclosedElement(tag),
1204            span: Span::new(start, span.end),
1205        }),
1206        Some(Frame::Component { name, start, .. }) => Err(TemplateError {
1207            kind: TemplateErrorKind::UnclosedElement(name),
1208            span: Span::new(start, span.end),
1209        }),
1210    }
1211}
1212
1213fn hole(p: &mut P, text: &str, base: usize, span: Span, raw: bool) -> Result<(), TemplateError> {
1214    let segments = split_top_level(text, '|');
1215    let mut iter = segments.iter();
1216    let head = iter.next().copied().unwrap_or("");
1217    if head.trim().is_empty() {
1218        return Err(TemplateError {
1219            kind: TemplateErrorKind::EmptyHole,
1220            span,
1221        });
1222    }
1223    let expr = texpr(head, base + offset_of(text, head));
1224    let mut filters = Vec::new();
1225    for seg in iter {
1226        let seg_trim = seg.trim();
1227        if seg_trim.is_empty() {
1228            return Err(TemplateError {
1229                kind: TemplateErrorKind::EmptyHole,
1230                span,
1231            });
1232        }
1233        let name_end = seg_trim.find(char::is_whitespace).unwrap_or(seg_trim.len());
1234        let name = seg_trim[..name_end].to_string();
1235        let args_text = &seg_trim[name_end..];
1236        let args: Vec<TExpr> = split_top_level_ws(args_text)
1237            .into_iter()
1238            .map(|a| texpr(a, base + offset_of(text, a)))
1239            .collect();
1240        filters.push(TFilter { name, args });
1241    }
1242    p.push_node(
1243        TNode::Hole {
1244            expr,
1245            filters,
1246            raw,
1247            span,
1248        },
1249        span.start,
1250    )
1251}
1252
1253/// Byte offset of the slice `part` inside `whole` (it is always a subslice).
1254fn offset_of(whole: &str, part: &str) -> usize {
1255    let w = whole.as_ptr() as usize;
1256    let p = part.as_ptr() as usize;
1257    if p >= w && p <= w + whole.len() {
1258        p - w
1259    } else {
1260        0
1261    }
1262}
1263
1264fn strip_colon(s: &str) -> Option<&str> {
1265    s.trim_end().strip_suffix(':')
1266}
1267
1268/// Position of `needle` (a space-delimited word like `" in "`) at the top level of `s`.
1269fn find_top_level_word(s: &str, needle: &str) -> Option<usize> {
1270    let b = s.as_bytes();
1271    let mut depth = 0i32;
1272    let mut i = 0;
1273    while i < b.len() {
1274        match b[i] {
1275            b'"' => {
1276                i += 1;
1277                while i < b.len() && b[i] != b'"' {
1278                    if b[i] == b'\\' {
1279                        i += 1;
1280                    }
1281                    i += 1;
1282                }
1283            }
1284            b'(' | b'[' | b'{' => depth += 1,
1285            b')' | b']' | b'}' => depth -= 1,
1286            // ★ COMPARED AS BYTES, NEVER SLICED. `i` walks byte by byte, so it lands inside a
1287            // multi-byte character — and `s[i..]` there is a PANIC ("byte index N is not a char
1288            // boundary"), which took down `largo check`, `largo build`, the LSP and the dev
1289            // server on `{for each café in cafés:}`. A byte-slice comparison cannot panic, and
1290            // it cannot match falsely either: every needle here begins with an ASCII space, and
1291            // no continuation byte is ASCII. See `lgx_non_ascii_lock`.
1292            _ if depth == 0 && b[i..].starts_with(needle.as_bytes()) => return Some(i),
1293            _ => {}
1294        }
1295        i += 1;
1296    }
1297    None
1298}
1299
1300/// Split `s` on `delim` outside strings and brackets, as subslices of `s` (so byte offsets
1301/// into the template survive).
1302fn split_top_level(s: &str, delim: char) -> Vec<&str> {
1303    let b = s.as_bytes();
1304    let d = delim as u8;
1305    let mut out = Vec::new();
1306    let mut depth = 0i32;
1307    let mut start = 0usize;
1308    let mut i = 0;
1309    while i < b.len() {
1310        match b[i] {
1311            b'"' => {
1312                i += 1;
1313                while i < b.len() && b[i] != b'"' {
1314                    if b[i] == b'\\' {
1315                        i += 1;
1316                    }
1317                    i += 1;
1318                }
1319            }
1320            b'(' | b'[' | b'{' => depth += 1,
1321            b')' | b']' | b'}' => depth -= 1,
1322            c if c == d && depth == 0 => {
1323                out.push(&s[start..i]);
1324                start = i + 1;
1325            }
1326            _ => {}
1327        }
1328        i += 1;
1329    }
1330    out.push(&s[start.min(s.len())..]);
1331    out
1332}
1333
1334/// Split filter arguments on whitespace outside strings and brackets, as subslices of `s`.
1335fn split_top_level_ws(s: &str) -> Vec<&str> {
1336    let b = s.as_bytes();
1337    let mut out = Vec::new();
1338    let mut depth = 0i32;
1339    let mut start: Option<usize> = None;
1340    let mut i = 0;
1341    while i < b.len() {
1342        let c = b[i];
1343        match c {
1344            b'"' => {
1345                if start.is_none() {
1346                    start = Some(i);
1347                }
1348                i += 1;
1349                while i < b.len() && b[i] != b'"' {
1350                    if b[i] == b'\\' {
1351                        i += 1;
1352                    }
1353                    i += 1;
1354                }
1355            }
1356            b'(' | b'[' | b'{' => {
1357                if start.is_none() {
1358                    start = Some(i);
1359                }
1360                depth += 1;
1361            }
1362            b')' | b']' | b'}' => depth -= 1,
1363            _ if c.is_ascii_whitespace() && depth == 0 => {
1364                if let Some(st) = start.take() {
1365                    out.push(&s[st..i]);
1366                }
1367            }
1368            _ => {
1369                if start.is_none() {
1370                    start = Some(i);
1371                }
1372            }
1373        }
1374        i += 1;
1375    }
1376    if let Some(st) = start {
1377        out.push(&s[st..]);
1378    }
1379    out
1380}
1381
1382// ---------------------------------------------------------------------------------------------
1383// Printing — the inverse of `parse`, for round-trip locks, `largo expand`, and formatting.
1384// ---------------------------------------------------------------------------------------------
1385
1386/// Print a template tree back to LGX source. `parse(print(t))` yields `t` again (spans
1387/// aside), and printing is idempotent; text and attribute values are escaped so that no
1388/// character can be re-read as markup or a hole.
1389pub fn print(nodes: &[TNode]) -> String {
1390    let mut out = String::new();
1391    print_nodes(nodes, &mut out);
1392    out
1393}
1394
1395fn print_nodes(nodes: &[TNode], out: &mut String) {
1396    for n in nodes {
1397        print_node(n, out);
1398    }
1399}
1400
1401fn escape_text(s: &str, out: &mut String) {
1402    for c in s.chars() {
1403        match c {
1404            '&' => out.push_str("&amp;"),
1405            '<' => out.push_str("&lt;"),
1406            '>' => out.push_str("&gt;"),
1407            '{' => out.push_str("&#123;"),
1408            '}' => out.push_str("&#125;"),
1409            _ => out.push(c),
1410        }
1411    }
1412}
1413
1414fn escape_attr(s: &str, out: &mut String) {
1415    for c in s.chars() {
1416        match c {
1417            '&' => out.push_str("&amp;"),
1418            '"' => out.push_str("&quot;"),
1419            '{' => out.push_str("&#123;"),
1420            '}' => out.push_str("&#125;"),
1421            _ => out.push(c),
1422        }
1423    }
1424}
1425
1426fn print_attrs(attrs: &[TAttr], out: &mut String) {
1427    for a in attrs {
1428        out.push(' ');
1429        match a {
1430            TAttr::Static {
1431                name, value: None, ..
1432            } => out.push_str(name),
1433            TAttr::Static {
1434                name,
1435                value: Some(v),
1436                ..
1437            } => {
1438                out.push_str(name);
1439                out.push_str("=\"");
1440                escape_attr(v, out);
1441                out.push('"');
1442            }
1443            TAttr::Dynamic { name, parts, .. } => {
1444                out.push_str(name);
1445                out.push('=');
1446                if let [AttrPart::Brace { inner, .. }] = parts.as_slice() {
1447                    out.push('{');
1448                    out.push_str(inner.trim());
1449                    out.push('}');
1450                } else {
1451                    out.push('"');
1452                    for p in parts {
1453                        match p {
1454                            AttrPart::Text(t) => escape_attr(t, out),
1455                            AttrPart::Brace { inner, .. } => {
1456                                out.push('{');
1457                                out.push_str(inner.trim());
1458                                out.push('}');
1459                            }
1460                        }
1461                    }
1462                    out.push('"');
1463                }
1464            }
1465            TAttr::On {
1466                event,
1467                modifiers,
1468                expr,
1469                ..
1470            } => {
1471                out.push_str("on-");
1472                out.push_str(event);
1473                for m in modifiers {
1474                    out.push('.');
1475                    out.push_str(&m.word());
1476                }
1477                out.push_str("={");
1478                out.push_str(&expr.text);
1479                out.push('}');
1480            }
1481            TAttr::Bind { field, .. } => {
1482                out.push_str("bind={");
1483                out.push_str(field);
1484                out.push('}');
1485            }
1486            TAttr::Class { name, expr, .. } => {
1487                out.push_str("class:");
1488                out.push_str(name);
1489                out.push_str("={");
1490                out.push_str(&expr.text);
1491                out.push('}');
1492            }
1493        }
1494    }
1495}
1496
1497fn print_node(n: &TNode, out: &mut String) {
1498    match n {
1499        TNode::Element {
1500            tag,
1501            attrs,
1502            children,
1503            ..
1504        } => {
1505            out.push('<');
1506            out.push_str(tag);
1507            print_attrs(attrs, out);
1508            out.push('>');
1509            if html::is_void(tag) {
1510                return;
1511            }
1512            print_nodes(children, out);
1513            out.push_str("</");
1514            out.push_str(tag);
1515            out.push('>');
1516        }
1517        TNode::Text { text, .. } => escape_text(text, out),
1518        TNode::Hole {
1519            expr, filters, raw, ..
1520        } => {
1521            out.push('{');
1522            if *raw {
1523                out.push_str("raw ");
1524            }
1525            out.push_str(&expr.text);
1526            for f in filters {
1527                out.push_str(" | ");
1528                out.push_str(&f.name);
1529                for a in &f.args {
1530                    out.push(' ');
1531                    out.push_str(&a.text);
1532                }
1533            }
1534            out.push('}');
1535        }
1536        TNode::If {
1537            arms, otherwise, ..
1538        } => {
1539            for (i, (cond, body)) in arms.iter().enumerate() {
1540                out.push_str(if i == 0 { "{if " } else { "{else if " });
1541                out.push_str(&cond.text);
1542                out.push_str(":}");
1543                print_nodes(body, out);
1544            }
1545            if let Some(o) = otherwise {
1546                out.push_str("{else:}");
1547                print_nodes(o, out);
1548            }
1549            out.push_str("{end}");
1550        }
1551        TNode::For {
1552            pattern,
1553            iterable,
1554            key,
1555            index,
1556            body,
1557            empty,
1558            ..
1559        } => {
1560            out.push_str("{for each ");
1561            out.push_str(pattern);
1562            out.push_str(" in ");
1563            out.push_str(&iterable.text);
1564            if let Some(k) = key {
1565                out.push_str(", keyed by ");
1566                out.push_str(&k.text);
1567            }
1568            if let Some(i) = index {
1569                out.push_str(", at index ");
1570                out.push_str(i);
1571            }
1572            out.push_str(":}");
1573            print_nodes(body, out);
1574            if let Some(e) = empty {
1575                out.push_str("{else:}");
1576                print_nodes(e, out);
1577            }
1578            out.push_str("{end}");
1579        }
1580        TNode::Inspect {
1581            scrutinee, arms, ..
1582        } => {
1583            out.push_str("{inspect ");
1584            out.push_str(&scrutinee.text);
1585            out.push_str(":}");
1586            for (pat, body) in arms {
1587                out.push_str("{when ");
1588                out.push_str(pat);
1589                out.push_str(":}");
1590                print_nodes(body, out);
1591            }
1592            out.push_str("{end}");
1593        }
1594        TNode::Let { name, expr, .. } => {
1595            out.push_str("{let ");
1596            out.push_str(name);
1597            out.push_str(" be ");
1598            out.push_str(&expr.text);
1599            out.push('}');
1600        }
1601        TNode::Extends { name, .. } => {
1602            out.push_str("{extends ");
1603            out.push_str(name);
1604            out.push('}');
1605        }
1606        TNode::Block { name, default, .. } => {
1607            out.push_str("{block ");
1608            out.push_str(name);
1609            match default {
1610                None => out.push('}'),
1611                Some(d) => {
1612                    out.push_str(":}");
1613                    print_nodes(d, out);
1614                    out.push_str("{end}");
1615                }
1616            }
1617        }
1618        TNode::Children { .. } => out.push_str("{children}"),
1619        TNode::Component {
1620            name,
1621            props,
1622            children,
1623            ..
1624        } => {
1625            out.push('<');
1626            out.push_str(name);
1627            print_attrs(props, out);
1628            if children.is_empty() {
1629                out.push_str("/>");
1630            } else {
1631                out.push('>');
1632                print_nodes(children, out);
1633                out.push_str("</");
1634                out.push_str(name);
1635                out.push('>');
1636            }
1637        }
1638    }
1639}