Skip to main content

webui_parser/
diagnostic.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Centralized, structured diagnostics for build-time template-authoring
5//! mistakes.
6//!
7//! Every "the developer wrote invalid template syntax" problem is described by
8//! a [`Diagnostic`] so they share one consistent shape (title + location +
9//! offending snippet + actionable `help`). A [`Diagnostic`] is **plain data**:
10//! it carries no color and never decides whether output is a terminal.
11//!
12//! Presentation is the responsibility of the **entry point**, not the library:
13//! - `webui-cli` reads the structured fields and colorizes them with
14//!   `console::style()` for a friendly terminal report.
15//! - `webui-ffi`, `webui-node`, and `webui-wasm` surface the plain
16//!   [`fmt::Display`] text through their own error channel
17//!   (`webui_last_error`, `napi::Error`, `JsValue`) so the host application can
18//!   handle it however it likes.
19//!
20//! A [`Diagnostic`] reaches those consumers as the payload of
21//! [`crate::ParserError::Template`]; its [`fmt::Display`] renders the plain
22//! report below:
23//!
24//! ```text
25//! error: invalid @pointerdown handler [invalid-event-handler]
26//!   in component <mail-inbox-page> · element <button>
27//!     @pointerdown="e.preventDefault()"
28//!   help: use @pointerdown="{handler()}" or @pointerdown="{handler(e)}" to pass the event
29//! ```
30
31use std::fmt;
32use std::fmt::Write as _;
33
34/// Severity of a [`Diagnostic`].
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Severity {
37    /// A fatal authoring error — the build cannot continue.
38    Error,
39    /// A non-fatal warning — the build continues. Surfaced e.g. for CSS tokens
40    /// used only with a literal `var()` fallback and absent from every theme.
41    Warning,
42}
43
44impl Severity {
45    /// Human label printed before the title (`error` / `warning`).
46    #[must_use]
47    pub fn label(self) -> &'static str {
48        match self {
49            Severity::Error => "error",
50            Severity::Warning => "warning",
51        }
52    }
53}
54
55/// Stable, machine-readable diagnostic codes.
56///
57/// Each constant is a permanent identifier for one class of authoring mistake.
58/// The code is rendered alongside every diagnostic and emitted as a structured
59/// field by `webui build --format json`, so editors, CI, and AI assistants can
60/// map an error to a deterministic fix or docs page regardless of the
61/// human-readable wording. Treat these as a stable API surface — rename only
62/// with a deliberate, documented migration.
63pub mod codes {
64    /// `<for>` element is missing its required `each` attribute.
65    pub const MISSING_FOR_EACH: &str = "missing-for-each";
66    /// `<for each>` value is not of the form `item in collection`.
67    pub const INVALID_FOR_EACH: &str = "invalid-for-each";
68    /// `<for each>` item or collection name uses disallowed characters.
69    pub const INVALID_FOR_IDENTIFIER: &str = "invalid-for-identifier";
70    /// An explicit repeat key is malformed or used on an invalid element.
71    pub const INVALID_FOR_KEY: &str = "invalid-for-key";
72    /// `<if>` element is missing its required `condition` attribute.
73    pub const MISSING_IF_CONDITION: &str = "missing-if-condition";
74    /// `<if condition>` value is not a parseable expression.
75    pub const INVALID_IF_CONDITION: &str = "invalid-if-condition";
76    /// A component tag has no matching registered component.
77    pub const UNKNOWN_COMPONENT: &str = "unknown-component";
78    /// An `@event` handler value is not a valid `{handler()}` expression.
79    pub const INVALID_EVENT_HANDLER: &str = "invalid-event-handler";
80    /// A scriptless HTML component contains event bindings and needs authored JS.
81    pub const SCRIPTLESS_EVENT_HANDLER: &str = "scriptless-event-handler";
82    /// A `w-ref` binding is missing its required `{braces}`.
83    pub const INVALID_W_REF: &str = "invalid-w-ref";
84    /// An HTML element is missing its closing tag.
85    pub const UNCLOSED_HTML_TAG: &str = "unclosed-html-tag";
86    /// An HTML tag could not be parsed (missing `>`, stray `<`, …).
87    pub const MALFORMED_HTML_TAG: &str = "malformed-html-tag";
88    /// A closing tag has no matching opening tag.
89    pub const UNEXPECTED_CLOSING_TAG: &str = "unexpected-closing-tag";
90    /// An unwrapped Light DOM component contains a native `<slot>`.
91    pub const LIGHT_DOM_SLOT: &str = "light-dom-slot";
92    /// A declarative Shadow DOM wrapper has an unsupported mode or placement.
93    pub const INVALID_SHADOW_ROOT_MODE: &str = "invalid-shadow-root-mode";
94    /// Legacy code for the removed compiler-owned Light DOM marker diagnostic.
95    #[deprecated(note = "Light DOM no longer generates compiler-owned markers")]
96    pub const RESERVED_LIGHT_DOM_MARKER: &str = "reserved-light-dom-marker";
97    /// Legacy code for the removed Light DOM scope-collision diagnostic.
98    #[deprecated(note = "Light DOM no longer generates compiler-owned markers")]
99    pub const LIGHT_SCOPE_COLLISION: &str = "light-scope-collision";
100    /// Light component CSS contains a Shadow-only selector.
101    pub const UNSUPPORTED_LIGHT_CSS: &str = "unsupported-light-css";
102    /// Compatibility alias for the FAST-owned Light DOM diagnostic code.
103    pub const FAST_LIGHT_DOM_UNSUPPORTED: &str =
104        crate::plugin::fast::diagnostic::FAST_LIGHT_DOM_UNSUPPORTED;
105    /// Legacy code for removed Light-local keyframe namespacing.
106    #[deprecated(note = "Light DOM no longer namespaces component-local keyframes")]
107    pub const DYNAMIC_LIGHT_KEYFRAME: &str = "dynamic-light-keyframe";
108    /// An HTML comment was opened but never closed with `-->`.
109    pub const UNTERMINATED_HTML_COMMENT: &str = "unterminated-html-comment";
110    /// An HTML declaration was opened but never closed with `>`.
111    pub const UNTERMINATED_HTML_DECLARATION: &str = "unterminated-html-declaration";
112    /// Template nesting exceeds the parser's depth limit.
113    pub const EXCESSIVE_NESTING: &str = "excessive-nesting";
114    /// A template references itself (directly or transitively) at build time.
115    pub const RECURSIVE_TEMPLATE: &str = "recursive-template";
116    /// A `<style>` block contains malformed CSS.
117    pub const INVALID_CSS: &str = "invalid-css";
118    /// A CSS token required by parser output is missing from the configured theme.
119    pub const MISSING_THEME_TOKEN: &str = "missing-theme-token";
120    /// A CSS token used only with a literal `var()` fallback and absent from
121    /// every theme — non-fatal, but usually a typo. Severity: warning.
122    pub const UNTHEMED_TOKEN: &str = "unthemed-token";
123    /// `<boundary>` is missing its required, non-empty `name` attribute.
124    pub const MISSING_BOUNDARY_NAME: &str = "missing-boundary-name";
125    /// `<boundary name>` is not a static string literal (e.g. it
126    /// contains a `{{binding}}`).
127    pub const INVALID_BOUNDARY_NAME: &str = "invalid-boundary-name";
128    /// Two `<boundary>` directives in the same owning template share a `name`.
129    pub const DUPLICATE_BOUNDARY_NAME: &str = "duplicate-boundary-name";
130    /// A `<boundary>` declaration renders more than once — through repeated
131    /// static callsites of its owning component — without a stable occurrence
132    /// key.
133    pub const MISSING_BOUNDARY_KEY: &str = "missing-boundary-key";
134    /// A `<boundary>` is reachable from a `<for>` repeat body, directly or
135    /// transitively through `<if>`, routes/outlets, or a reusable component.
136    /// A repeat iteration cannot suspend, so the declaration can never be paced
137    /// independently.
138    pub const BOUNDARY_IN_REPEAT: &str = "boundary-in-repeat";
139    /// A `<boundary key>` is empty or has malformed binding delimiters.
140    pub const INVALID_BOUNDARY_KEY: &str = "invalid-boundary-key";
141    /// The protocol-wide boundary declaration identity space was exhausted.
142    pub const TOO_MANY_BOUNDARIES: &str = "too-many-boundaries";
143    /// A `<boundary>` is nested inside another `<boundary>`.
144    pub const NESTED_BOUNDARY: &str = "nested-boundary";
145    /// A `<boundary>` would cut through component host content or an
146    /// inert/raw-text HTML element instead of independently wrapping it.
147    pub const BOUNDARY_CROSSES_SCOPE: &str = "boundary-crosses-scope";
148    /// A `<boundary>` appears before `<body>` opens or after it closes.
149    pub const BOUNDARY_OUTSIDE_BODY: &str = "boundary-outside-body";
150    /// A `<boundary>` appears inside an HTML insertion mode that
151    /// foster-parents unknown elements (`<table>`, `<tbody>`, `<tr>`,
152    /// `<select>`, …), which would separate the generated `<webui-hydrate>`
153    /// sentinel from its payload script.
154    pub const BOUNDARY_IN_FOSTER_CONTEXT: &str = "boundary-in-foster-context";
155    /// A route-local `<boundary>` is nested in ignored route markup instead of
156    /// being a direct child of the `<route>`.
157    pub const INVALID_ROUTE_BOUNDARY_PLACEMENT: &str = "invalid-route-boundary-placement";
158    /// `<webui-hydrate>` was written by an application author. It is reserved
159    /// for the compiler/handler-generated runtime hydration sentinel.
160    pub const AUTHORED_WEBUI_HYDRATE: &str = "authored-webui-hydrate";
161    /// A build declares `<boundary>` streaming checkpoints but has no
162    /// state-projection manifest, so every checkpoint serializes the whole
163    /// state object instead of its own components' keys.
164    /// Severity: warning.
165    pub const STREAMING_WITHOUT_PROJECTION: &str = "streaming-without-projection";
166    /// More than one build output shares the file name of an authored
167    /// `<script type="module" src>`, so its shared chunks cannot be preloaded
168    /// without guessing which output the URL serves.
169    /// Severity: warning.
170    pub const AMBIGUOUS_MODULE_ENTRY: &str = "ambiguous-module-entry";
171    /// A module entry's static import closure exceeded the `modulepreload`
172    /// hint cap. Preloads share one connection, so the remainder would delay
173    /// the entry they are meant to accelerate.
174    /// Severity: warning.
175    pub const EXCESSIVE_MODULE_PRELOADS: &str = "excessive-module-preloads";
176    /// A resolved `modulepreload` href contains characters that cannot be
177    /// written verbatim into an HTML attribute, so the hint was dropped rather
178    /// than emitted or escaped.
179    /// Severity: warning.
180    pub const UNSAFE_MODULE_PRELOAD: &str = "unsafe-module-preload";
181    /// Static component assets were requested for a graph containing `<route>`.
182    pub const COMPONENT_ASSETS_WITH_ROUTES: &str = "component-assets-with-routes";
183    /// A component's root rendering or hydration policy is malformed.
184    pub const INVALID_COMPONENT_RENDER_POLICY: &str = "invalid-component-render-policy";
185    /// Lazy rendering omitted the intrinsic block-size estimate needed to
186    /// reserve scroll geometry while the browser skips layout.
187    pub const MISSING_RENDER_RESERVATION: &str = "missing-render-reservation";
188    /// A lazy-render intrinsic block-size estimate is malformed or misplaced.
189    pub const INVALID_RENDER_RESERVATION: &str = "invalid-render-reservation";
190}
191
192/// A build-time template-authoring diagnostic.
193///
194/// Construct with [`Diagnostic::error`] / [`Diagnostic::warning`], attach
195/// context with the builder methods, then return it inside
196/// [`crate::ParserError::Template`]. The library never colors or prints it; the
197/// CLI reads the fields via the getters and renders them, while other hosts use
198/// the plain [`fmt::Display`] text.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Diagnostic {
201    severity: Severity,
202    /// Stable, machine-readable code (e.g. `invalid-for-each`); see [`codes`].
203    code: Option<&'static str>,
204    title: String,
205    component: Option<String>,
206    element: Option<String>,
207    snippet: Option<String>,
208    help: Option<String>,
209    /// 1-based `(line, column)` of the offending source location, if known.
210    position: Option<(usize, usize)>,
211}
212
213impl Diagnostic {
214    /// Start a fatal error diagnostic. Titles are short and lowercase by
215    /// convention (e.g. `invalid @click handler`).
216    #[must_use]
217    pub fn error(title: impl Into<String>) -> Self {
218        Self::new(Severity::Error, title)
219    }
220
221    /// Start a non-fatal warning diagnostic.
222    ///
223    /// Rendered with the same location/snippet/`help:` layout as an error, but
224    /// the build continues (e.g. an unthemed literal-fallback CSS token).
225    #[must_use]
226    pub fn warning(title: impl Into<String>) -> Self {
227        Self::new(Severity::Warning, title)
228    }
229
230    fn new(severity: Severity, title: impl Into<String>) -> Self {
231        Self {
232            severity,
233            code: None,
234            title: title.into(),
235            component: None,
236            element: None,
237            snippet: None,
238            help: None,
239            position: None,
240        }
241    }
242
243    /// Attach a stable, machine-readable error [`code`](codes) (e.g.
244    /// `invalid-for-each`).
245    ///
246    /// The code is a constant identifier that does not change with the wording
247    /// of the message, so tools and AI assistants can recognize the error class
248    /// and apply a deterministic fix. Prefer the constants in [`codes`].
249    #[must_use]
250    pub fn code(mut self, code: &'static str) -> Self {
251        self.code = Some(code);
252        self
253    }
254
255    /// Name the owning component template (e.g. `mail-inbox-page`).
256    #[must_use]
257    pub fn component(mut self, component: impl Into<String>) -> Self {
258        self.component = Some(component.into());
259        self
260    }
261
262    /// Name the offending element tag (e.g. `button`), when known.
263    #[must_use]
264    pub fn element(mut self, element: impl Into<String>) -> Self {
265        self.element = Some(element.into());
266        self
267    }
268
269    /// Attach the offending source snippet (e.g. `@click="bad()"`).
270    #[must_use]
271    pub fn snippet(mut self, snippet: impl Into<String>) -> Self {
272        self.snippet = Some(snippet.into());
273        self
274    }
275
276    /// Attach a `help:` hint describing the fix.
277    #[must_use]
278    pub fn help(mut self, help: impl Into<String>) -> Self {
279        self.help = Some(help.into());
280        self
281    }
282
283    /// Attach the 1-based `(line, column)` of the offending source location.
284    #[must_use]
285    pub fn position(mut self, line: usize, column: usize) -> Self {
286        self.position = Some((line, column));
287        self
288    }
289
290    /// Attach the source location computed from a byte `offset` into `source`.
291    ///
292    /// Convenience over [`Diagnostic::position`] for callers that hold the
293    /// source text and a byte offset (e.g. an element's start). Computes the
294    /// 1-based line and column in a single forward scan.
295    #[must_use]
296    pub fn at_offset(self, source: &str, offset: usize) -> Self {
297        let (line, column) = line_column(source, offset);
298        self.position(line, column)
299    }
300
301    /// Severity of this diagnostic.
302    #[must_use]
303    pub fn severity(&self) -> Severity {
304        self.severity
305    }
306
307    /// The stable, machine-readable error [`code`](codes), if set.
308    #[must_use]
309    pub fn error_code(&self) -> Option<&'static str> {
310        self.code
311    }
312
313    /// The short error title (e.g. `invalid @click handler`).
314    #[must_use]
315    pub fn title(&self) -> &str {
316        &self.title
317    }
318
319    /// The owning component tag, if set.
320    #[must_use]
321    pub fn component_name(&self) -> Option<&str> {
322        self.component.as_deref()
323    }
324
325    /// The offending element tag, if set.
326    #[must_use]
327    pub fn element_name(&self) -> Option<&str> {
328        self.element.as_deref()
329    }
330
331    /// The offending source snippet, if set.
332    #[must_use]
333    pub fn snippet_text(&self) -> Option<&str> {
334        self.snippet.as_deref()
335    }
336
337    /// The `help:` hint, if set.
338    #[must_use]
339    pub fn help_text(&self) -> Option<&str> {
340        self.help.as_deref()
341    }
342
343    /// The 1-based `(line, column)` of the offending source location, if known.
344    #[must_use]
345    pub fn position_line_column(&self) -> Option<(usize, usize)> {
346        self.position
347    }
348
349    /// Render the location line content (already including its leading marker),
350    /// if any.
351    ///
352    /// When a source position is known it is reported rustc-style as
353    /// `--> owner:line:column` (the `owner` is the entry file or component
354    /// tag), which is compact and recognizable. Without a position it falls
355    /// back to a descriptive `in component <c> · element <e>` form. Returned so
356    /// callers (e.g. the CLI) can render the location on its own styled line.
357    #[must_use]
358    pub fn location(&self) -> Option<String> {
359        if let Some((line, column)) = self.position {
360            return Some(match &self.component {
361                Some(c) => format!("--> {c}:{line}:{column}"),
362                None => format!("--> {line}:{column}"),
363            });
364        }
365        match (&self.component, &self.element) {
366            (Some(c), Some(e)) => Some(format!("in component <{c}> · element <{e}>")),
367            (Some(c), None) => Some(format!("in component <{c}>")),
368            (None, Some(e)) => Some(format!("in element <{e}>")),
369            (None, None) => None,
370        }
371    }
372
373    /// Render the message body — everything after the severity label: title,
374    /// location, offending snippet, and `help:` hint, each on its own line.
375    ///
376    /// Used by callers that supply their own leading label (e.g. the dev
377    /// server prints `build error: {body}`) so the severity word isn't
378    /// duplicated. [`fmt::Display`] prepends the severity to this body.
379    #[must_use]
380    pub fn body(&self) -> String {
381        let mut out = String::with_capacity(96);
382        out.push_str(&self.title);
383        if let Some(code) = self.code {
384            let _ = write!(out, " [{code}]");
385        }
386        if let Some(location) = self.location() {
387            let _ = write!(out, "\n  {location}");
388        }
389        if let Some(snippet) = &self.snippet {
390            let _ = write!(out, "\n    {snippet}");
391        }
392        if let Some(help) = &self.help {
393            let _ = write!(out, "\n  help: {help}");
394        }
395        out
396    }
397}
398
399/// Compute the 1-based `(line, column)` of a byte `offset` into `source`.
400///
401/// A single forward scan over the bytes preceding `offset` (no regex, no
402/// allocation); the column counts characters within the line so multi-byte
403/// UTF-8 is reported correctly.
404#[must_use]
405pub(crate) fn line_column(source: &str, offset: usize) -> (usize, usize) {
406    let offset = offset.min(source.len());
407    let mut line = 1usize;
408    let mut line_start = 0usize;
409    for (i, &b) in source.as_bytes()[..offset].iter().enumerate() {
410        if b == b'\n' {
411            line += 1;
412            line_start = i + 1;
413        }
414    }
415    let column = source[line_start..offset].chars().count() + 1;
416    (line, column)
417}
418
419impl fmt::Display for Diagnostic {
420    /// Render the plain, color-free report. Hosts that want color (the CLI)
421    /// read the fields via the getters instead.
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        write!(f, "{}: {}", self.severity.label(), self.body())
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn renders_full_error_compact() {
433        let diag = Diagnostic::error("invalid @pointerdown handler")
434            .component("mail-inbox-page")
435            .element("button")
436            .snippet(r#"@pointerdown="e.preventDefault()""#)
437            .help(r#"use @pointerdown="{handler()}" to pass the event"#);
438
439        let expected = "error: invalid @pointerdown handler\n  \
440             in component <mail-inbox-page> · element <button>\n    \
441             @pointerdown=\"e.preventDefault()\"\n  \
442             help: use @pointerdown=\"{handler()}\" to pass the event";
443
444        assert_eq!(diag.to_string(), expected);
445    }
446
447    #[test]
448    fn omits_optional_sections_when_absent() {
449        let diag = Diagnostic::warning("something minor").component("my-card");
450        assert_eq!(
451            diag.to_string(),
452            "warning: something minor\n  in component <my-card>"
453        );
454    }
455
456    #[test]
457    fn element_only_location() {
458        let diag = Diagnostic::error("x").element("button");
459        assert_eq!(diag.to_string(), "error: x\n  in element <button>");
460    }
461
462    #[test]
463    fn getters_expose_structured_fields() {
464        let diag = Diagnostic::error("boom")
465            .component("c")
466            .element("e")
467            .snippet("snip")
468            .help("do this");
469        assert_eq!(diag.severity(), Severity::Error);
470        assert_eq!(diag.title(), "boom");
471        assert_eq!(diag.component_name(), Some("c"));
472        assert_eq!(diag.element_name(), Some("e"));
473        assert_eq!(diag.snippet_text(), Some("snip"));
474        assert_eq!(diag.help_text(), Some("do this"));
475        assert_eq!(
476            diag.location().as_deref(),
477            Some("in component <c> · element <e>")
478        );
479    }
480
481    #[test]
482    fn position_renders_rustc_style_location() {
483        let diag = Diagnostic::error("boom")
484            .component("index.html")
485            .element("for")
486            .position(3, 7);
487        assert_eq!(diag.position_line_column(), Some((3, 7)));
488        // A known position overrides the descriptive form with `--> file:l:c`.
489        assert_eq!(diag.location().as_deref(), Some("--> index.html:3:7"));
490        assert!(diag.to_string().contains("--> index.html:3:7"));
491    }
492
493    #[test]
494    fn bare_position_renders_without_component_or_element() {
495        let diag = Diagnostic::error("boom").position(5, 1);
496        assert_eq!(diag.location().as_deref(), Some("--> 5:1"));
497    }
498
499    #[test]
500    fn line_column_counts_lines_and_chars() {
501        let src = "<div>\n  <for each=\"x\">\n</div>";
502        // Offset of the '<' in "<for" — line 2, column 3 (after two spaces).
503        let offset = src.find("<for").unwrap();
504        assert_eq!(line_column(src, offset), (2, 3));
505        // Start of file.
506        assert_eq!(line_column(src, 0), (1, 1));
507    }
508
509    #[test]
510    fn line_column_uses_char_columns_for_multibyte() {
511        // "📚x" — the 'x' is one char in, though the emoji is 4 bytes.
512        let src = "📚x";
513        let offset = src.find('x').unwrap();
514        assert_eq!(line_column(src, offset), (1, 2));
515    }
516}