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. Part of the diagnostics
40    /// surface for future advisories; not constructed while every current
41    /// authoring check is fatal.
42    #[allow(dead_code)]
43    Warning,
44}
45
46impl Severity {
47    /// Human label printed before the title (`error` / `warning`).
48    #[must_use]
49    pub fn label(self) -> &'static str {
50        match self {
51            Severity::Error => "error",
52            Severity::Warning => "warning",
53        }
54    }
55}
56
57/// Stable, machine-readable diagnostic codes.
58///
59/// Each constant is a permanent identifier for one class of authoring mistake.
60/// The code is rendered alongside every diagnostic and emitted as a structured
61/// field by `webui build --format json`, so editors, CI, and AI assistants can
62/// map an error to a deterministic fix or docs page regardless of the
63/// human-readable wording. Treat these as a stable API surface — rename only
64/// with a deliberate, documented migration.
65pub mod codes {
66    /// `<for>` element is missing its required `each` attribute.
67    pub const MISSING_FOR_EACH: &str = "missing-for-each";
68    /// `<for each>` value is not of the form `item in collection`.
69    pub const INVALID_FOR_EACH: &str = "invalid-for-each";
70    /// `<for each>` item or collection name uses disallowed characters.
71    pub const INVALID_FOR_IDENTIFIER: &str = "invalid-for-identifier";
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 `w-ref` binding is missing its required `{braces}`.
81    pub const INVALID_W_REF: &str = "invalid-w-ref";
82    /// An HTML element is missing its closing tag.
83    pub const UNCLOSED_HTML_TAG: &str = "unclosed-html-tag";
84    /// An HTML tag could not be parsed (missing `>`, stray `<`, …).
85    pub const MALFORMED_HTML_TAG: &str = "malformed-html-tag";
86    /// A closing tag has no matching opening tag.
87    pub const UNEXPECTED_CLOSING_TAG: &str = "unexpected-closing-tag";
88    /// An HTML comment was opened but never closed with `-->`.
89    pub const UNTERMINATED_HTML_COMMENT: &str = "unterminated-html-comment";
90    /// An HTML declaration was opened but never closed with `>`.
91    pub const UNTERMINATED_HTML_DECLARATION: &str = "unterminated-html-declaration";
92    /// Template nesting exceeds the parser's depth limit.
93    pub const EXCESSIVE_NESTING: &str = "excessive-nesting";
94    /// A template references itself (directly or transitively) at build time.
95    pub const RECURSIVE_TEMPLATE: &str = "recursive-template";
96    /// A `<style>` block contains malformed CSS.
97    pub const INVALID_CSS: &str = "invalid-css";
98}
99
100/// A build-time template-authoring diagnostic.
101///
102/// Construct with [`Diagnostic::error`] / [`Diagnostic::warning`], attach
103/// context with the builder methods, then return it inside
104/// [`crate::ParserError::Template`]. The library never colors or prints it; the
105/// CLI reads the fields via the getters and renders them, while other hosts use
106/// the plain [`fmt::Display`] text.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Diagnostic {
109    severity: Severity,
110    /// Stable, machine-readable code (e.g. `invalid-for-each`); see [`codes`].
111    code: Option<&'static str>,
112    title: String,
113    component: Option<String>,
114    element: Option<String>,
115    snippet: Option<String>,
116    help: Option<String>,
117    /// 1-based `(line, column)` of the offending source location, if known.
118    position: Option<(usize, usize)>,
119}
120
121impl Diagnostic {
122    /// Start a fatal error diagnostic. Titles are short and lowercase by
123    /// convention (e.g. `invalid @click handler`).
124    #[must_use]
125    pub fn error(title: impl Into<String>) -> Self {
126        Self::new(Severity::Error, title)
127    }
128
129    /// Start a non-fatal warning diagnostic.
130    ///
131    /// Reserved for future advisories; every current authoring check is fatal,
132    /// so this is not yet called outside tests.
133    #[allow(dead_code)]
134    #[must_use]
135    pub fn warning(title: impl Into<String>) -> Self {
136        Self::new(Severity::Warning, title)
137    }
138
139    fn new(severity: Severity, title: impl Into<String>) -> Self {
140        Self {
141            severity,
142            code: None,
143            title: title.into(),
144            component: None,
145            element: None,
146            snippet: None,
147            help: None,
148            position: None,
149        }
150    }
151
152    /// Attach a stable, machine-readable error [`code`](codes) (e.g.
153    /// `invalid-for-each`).
154    ///
155    /// The code is a constant identifier that does not change with the wording
156    /// of the message, so tools and AI assistants can recognize the error class
157    /// and apply a deterministic fix. Prefer the constants in [`codes`].
158    #[must_use]
159    pub fn code(mut self, code: &'static str) -> Self {
160        self.code = Some(code);
161        self
162    }
163
164    /// Name the owning component template (e.g. `mail-inbox-page`).
165    #[must_use]
166    pub fn component(mut self, component: impl Into<String>) -> Self {
167        self.component = Some(component.into());
168        self
169    }
170
171    /// Name the offending element tag (e.g. `button`), when known.
172    #[must_use]
173    pub fn element(mut self, element: impl Into<String>) -> Self {
174        self.element = Some(element.into());
175        self
176    }
177
178    /// Attach the offending source snippet (e.g. `@click="bad()"`).
179    #[must_use]
180    pub fn snippet(mut self, snippet: impl Into<String>) -> Self {
181        self.snippet = Some(snippet.into());
182        self
183    }
184
185    /// Attach a `help:` hint describing the fix.
186    #[must_use]
187    pub fn help(mut self, help: impl Into<String>) -> Self {
188        self.help = Some(help.into());
189        self
190    }
191
192    /// Attach the 1-based `(line, column)` of the offending source location.
193    #[must_use]
194    pub fn position(mut self, line: usize, column: usize) -> Self {
195        self.position = Some((line, column));
196        self
197    }
198
199    /// Attach the source location computed from a byte `offset` into `source`.
200    ///
201    /// Convenience over [`Diagnostic::position`] for callers that hold the
202    /// source text and a byte offset (e.g. an element's start). Computes the
203    /// 1-based line and column in a single forward scan.
204    #[must_use]
205    pub fn at_offset(self, source: &str, offset: usize) -> Self {
206        let (line, column) = line_column(source, offset);
207        self.position(line, column)
208    }
209
210    /// Severity of this diagnostic.
211    #[must_use]
212    pub fn severity(&self) -> Severity {
213        self.severity
214    }
215
216    /// The stable, machine-readable error [`code`](codes), if set.
217    #[must_use]
218    pub fn error_code(&self) -> Option<&'static str> {
219        self.code
220    }
221
222    /// The short error title (e.g. `invalid @click handler`).
223    #[must_use]
224    pub fn title(&self) -> &str {
225        &self.title
226    }
227
228    /// The owning component tag, if set.
229    #[must_use]
230    pub fn component_name(&self) -> Option<&str> {
231        self.component.as_deref()
232    }
233
234    /// The offending element tag, if set.
235    #[must_use]
236    pub fn element_name(&self) -> Option<&str> {
237        self.element.as_deref()
238    }
239
240    /// The offending source snippet, if set.
241    #[must_use]
242    pub fn snippet_text(&self) -> Option<&str> {
243        self.snippet.as_deref()
244    }
245
246    /// The `help:` hint, if set.
247    #[must_use]
248    pub fn help_text(&self) -> Option<&str> {
249        self.help.as_deref()
250    }
251
252    /// The 1-based `(line, column)` of the offending source location, if known.
253    #[must_use]
254    pub fn position_line_column(&self) -> Option<(usize, usize)> {
255        self.position
256    }
257
258    /// Render the location line content (already including its leading marker),
259    /// if any.
260    ///
261    /// When a source position is known it is reported rustc-style as
262    /// `--> owner:line:column` (the `owner` is the entry file or component
263    /// tag), which is compact and recognizable. Without a position it falls
264    /// back to a descriptive `in component <c> · element <e>` form. Returned so
265    /// callers (e.g. the CLI) can render the location on its own styled line.
266    #[must_use]
267    pub fn location(&self) -> Option<String> {
268        if let Some((line, column)) = self.position {
269            return Some(match &self.component {
270                Some(c) => format!("--> {c}:{line}:{column}"),
271                None => format!("--> {line}:{column}"),
272            });
273        }
274        match (&self.component, &self.element) {
275            (Some(c), Some(e)) => Some(format!("in component <{c}> · element <{e}>")),
276            (Some(c), None) => Some(format!("in component <{c}>")),
277            (None, Some(e)) => Some(format!("in element <{e}>")),
278            (None, None) => None,
279        }
280    }
281
282    /// Render the message body — everything after the severity label: title,
283    /// location, offending snippet, and `help:` hint, each on its own line.
284    ///
285    /// Used by callers that supply their own leading label (e.g. the dev
286    /// server prints `build error: {body}`) so the severity word isn't
287    /// duplicated. [`fmt::Display`] prepends the severity to this body.
288    #[must_use]
289    pub fn body(&self) -> String {
290        let mut out = String::with_capacity(96);
291        out.push_str(&self.title);
292        if let Some(code) = self.code {
293            let _ = write!(out, " [{code}]");
294        }
295        if let Some(location) = self.location() {
296            let _ = write!(out, "\n  {location}");
297        }
298        if let Some(snippet) = &self.snippet {
299            let _ = write!(out, "\n    {snippet}");
300        }
301        if let Some(help) = &self.help {
302            let _ = write!(out, "\n  help: {help}");
303        }
304        out
305    }
306}
307
308/// Compute the 1-based `(line, column)` of a byte `offset` into `source`.
309///
310/// A single forward scan over the bytes preceding `offset` (no regex, no
311/// allocation); the column counts characters within the line so multi-byte
312/// UTF-8 is reported correctly.
313#[must_use]
314pub(crate) fn line_column(source: &str, offset: usize) -> (usize, usize) {
315    let offset = offset.min(source.len());
316    let mut line = 1usize;
317    let mut line_start = 0usize;
318    for (i, &b) in source.as_bytes()[..offset].iter().enumerate() {
319        if b == b'\n' {
320            line += 1;
321            line_start = i + 1;
322        }
323    }
324    let column = source[line_start..offset].chars().count() + 1;
325    (line, column)
326}
327
328impl fmt::Display for Diagnostic {
329    /// Render the plain, color-free report. Hosts that want color (the CLI)
330    /// read the fields via the getters instead.
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        write!(f, "{}: {}", self.severity.label(), self.body())
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn renders_full_error_compact() {
342        let diag = Diagnostic::error("invalid @pointerdown handler")
343            .component("mail-inbox-page")
344            .element("button")
345            .snippet(r#"@pointerdown="e.preventDefault()""#)
346            .help(r#"use @pointerdown="{handler()}" to pass the event"#);
347
348        let expected = "error: invalid @pointerdown handler\n  \
349             in component <mail-inbox-page> · element <button>\n    \
350             @pointerdown=\"e.preventDefault()\"\n  \
351             help: use @pointerdown=\"{handler()}\" to pass the event";
352
353        assert_eq!(diag.to_string(), expected);
354    }
355
356    #[test]
357    fn omits_optional_sections_when_absent() {
358        let diag = Diagnostic::warning("something minor").component("my-card");
359        assert_eq!(
360            diag.to_string(),
361            "warning: something minor\n  in component <my-card>"
362        );
363    }
364
365    #[test]
366    fn element_only_location() {
367        let diag = Diagnostic::error("x").element("button");
368        assert_eq!(diag.to_string(), "error: x\n  in element <button>");
369    }
370
371    #[test]
372    fn getters_expose_structured_fields() {
373        let diag = Diagnostic::error("boom")
374            .component("c")
375            .element("e")
376            .snippet("snip")
377            .help("do this");
378        assert_eq!(diag.severity(), Severity::Error);
379        assert_eq!(diag.title(), "boom");
380        assert_eq!(diag.component_name(), Some("c"));
381        assert_eq!(diag.element_name(), Some("e"));
382        assert_eq!(diag.snippet_text(), Some("snip"));
383        assert_eq!(diag.help_text(), Some("do this"));
384        assert_eq!(
385            diag.location().as_deref(),
386            Some("in component <c> · element <e>")
387        );
388    }
389
390    #[test]
391    fn position_renders_rustc_style_location() {
392        let diag = Diagnostic::error("boom")
393            .component("index.html")
394            .element("for")
395            .position(3, 7);
396        assert_eq!(diag.position_line_column(), Some((3, 7)));
397        // A known position overrides the descriptive form with `--> file:l:c`.
398        assert_eq!(diag.location().as_deref(), Some("--> index.html:3:7"));
399        assert!(diag.to_string().contains("--> index.html:3:7"));
400    }
401
402    #[test]
403    fn bare_position_renders_without_component_or_element() {
404        let diag = Diagnostic::error("boom").position(5, 1);
405        assert_eq!(diag.location().as_deref(), Some("--> 5:1"));
406    }
407
408    #[test]
409    fn line_column_counts_lines_and_chars() {
410        let src = "<div>\n  <for each=\"x\">\n</div>";
411        // Offset of the '<' in "<for" — line 2, column 3 (after two spaces).
412        let offset = src.find("<for").unwrap();
413        assert_eq!(line_column(src, offset), (2, 3));
414        // Start of file.
415        assert_eq!(line_column(src, 0), (1, 1));
416    }
417
418    #[test]
419    fn line_column_uses_char_columns_for_multibyte() {
420        // "📚x" — the 'x' is one char in, though the emoji is 4 bytes.
421        let src = "📚x";
422        let offset = src.find('x').unwrap();
423        assert_eq!(line_column(src, offset), (1, 2));
424    }
425}