Skip to main content

logicaffeine_language/lgx/
check.rs

1//! Structural and accessibility checks over a parsed template tree — the questions a
2//! template can answer before any LOGOS expression inside it is parsed.
3//!
4//! Errors are shapes that cannot mean anything: content beside `{block}`s in a view that
5//! `{extends}` a layout, a block name defined twice, `{children}` twice, `bind` on
6//! something that is not a form control, `class:` on a component (a component takes
7//! props, not classes). Warnings are the accessibility mistakes that ship far too often
8//! — an image with no `alt`, a button that announces nothing, a form control with no
9//! label, a link with nowhere to go. Every finding carries the span and a message that
10//! names the fix.
11
12use super::template::{TAttr, TNode};
13use crate::token::Span;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Severity {
17    Error,
18    Warning,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum FindingKind {
23    /// Loose content at the top level of a view that `{extends}` a layout.
24    ContentOutsideBlock,
25    /// The same `{block name}` twice in one template.
26    DuplicateBlock(String),
27    /// `{children}` more than once.
28    DuplicateChildren,
29    /// `bind={…}` on an element that is not `<input>`, `<textarea>` or `<select>`.
30    BindOnNonInput(String),
31    /// `class:name={…}` on a component.
32    ClassToggleOnComponent(String),
33    /// `<img>` without an `alt` attribute (an empty `alt=""` marks it decorative and is fine).
34    ImageWithoutAlt,
35    /// `<button>` with no text, no hole, and no `aria-label`/`aria-labelledby`/`title`.
36    ButtonWithoutName,
37    /// A form control with no `<label for>`, no wrapping `<label>`, and no `aria-label`.
38    InputWithoutLabel,
39    /// `<a>` without `href`.
40    LinkWithoutHref,
41    /// A `<form>` whose method changes state, with no `_csrf` field in it.
42    FormWithoutCsrf(String),
43    /// An inline event-handler attribute (`onclick`, `onerror`, …) — a JavaScript sink.
44    InlineEventHandler(String),
45    /// A `bind` on a form control the browser will not let a page write — `<input type="file">`
46    /// and `<select multiple>`.
47    ///
48    /// ★ CHECKED HERE, NOT AT A TIER, because the mistake is semantic rather than a limit of any
49    /// one backend: a file input's value cannot be set by a page ANYWHERE, and a multiple select's
50    /// value is a list on every tier. A tier-specific gate would let any tier outside it accept it
51    /// happily — the binding written as a plain text `setValue` does nothing and reports nothing.
52    ///
53    /// The wording comes from `BoundInput::refusal()` so the check and the lowering cannot drift.
54    BindNotHonoured(&'static str),
55    /// Markup a browser's HTML parser does not keep as written — `what` (an element, or `text`)
56    /// where the parser moves, wraps or closes it — so the document a browser builds from the
57    /// pre-rendered page is not the page the view draws, and the page is drawn again as it loads.
58    RewrittenByParser(ParserRewrite, String),
59}
60
61/// What a browser's HTML parser does to markup it does not keep as written — see
62/// [`FindingKind::RewrittenByParser`].
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ParserRewrite {
65    /// A `<tr>` directly in a `<table>` gets a `<tbody>` around it.
66    WrapsRowInTbody,
67    /// A `<td>`/`<th>` directly in a table or a table section gets a `<tr>` around it.
68    WrapsCellInRow,
69    /// A `<col>` directly in a `<table>` gets a `<colgroup>` around it.
70    WrapsColInColgroup,
71    /// Content a table or table row cannot hold is moved out, in front of the table.
72    MovesOutOfTable,
73    /// A block element closes the `<p>` it is written in.
74    ClosesParagraph,
75    /// A link inside a link closes the outer one.
76    ClosesLink,
77    /// A form inside a form is dropped.
78    DropsNestedForm,
79    /// A button inside a button closes the outer one.
80    ClosesButton,
81    /// A list item, or a `<dt>`/`<dd>`, closes the one it is written in.
82    ClosesItem,
83    /// An `<option>` or `<optgroup>` closes the option it is written in.
84    ClosesOption,
85    /// A heading closes the heading it is written in.
86    ClosesHeading,
87}
88
89impl ParserRewrite {
90    /// What the parser does, as the finding says it, and the fix.
91    fn says(self) -> (&'static str, &'static str) {
92        use ParserRewrite::*;
93        match self {
94            WrapsRowInTbody => ("wraps the row in a `<tbody>`", "Put the rows in `<tbody>…</tbody>` (or `<thead>`/`<tfoot>`)."),
95            WrapsCellInRow => ("wraps the cell in a `<tr>`", "Put the cells in a `<tr>`."),
96            WrapsColInColgroup => ("wraps it in a `<colgroup>`", "Put the `<col>`s in a `<colgroup>`."),
97            MovesOutOfTable => ("moves it out, in front of the table", "Put it inside a cell (`<td>`), or outside the table."),
98            ClosesParagraph => ("closes the `<p>` before it, so it and what follows it land outside the paragraph", "Use a `<div>` in place of the `<p>`, or keep the paragraph to text and inline elements."),
99            ClosesLink => ("closes the outer link first, so the two links end up side by side", "Links cannot nest: move the inner one out, or make the outer one a `<div>`."),
100            DropsNestedForm => ("drops the inner form's tag", "Forms cannot nest: close the outer form first."),
101            ClosesButton => ("closes the outer button first", "Buttons cannot nest: move the inner one out."),
102            ClosesItem => ("closes the outer item first", "Nest a list (`<ul>`, `<ol>`, `<dl>`) between the two items."),
103            ClosesOption => ("closes the outer option first", "Options cannot nest: group them with `<optgroup>` inside the `<select>`."),
104            ClosesHeading => ("closes the outer heading first", "Headings cannot nest: use a `<span>` inside the heading."),
105        }
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Finding {
111    pub kind: FindingKind,
112    pub severity: Severity,
113    pub span: Span,
114    pub message: String,
115}
116
117fn finding(kind: FindingKind, span: Span) -> Finding {
118    use FindingKind::*;
119    let (severity, message) = match &kind {
120        ContentOutsideBlock => (Severity::Error, "A view that `{extends}` a layout fills the layout's blocks: put this content inside a `{block name:} … {end}` (or drop the `{extends}`).".to_string()),
121        DuplicateBlock(n) => (Severity::Error, format!("`{{block {n}}}` is defined twice in this template; a block name is a slot, so name each one once.")),
122        DuplicateChildren => (Severity::Error, "`{children}` can appear once: it is where the caller's content goes. For several slots, use named `{block}`s.".to_string()),
123        BindOnNonInput(tag) => (Severity::Error, format!("`bind={{…}}` two-way binds a form control's value; `<{tag}>` has none. Use it on `<input>`, `<textarea>` or `<select>`, or show the value with a hole `{{…}}`.")),
124        ClassToggleOnComponent(name) => (Severity::Error, format!("`class:` toggles a class on an HTML element; `<{name}>` is a component and takes props. Pass the flag as a prop (`<{name} done={{…}}>`) and toggle inside its view.")),
125        ImageWithoutAlt => (Severity::Warning, "This `<img>` has no `alt`. Describe it (`alt=\"A cat\"`), or mark it decorative with `alt=\"\"` so screen readers skip it.".to_string()),
126        ButtonWithoutName => (Severity::Warning, "This `<button>` announces nothing: give it text, a `{…}` hole, or `aria-label=\"…\"`.".to_string()),
127        InputWithoutLabel => (Severity::Warning, "This form control has no label. Add `<label for=\"id\">` with a matching `id`, wrap it in `<label>…</label>`, or set `aria-label=\"…\"`.".to_string()),
128        LinkWithoutHref => (Severity::Warning, "This `<a>` has no `href`, so it is not a link: add `href=\"…\"`, or use a `<button>` for an action.".to_string()),
129        // An ERROR, not a warning, and deliberately so: CSRF protection you have to REMEMBER is
130        // not protection. A warning is skipped on the one form somebody added in a hurry, and
131        // nothing says so until a visitor's browser is made to submit it from another site.
132        FormWithoutCsrf(method) => (Severity::Error, format!("This `<form method=\"{method}\">` changes something, so a browser can be made to submit it from another site. Add the token this page was given: `<input type=\"hidden\" name=\"_csrf\" value={{token}}>`, and check it with `csrfValid` in the route that answers. (A `get` form changes nothing and needs none.)")),
133        // An ERROR for the same reason `<script>` is: this is a JavaScript position, and the
134        // escaping that protects every other attribute is meaningless in one. A hole here is a
135        // stranger's text being EXECUTED, not markup being neutralised.
136        //
137        // It is also the likelier mistake: the framework's own spelling is `on-click` with a
138        // hyphen, and `onclick` is one keystroke away. Written that way it is not wired to
139        // anything — it emits a raw handler carrying the stringified value — so the page looks
140        // connected and answers nothing, which is why the message leads with the fix.
141        BindNotHonoured(why) => (Severity::Error, format!("{why}.")),
142        RewrittenByParser(rewrite, what) => {
143            let (does, fix) = rewrite.says();
144            (Severity::Warning, format!("A browser's HTML parser does not keep {what} where this view puts it: it {does}. The page a browser builds from the pre-rendered markup is then not the page this view draws, so the page is drawn again as it loads, and what a visitor focused or typed first is lost. {fix}"))
145        }
146        InlineEventHandler(name) => (Severity::Error, format!("`{name}` is an inline JavaScript handler, and a view cannot carry one: behavior belongs in `Actions:`. Write the action and attach it with `on-{}={{…}}` — note the hyphen, which is what tells the framework to wire it up. (Escaping cannot help inside `{name}`: its value is script, not markup.)", name.strip_prefix("on").unwrap_or(name))),
147    };
148    Finding {
149        kind,
150        severity,
151        span,
152        message,
153    }
154}
155
156/// Does this subtree carry an `<input name="_csrf">`?
157///
158/// The check is about the FIELD being present, never about what fills it: the token is computed,
159/// so its value is a hole in every real page.
160fn has_csrf_field(nodes: &[TNode]) -> bool {
161    nodes.iter().any(|n| match n {
162        TNode::Element {
163            tag,
164            attrs,
165            children,
166            ..
167        } => {
168            (tag.eq_ignore_ascii_case("input")
169                && static_attr(attrs, "name").is_some_and(|v| v == "_csrf"))
170                || has_csrf_field(children)
171        }
172        TNode::If {
173            arms, otherwise, ..
174        } => {
175            arms.iter().any(|(_, body)| has_csrf_field(body))
176                || otherwise.as_ref().is_some_and(|o| has_csrf_field(o))
177        }
178        TNode::For { body, empty, .. } => {
179            has_csrf_field(body) || empty.as_ref().is_some_and(|e| has_csrf_field(e))
180        }
181        TNode::Inspect { arms, .. } => arms.iter().any(|(_, body)| has_csrf_field(body)),
182        TNode::Block {
183            default: Some(body),
184            ..
185        } => has_csrf_field(body),
186        TNode::Component { children, .. } => has_csrf_field(children),
187        _ => false,
188    })
189}
190
191/// Run every check over a template tree.
192pub fn check(nodes: &[TNode]) -> Vec<Finding> {
193    let mut out = Vec::new();
194    // Structural: an extending view carries only blocks at its top level.
195    if matches!(nodes.first(), Some(TNode::Extends { .. })) {
196        for n in &nodes[1..] {
197            match n {
198                TNode::Block { .. } => {}
199                TNode::Text { text, .. } if text.trim().is_empty() => {}
200                other => out.push(finding(FindingKind::ContentOutsideBlock, node_span(other))),
201            }
202        }
203    }
204    let mut blocks: Vec<String> = Vec::new();
205    let mut children_seen = 0usize;
206    let labels_for = collect_label_targets(nodes);
207    walk(
208        nodes,
209        false,
210        &labels_for,
211        &mut blocks,
212        &mut children_seen,
213        &mut out,
214    );
215    parser_rewrites(nodes, &mut Vec::new(), &mut out);
216    out
217}
218
219/// A table or table section, whose content model the parser enforces as it goes.
220const TABLE_PARTS: &[&str] = &["table", "tbody", "thead", "tfoot", "tr"];
221
222/// The elements that close an open `<p>` (the `p` end tag may be omitted before each).
223const CLOSES_P: &[&str] = &[
224    "address",
225    "article",
226    "aside",
227    "blockquote",
228    "details",
229    "dialog",
230    "div",
231    "dl",
232    "fieldset",
233    "figcaption",
234    "figure",
235    "footer",
236    "form",
237    "h1",
238    "h2",
239    "h3",
240    "h4",
241    "h5",
242    "h6",
243    "header",
244    "hgroup",
245    "hr",
246    "main",
247    "menu",
248    "nav",
249    "ol",
250    "p",
251    "pre",
252    "search",
253    "section",
254    "table",
255    "ul",
256];
257
258/// The elements that bound the parser's "in scope" searches; a `button` bounds the paragraph's.
259const SCOPE_BOUNDS: &[&str] = &[
260    "applet", "caption", "html", "table", "td", "th", "marquee", "object", "template",
261];
262
263/// The elements the parser's list-item search stops at (its "special" category, less `address`,
264/// `div` and `p`).
265const ITEM_BOUNDS: &[&str] = &[
266    "applet",
267    "article",
268    "aside",
269    "blockquote",
270    "body",
271    "button",
272    "caption",
273    "center",
274    "colgroup",
275    "details",
276    "dir",
277    "dl",
278    "fieldset",
279    "figcaption",
280    "figure",
281    "footer",
282    "form",
283    "h1",
284    "h2",
285    "h3",
286    "h4",
287    "h5",
288    "h6",
289    "header",
290    "hgroup",
291    "html",
292    "iframe",
293    "main",
294    "marquee",
295    "menu",
296    "nav",
297    "object",
298    "ol",
299    "pre",
300    "section",
301    "select",
302    "summary",
303    "table",
304    "tbody",
305    "td",
306    "template",
307    "textarea",
308    "tfoot",
309    "th",
310    "thead",
311    "tr",
312    "ul",
313];
314
315const HEADINGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
316
317/// Whether an ancestor in `open` (outermost first) named in `hits` is reached before one named in
318/// `bounds`, searching from the innermost.
319fn open_within(open: &[String], hits: &[&str], bounds: &[&str]) -> bool {
320    for a in open.iter().rev() {
321        if hits.contains(&a.as_str()) {
322            return true;
323        }
324        if bounds.contains(&a.as_str()) {
325            return false;
326        }
327    }
328    false
329}
330
331/// The rewrite the HTML parser makes to `<tag>` opened with `open` (outermost first) around it.
332fn rewrite_of(tag: &str, open: &[String]) -> Option<ParserRewrite> {
333    use ParserRewrite::*;
334    let parent = open.last().map(String::as_str).unwrap_or("");
335    let table_content = |t: &str| match parent {
336        "table" => matches!(
337            t,
338            "caption"
339                | "colgroup"
340                | "col"
341                | "thead"
342                | "tbody"
343                | "tfoot"
344                | "tr"
345                | "script"
346                | "template"
347                | "style"
348        ),
349        "tbody" | "thead" | "tfoot" => {
350            matches!(t, "tr" | "td" | "th" | "script" | "template" | "style")
351        }
352        "tr" => matches!(t, "td" | "th" | "script" | "template" | "style"),
353        _ => true,
354    };
355    if parent == "table" && tag == "tr" {
356        return Some(WrapsRowInTbody);
357    }
358    if matches!(parent, "table" | "tbody" | "thead" | "tfoot") && matches!(tag, "td" | "th") {
359        return Some(WrapsCellInRow);
360    }
361    if parent == "table" && tag == "col" {
362        return Some(WrapsColInColgroup);
363    }
364    if !table_content(tag) {
365        return Some(MovesOutOfTable);
366    }
367    if CLOSES_P.contains(&tag) && open_within(open, &["p"], &[SCOPE_BOUNDS, &["button"]].concat()) {
368        return Some(ClosesParagraph);
369    }
370    match tag {
371        "a" if open_within(
372            open,
373            &["a"],
374            &[
375                "applet", "caption", "marquee", "object", "template", "td", "th",
376            ],
377        ) =>
378        {
379            Some(ClosesLink)
380        }
381        "form" if open.iter().any(|a| a == "form") => Some(DropsNestedForm),
382        "button" if open_within(open, &["button"], SCOPE_BOUNDS) => Some(ClosesButton),
383        "li" if open_within(open, &["li"], ITEM_BOUNDS) => Some(ClosesItem),
384        "dd" | "dt" if open_within(open, &["dd", "dt"], ITEM_BOUNDS) => Some(ClosesItem),
385        "option" if parent == "option" => Some(ClosesOption),
386        "optgroup" if matches!(parent, "option" | "optgroup") => Some(ClosesOption),
387        _ if HEADINGS.contains(&tag) && HEADINGS.contains(&parent) => Some(ClosesHeading),
388        _ => None,
389    }
390}
391
392/// The markup the HTML parser would not keep as written — see [`FindingKind::RewrittenByParser`].
393/// `open` holds the elements around `nodes`, outermost first; a component's children are drawn
394/// wherever its own view puts `{children}`, so their surroundings are not known here.
395fn parser_rewrites(nodes: &[TNode], open: &mut Vec<String>, out: &mut Vec<Finding>) {
396    for n in nodes {
397        match n {
398            TNode::Element {
399                tag,
400                children,
401                span,
402                ..
403            } => {
404                let t = tag.to_ascii_lowercase();
405                if let Some(rewrite) = rewrite_of(&t, open) {
406                    out.push(finding(
407                        FindingKind::RewrittenByParser(rewrite, format!("`<{t}>`")),
408                        *span,
409                    ));
410                }
411                open.push(t);
412                parser_rewrites(children, open, out);
413                open.pop();
414            }
415            TNode::Text { text, span }
416                if !text.trim().is_empty()
417                    && TABLE_PARTS.contains(&open.last().map(String::as_str).unwrap_or("")) =>
418            {
419                out.push(finding(
420                    FindingKind::RewrittenByParser(ParserRewrite::MovesOutOfTable, "text".into()),
421                    *span,
422                ));
423            }
424            TNode::Hole { span, .. }
425                if TABLE_PARTS.contains(&open.last().map(String::as_str).unwrap_or("")) =>
426            {
427                out.push(finding(
428                    FindingKind::RewrittenByParser(ParserRewrite::MovesOutOfTable, "text".into()),
429                    *span,
430                ));
431            }
432            TNode::Component { children, .. } => parser_rewrites(children, &mut Vec::new(), out),
433            TNode::Block {
434                default: Some(body),
435                ..
436            } => parser_rewrites(body, open, out),
437            TNode::If {
438                arms, otherwise, ..
439            } => {
440                for (_, body) in arms {
441                    parser_rewrites(body, open, out);
442                }
443                if let Some(o) = otherwise {
444                    parser_rewrites(o, open, out);
445                }
446            }
447            TNode::For { body, empty, .. } => {
448                parser_rewrites(body, open, out);
449                if let Some(e) = empty {
450                    parser_rewrites(e, open, out);
451                }
452            }
453            TNode::Inspect { arms, .. } => {
454                for (_, body) in arms {
455                    parser_rewrites(body, open, out);
456                }
457            }
458            _ => {}
459        }
460    }
461}
462
463fn node_span(n: &TNode) -> Span {
464    match n {
465        TNode::Element { span, .. }
466        | TNode::Text { span, .. }
467        | TNode::Hole { span, .. }
468        | TNode::If { span, .. }
469        | TNode::For { span, .. }
470        | TNode::Inspect { span, .. }
471        | TNode::Let { span, .. }
472        | TNode::Extends { span, .. }
473        | TNode::Block { span, .. }
474        | TNode::Children { span }
475        | TNode::Component { span, .. } => *span,
476    }
477}
478
479/// Every `for` value of a `<label for="…">` anywhere in the tree (static values only — a
480/// dynamic `for={…}` is trusted).
481fn collect_label_targets(nodes: &[TNode]) -> Vec<String> {
482    let mut out = Vec::new();
483    fn go(nodes: &[TNode], out: &mut Vec<String>) {
484        for n in nodes {
485            match n {
486                TNode::Element {
487                    tag,
488                    attrs,
489                    children,
490                    ..
491                } => {
492                    if tag.eq_ignore_ascii_case("label") {
493                        if let Some(v) = static_attr(attrs, "for") {
494                            out.push(v.to_string());
495                        }
496                    }
497                    go(children, out);
498                }
499                TNode::Component { children, .. }
500                | TNode::Block {
501                    default: Some(children),
502                    ..
503                } => go(children, out),
504                TNode::If {
505                    arms, otherwise, ..
506                } => {
507                    for (_, b) in arms {
508                        go(b, out);
509                    }
510                    if let Some(o) = otherwise {
511                        go(o, out);
512                    }
513                }
514                TNode::For { body, empty, .. } => {
515                    go(body, out);
516                    if let Some(e) = empty {
517                        go(e, out);
518                    }
519                }
520                TNode::Inspect { arms, .. } => {
521                    for (_, b) in arms {
522                        go(b, out);
523                    }
524                }
525                _ => {}
526            }
527        }
528    }
529    go(nodes, &mut out);
530    out
531}
532
533fn static_attr<'a>(attrs: &'a [TAttr], name: &str) -> Option<&'a str> {
534    attrs.iter().find_map(|a| match a {
535        TAttr::Static { name: n, value, .. } if n.eq_ignore_ascii_case(name) => {
536            Some(value.as_deref().unwrap_or(""))
537        }
538        _ => None,
539    })
540}
541
542fn has_attr(attrs: &[TAttr], name: &str) -> bool {
543    attrs.iter().any(|a| match a {
544        TAttr::Static { name: n, .. } | TAttr::Dynamic { name: n, .. } => {
545            n.eq_ignore_ascii_case(name)
546        }
547        _ => false,
548    })
549}
550
551/// The name of an inline event-handler attribute on this element, if it carries one.
552///
553/// ★ THE RULE IS THE SHAPE, NOT A LIST OF EVENT NAMES. HTML defines an event-handler content
554/// attribute as `on` followed by an event type, and event types are invented faster than any list
555/// here could track — a denylist of `onclick`/`onerror`/`onload` is exactly the enumeration that
556/// goes stale, and it fails OPEN when it does. So the test is structural: `on` + an ASCII letter.
557///
558/// The framework's own directive is `on-click`, whose third character is `-`, so it can never match
559/// — and it arrives as `TAttr::On` rather than `Static`/`Dynamic` anyway, so it is not even
560/// considered here. That is the distinction the message points at.
561fn inline_handler_attr(attrs: &[TAttr]) -> Option<String> {
562    attrs.iter().find_map(|a| {
563        let name = match a {
564            TAttr::Static { name, .. } | TAttr::Dynamic { name, .. } => name,
565            _ => return None,
566        };
567        let lower = name.to_ascii_lowercase();
568        let rest = lower.strip_prefix("on")?;
569        rest.chars()
570            .next()
571            .filter(char::is_ascii_alphabetic)
572            .map(|_| lower)
573    })
574}
575
576/// Whether `method` names a submission that changes something on the server.
577fn method_changes_state(method: &str) -> bool {
578    matches!(
579        method.to_ascii_lowercase().as_str(),
580        "post" | "put" | "patch" | "delete"
581    )
582}
583
584/// The method this form actually submits with, when that submission changes something — `None`
585/// when it is provably a plain `get`.
586///
587/// ★ THIS GUARD FAILS CLOSED, and it did not used to. It read `method` with [`static_attr`], which
588/// matches only a LITERAL attribute, so `<form method={m}>` answered `None`, defaulted to `"get"`,
589/// and shipped with no token — a computed method silently opting out of the one check that is an
590/// Error rather than a warning. An attribute the checker cannot read must mean "I cannot tell, so
591/// demand the token", never "it is safe". Getting that backwards is how a defence becomes decoration.
592///
593/// ★ AND `formmethod` OVERRIDES THE FORM. HTML lets a submit button carry its own method, so
594/// `<form action="/x"><button type="submit" formmethod="post">` is a state-changing submission on a
595/// form whose own `method` says `get`. Nothing modelled that attribute at all, so the whole shape
596/// was invisible. The subtree is walked because the button may sit inside a `{if}`, a fieldset or a
597/// row, exactly as [`has_csrf_field`] walks it.
598fn state_changing_method(attrs: &[TAttr], children: &[TNode]) -> Option<String> {
599    // The form's own method: a literal decides; anything computed is unreadable and so counts.
600    match static_attr(attrs, "method") {
601        Some(m) if method_changes_state(m) => return Some(m.to_ascii_lowercase()),
602        Some(_) => {}
603        None if has_attr(attrs, "method") => return Some("post".to_string()),
604        None => {}
605    }
606    overriding_method(children)
607}
608
609/// A `formmethod` anywhere under this form that changes state — or one computed, which cannot be
610/// read and therefore counts.
611fn overriding_method(nodes: &[TNode]) -> Option<String> {
612    nodes.iter().find_map(|n| match n {
613        TNode::Element {
614            attrs, children, ..
615        } => match static_attr(attrs, "formmethod") {
616            Some(m) if method_changes_state(m) => Some(m.to_ascii_lowercase()),
617            Some(_) => overriding_method(children),
618            None if has_attr(attrs, "formmethod") => Some("post".to_string()),
619            None => overriding_method(children),
620        },
621        TNode::If {
622            arms, otherwise, ..
623        } => arms
624            .iter()
625            .find_map(|(_, b)| overriding_method(b))
626            .or_else(|| otherwise.as_deref().and_then(overriding_method)),
627        TNode::For { body, empty, .. } => {
628            overriding_method(body).or_else(|| empty.as_deref().and_then(overriding_method))
629        }
630        TNode::Inspect { arms, .. } => arms.iter().find_map(|(_, b)| overriding_method(b)),
631        TNode::Block {
632            default: Some(body),
633            ..
634        } => overriding_method(body),
635        TNode::Component { children, .. } => overriding_method(children),
636        _ => None,
637    })
638}
639
640fn has_visible_content(children: &[TNode]) -> bool {
641    children.iter().any(|c| match c {
642        TNode::Text { text, .. } => !text.trim().is_empty(),
643        TNode::Hole { .. }
644        | TNode::Component { .. }
645        | TNode::Children { .. }
646        | TNode::Block { .. } => true,
647        TNode::Element {
648            tag,
649            attrs,
650            children,
651            ..
652        } => {
653            (tag.eq_ignore_ascii_case("img")
654                && static_attr(attrs, "alt").is_some_and(|a| !a.is_empty()))
655                || has_visible_content(children)
656        }
657        TNode::If {
658            arms, otherwise, ..
659        } => {
660            arms.iter().any(|(_, b)| has_visible_content(b))
661                || otherwise.as_deref().is_some_and(has_visible_content)
662        }
663        TNode::For { body, .. } => has_visible_content(body),
664        TNode::Inspect { arms, .. } => arms.iter().any(|(_, b)| has_visible_content(b)),
665        _ => false,
666    })
667}
668
669fn walk(
670    nodes: &[TNode],
671    inside_label: bool,
672    labels_for: &[String],
673    blocks: &mut Vec<String>,
674    children_seen: &mut usize,
675    out: &mut Vec<Finding>,
676) {
677    for n in nodes {
678        match n {
679            TNode::Element {
680                tag,
681                attrs,
682                children,
683                span,
684            } => {
685                let t = tag.to_ascii_lowercase();
686                if attrs.iter().any(|a| matches!(a, TAttr::Bind { .. }))
687                    && !matches!(t.as_str(), "input" | "textarea" | "select")
688                {
689                    out.push(finding(FindingKind::BindOnNonInput(tag.clone()), *span));
690                }
691                // A `bind` on a control the browser will not let a page write. See
692                // `FindingKind::BindNotHonoured`; the two elements are told apart by TAG and by a
693                // boolean attribute, neither of which the attribute-only question could see.
694                if attrs.iter().any(|a| matches!(a, TAttr::Bind { .. })) {
695                    let refused = if t == "select" && has_attr(attrs, "multiple") {
696                        crate::ast::view::BoundInput::SelectMultiple.refusal()
697                    } else if t == "input"
698                        && static_attr(attrs, "type")
699                            .is_some_and(|v| v.eq_ignore_ascii_case("file"))
700                    {
701                        crate::ast::view::BoundInput::File.refusal()
702                    } else {
703                        None
704                    };
705                    if let Some(why) = refused {
706                        out.push(finding(FindingKind::BindNotHonoured(why), *span));
707                    }
708                }
709                if let Some(handler) = inline_handler_attr(attrs) {
710                    out.push(finding(FindingKind::InlineEventHandler(handler), *span));
711                }
712                // A form whose method CHANGES something must carry the token. Checked on the
713                // form's own subtree, so a form nested in a card or a list item is found — a
714                // check that only looked at the top level would pass every real page.
715                if t == "form" {
716                    if let Some(method) = state_changing_method(attrs, children) {
717                        if !has_csrf_field(children) {
718                            out.push(finding(FindingKind::FormWithoutCsrf(method), *span));
719                        }
720                    }
721                }
722                match t.as_str() {
723                    "img" if !has_attr(attrs, "alt") => {
724                        out.push(finding(FindingKind::ImageWithoutAlt, *span))
725                    }
726                    "button"
727                        if !has_visible_content(children)
728                            && !has_attr(attrs, "aria-label")
729                            && !has_attr(attrs, "aria-labelledby")
730                            && !has_attr(attrs, "title") =>
731                    {
732                        out.push(finding(FindingKind::ButtonWithoutName, *span))
733                    }
734                    "input" | "textarea" | "select" => {
735                        let ty = static_attr(attrs, "type")
736                            .unwrap_or("text")
737                            .to_ascii_lowercase();
738                        let needs_label = t != "input"
739                            || !matches!(
740                                ty.as_str(),
741                                "hidden" | "submit" | "button" | "reset" | "image"
742                            );
743                        let labelled = inside_label
744                            || has_attr(attrs, "aria-label")
745                            || has_attr(attrs, "aria-labelledby")
746                            || attrs.iter().any(|a| matches!(a, TAttr::Dynamic { name, .. } if name.eq_ignore_ascii_case("id")))
747                            || static_attr(attrs, "id").is_some_and(|id| labels_for.iter().any(|l| l == id));
748                        if needs_label && !labelled {
749                            out.push(finding(FindingKind::InputWithoutLabel, *span));
750                        }
751                    }
752                    "a" if !has_attr(attrs, "href") => {
753                        out.push(finding(FindingKind::LinkWithoutHref, *span))
754                    }
755                    _ => {}
756                }
757                walk(
758                    children,
759                    inside_label || t == "label",
760                    labels_for,
761                    blocks,
762                    children_seen,
763                    out,
764                );
765            }
766            TNode::Component {
767                name,
768                props,
769                children,
770                span,
771            } => {
772                if props.iter().any(|a| matches!(a, TAttr::Class { .. })) {
773                    out.push(finding(
774                        FindingKind::ClassToggleOnComponent(name.clone()),
775                        *span,
776                    ));
777                }
778                walk(
779                    children,
780                    inside_label,
781                    labels_for,
782                    blocks,
783                    children_seen,
784                    out,
785                );
786            }
787            TNode::Block {
788                name,
789                default,
790                span,
791            } => {
792                if blocks.iter().any(|b| b == name) {
793                    out.push(finding(FindingKind::DuplicateBlock(name.clone()), *span));
794                } else {
795                    blocks.push(name.clone());
796                }
797                if let Some(d) = default {
798                    walk(d, inside_label, labels_for, blocks, children_seen, out);
799                }
800            }
801            TNode::Children { span } => {
802                *children_seen += 1;
803                if *children_seen > 1 {
804                    out.push(finding(FindingKind::DuplicateChildren, *span));
805                }
806            }
807            TNode::If {
808                arms, otherwise, ..
809            } => {
810                for (_, b) in arms {
811                    walk(b, inside_label, labels_for, blocks, children_seen, out);
812                }
813                if let Some(o) = otherwise {
814                    walk(o, inside_label, labels_for, blocks, children_seen, out);
815                }
816            }
817            TNode::For { body, empty, .. } => {
818                walk(body, inside_label, labels_for, blocks, children_seen, out);
819                if let Some(e) = empty {
820                    walk(e, inside_label, labels_for, blocks, children_seen, out);
821                }
822            }
823            TNode::Inspect { arms, .. } => {
824                for (_, b) in arms {
825                    walk(b, inside_label, labels_for, blocks, children_seen, out);
826                }
827            }
828            TNode::Text { .. } | TNode::Hole { .. } | TNode::Let { .. } | TNode::Extends { .. } => {
829            }
830        }
831    }
832}
833
834/// The elements a document's head may hold. `<style>` and `<script>` are refused by
835/// the tokenizer before this ever runs, and `<base>` is included because a site
836/// served under a path prefix needs it.
837///
838/// ★ `<html>` IS HERE AND IS NOT A HEAD ELEMENT, DELIBERATELY. `<html lang="fr">` in a `Head:`
839/// block is how a page declares its language — `lang` is what a screen reader picks a voice from
840/// and what a browser offers to translate on, and it was welded to `en` for every page LOGOS has
841/// ever built. The document builder LIFTS the tag onto the document element rather than rendering
842/// it, so it never reaches the head it is written in; allowing it here is what lets it be written
843/// at all. Anything else genuinely misplaced — a `<div>`, a `<p>` — is still caught.
844const HEAD_ELEMENTS: &[&str] = &["title", "meta", "link", "base", "html"];
845
846/// The first element in `nodes` that does not belong in a document's head, with its
847/// span — `None` when every element is head-legal.
848///
849/// A browser relocates a body element out of the head rather than reporting it, so
850/// this is the only place the mistake can be caught where it was made.
851pub fn first_non_head_element(
852    nodes: &[crate::lgx::template::TNode],
853) -> Option<(String, crate::token::Span)> {
854    use crate::lgx::template::TNode;
855    for n in nodes {
856        match n {
857            TNode::Element {
858                tag,
859                children,
860                span,
861                ..
862            } => {
863                if !HEAD_ELEMENTS.contains(&tag.to_ascii_lowercase().as_str()) {
864                    return Some((tag.clone(), *span));
865                }
866                // `<title>Hello {who}</title>` has children; they are text and holes,
867                // never elements, but walking is what makes that a fact rather than
868                // an assumption.
869                if let Some(bad) = first_non_head_element(children) {
870                    return Some(bad);
871                }
872            }
873            TNode::Component { name, span, .. } => return Some((name.clone(), *span)),
874            _ => {}
875        }
876    }
877    None
878}