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}