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