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 /// `<if>` element is missing its required `condition` attribute.
71 pub const MISSING_IF_CONDITION: &str = "missing-if-condition";
72 /// `<if condition>` value is not a parseable expression.
73 pub const INVALID_IF_CONDITION: &str = "invalid-if-condition";
74 /// A component tag has no matching registered component.
75 pub const UNKNOWN_COMPONENT: &str = "unknown-component";
76 /// An `@event` handler value is not a valid `{handler()}` expression.
77 pub const INVALID_EVENT_HANDLER: &str = "invalid-event-handler";
78 /// A scriptless HTML component contains event bindings and needs authored JS.
79 pub const SCRIPTLESS_EVENT_HANDLER: &str = "scriptless-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 /// A CSS token required by parser output is missing from the configured theme.
99 pub const MISSING_THEME_TOKEN: &str = "missing-theme-token";
100 /// A CSS token used only with a literal `var()` fallback and absent from
101 /// every theme — non-fatal, but usually a typo. Severity: warning.
102 pub const UNTHEMED_TOKEN: &str = "unthemed-token";
103}
104
105/// A build-time template-authoring diagnostic.
106///
107/// Construct with [`Diagnostic::error`] / [`Diagnostic::warning`], attach
108/// context with the builder methods, then return it inside
109/// [`crate::ParserError::Template`]. The library never colors or prints it; the
110/// CLI reads the fields via the getters and renders them, while other hosts use
111/// the plain [`fmt::Display`] text.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Diagnostic {
114 severity: Severity,
115 /// Stable, machine-readable code (e.g. `invalid-for-each`); see [`codes`].
116 code: Option<&'static str>,
117 title: String,
118 component: Option<String>,
119 element: Option<String>,
120 snippet: Option<String>,
121 help: Option<String>,
122 /// 1-based `(line, column)` of the offending source location, if known.
123 position: Option<(usize, usize)>,
124}
125
126impl Diagnostic {
127 /// Start a fatal error diagnostic. Titles are short and lowercase by
128 /// convention (e.g. `invalid @click handler`).
129 #[must_use]
130 pub fn error(title: impl Into<String>) -> Self {
131 Self::new(Severity::Error, title)
132 }
133
134 /// Start a non-fatal warning diagnostic.
135 ///
136 /// Rendered with the same location/snippet/`help:` layout as an error, but
137 /// the build continues (e.g. an unthemed literal-fallback CSS token).
138 #[must_use]
139 pub fn warning(title: impl Into<String>) -> Self {
140 Self::new(Severity::Warning, title)
141 }
142
143 fn new(severity: Severity, title: impl Into<String>) -> Self {
144 Self {
145 severity,
146 code: None,
147 title: title.into(),
148 component: None,
149 element: None,
150 snippet: None,
151 help: None,
152 position: None,
153 }
154 }
155
156 /// Attach a stable, machine-readable error [`code`](codes) (e.g.
157 /// `invalid-for-each`).
158 ///
159 /// The code is a constant identifier that does not change with the wording
160 /// of the message, so tools and AI assistants can recognize the error class
161 /// and apply a deterministic fix. Prefer the constants in [`codes`].
162 #[must_use]
163 pub fn code(mut self, code: &'static str) -> Self {
164 self.code = Some(code);
165 self
166 }
167
168 /// Name the owning component template (e.g. `mail-inbox-page`).
169 #[must_use]
170 pub fn component(mut self, component: impl Into<String>) -> Self {
171 self.component = Some(component.into());
172 self
173 }
174
175 /// Name the offending element tag (e.g. `button`), when known.
176 #[must_use]
177 pub fn element(mut self, element: impl Into<String>) -> Self {
178 self.element = Some(element.into());
179 self
180 }
181
182 /// Attach the offending source snippet (e.g. `@click="bad()"`).
183 #[must_use]
184 pub fn snippet(mut self, snippet: impl Into<String>) -> Self {
185 self.snippet = Some(snippet.into());
186 self
187 }
188
189 /// Attach a `help:` hint describing the fix.
190 #[must_use]
191 pub fn help(mut self, help: impl Into<String>) -> Self {
192 self.help = Some(help.into());
193 self
194 }
195
196 /// Attach the 1-based `(line, column)` of the offending source location.
197 #[must_use]
198 pub fn position(mut self, line: usize, column: usize) -> Self {
199 self.position = Some((line, column));
200 self
201 }
202
203 /// Attach the source location computed from a byte `offset` into `source`.
204 ///
205 /// Convenience over [`Diagnostic::position`] for callers that hold the
206 /// source text and a byte offset (e.g. an element's start). Computes the
207 /// 1-based line and column in a single forward scan.
208 #[must_use]
209 pub fn at_offset(self, source: &str, offset: usize) -> Self {
210 let (line, column) = line_column(source, offset);
211 self.position(line, column)
212 }
213
214 /// Severity of this diagnostic.
215 #[must_use]
216 pub fn severity(&self) -> Severity {
217 self.severity
218 }
219
220 /// The stable, machine-readable error [`code`](codes), if set.
221 #[must_use]
222 pub fn error_code(&self) -> Option<&'static str> {
223 self.code
224 }
225
226 /// The short error title (e.g. `invalid @click handler`).
227 #[must_use]
228 pub fn title(&self) -> &str {
229 &self.title
230 }
231
232 /// The owning component tag, if set.
233 #[must_use]
234 pub fn component_name(&self) -> Option<&str> {
235 self.component.as_deref()
236 }
237
238 /// The offending element tag, if set.
239 #[must_use]
240 pub fn element_name(&self) -> Option<&str> {
241 self.element.as_deref()
242 }
243
244 /// The offending source snippet, if set.
245 #[must_use]
246 pub fn snippet_text(&self) -> Option<&str> {
247 self.snippet.as_deref()
248 }
249
250 /// The `help:` hint, if set.
251 #[must_use]
252 pub fn help_text(&self) -> Option<&str> {
253 self.help.as_deref()
254 }
255
256 /// The 1-based `(line, column)` of the offending source location, if known.
257 #[must_use]
258 pub fn position_line_column(&self) -> Option<(usize, usize)> {
259 self.position
260 }
261
262 /// Render the location line content (already including its leading marker),
263 /// if any.
264 ///
265 /// When a source position is known it is reported rustc-style as
266 /// `--> owner:line:column` (the `owner` is the entry file or component
267 /// tag), which is compact and recognizable. Without a position it falls
268 /// back to a descriptive `in component <c> · element <e>` form. Returned so
269 /// callers (e.g. the CLI) can render the location on its own styled line.
270 #[must_use]
271 pub fn location(&self) -> Option<String> {
272 if let Some((line, column)) = self.position {
273 return Some(match &self.component {
274 Some(c) => format!("--> {c}:{line}:{column}"),
275 None => format!("--> {line}:{column}"),
276 });
277 }
278 match (&self.component, &self.element) {
279 (Some(c), Some(e)) => Some(format!("in component <{c}> · element <{e}>")),
280 (Some(c), None) => Some(format!("in component <{c}>")),
281 (None, Some(e)) => Some(format!("in element <{e}>")),
282 (None, None) => None,
283 }
284 }
285
286 /// Render the message body — everything after the severity label: title,
287 /// location, offending snippet, and `help:` hint, each on its own line.
288 ///
289 /// Used by callers that supply their own leading label (e.g. the dev
290 /// server prints `build error: {body}`) so the severity word isn't
291 /// duplicated. [`fmt::Display`] prepends the severity to this body.
292 #[must_use]
293 pub fn body(&self) -> String {
294 let mut out = String::with_capacity(96);
295 out.push_str(&self.title);
296 if let Some(code) = self.code {
297 let _ = write!(out, " [{code}]");
298 }
299 if let Some(location) = self.location() {
300 let _ = write!(out, "\n {location}");
301 }
302 if let Some(snippet) = &self.snippet {
303 let _ = write!(out, "\n {snippet}");
304 }
305 if let Some(help) = &self.help {
306 let _ = write!(out, "\n help: {help}");
307 }
308 out
309 }
310}
311
312/// Compute the 1-based `(line, column)` of a byte `offset` into `source`.
313///
314/// A single forward scan over the bytes preceding `offset` (no regex, no
315/// allocation); the column counts characters within the line so multi-byte
316/// UTF-8 is reported correctly.
317#[must_use]
318pub(crate) fn line_column(source: &str, offset: usize) -> (usize, usize) {
319 let offset = offset.min(source.len());
320 let mut line = 1usize;
321 let mut line_start = 0usize;
322 for (i, &b) in source.as_bytes()[..offset].iter().enumerate() {
323 if b == b'\n' {
324 line += 1;
325 line_start = i + 1;
326 }
327 }
328 let column = source[line_start..offset].chars().count() + 1;
329 (line, column)
330}
331
332impl fmt::Display for Diagnostic {
333 /// Render the plain, color-free report. Hosts that want color (the CLI)
334 /// read the fields via the getters instead.
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 write!(f, "{}: {}", self.severity.label(), self.body())
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn renders_full_error_compact() {
346 let diag = Diagnostic::error("invalid @pointerdown handler")
347 .component("mail-inbox-page")
348 .element("button")
349 .snippet(r#"@pointerdown="e.preventDefault()""#)
350 .help(r#"use @pointerdown="{handler()}" to pass the event"#);
351
352 let expected = "error: invalid @pointerdown handler\n \
353 in component <mail-inbox-page> · element <button>\n \
354 @pointerdown=\"e.preventDefault()\"\n \
355 help: use @pointerdown=\"{handler()}\" to pass the event";
356
357 assert_eq!(diag.to_string(), expected);
358 }
359
360 #[test]
361 fn omits_optional_sections_when_absent() {
362 let diag = Diagnostic::warning("something minor").component("my-card");
363 assert_eq!(
364 diag.to_string(),
365 "warning: something minor\n in component <my-card>"
366 );
367 }
368
369 #[test]
370 fn element_only_location() {
371 let diag = Diagnostic::error("x").element("button");
372 assert_eq!(diag.to_string(), "error: x\n in element <button>");
373 }
374
375 #[test]
376 fn getters_expose_structured_fields() {
377 let diag = Diagnostic::error("boom")
378 .component("c")
379 .element("e")
380 .snippet("snip")
381 .help("do this");
382 assert_eq!(diag.severity(), Severity::Error);
383 assert_eq!(diag.title(), "boom");
384 assert_eq!(diag.component_name(), Some("c"));
385 assert_eq!(diag.element_name(), Some("e"));
386 assert_eq!(diag.snippet_text(), Some("snip"));
387 assert_eq!(diag.help_text(), Some("do this"));
388 assert_eq!(
389 diag.location().as_deref(),
390 Some("in component <c> · element <e>")
391 );
392 }
393
394 #[test]
395 fn position_renders_rustc_style_location() {
396 let diag = Diagnostic::error("boom")
397 .component("index.html")
398 .element("for")
399 .position(3, 7);
400 assert_eq!(diag.position_line_column(), Some((3, 7)));
401 // A known position overrides the descriptive form with `--> file:l:c`.
402 assert_eq!(diag.location().as_deref(), Some("--> index.html:3:7"));
403 assert!(diag.to_string().contains("--> index.html:3:7"));
404 }
405
406 #[test]
407 fn bare_position_renders_without_component_or_element() {
408 let diag = Diagnostic::error("boom").position(5, 1);
409 assert_eq!(diag.location().as_deref(), Some("--> 5:1"));
410 }
411
412 #[test]
413 fn line_column_counts_lines_and_chars() {
414 let src = "<div>\n <for each=\"x\">\n</div>";
415 // Offset of the '<' in "<for" — line 2, column 3 (after two spaces).
416 let offset = src.find("<for").unwrap();
417 assert_eq!(line_column(src, offset), (2, 3));
418 // Start of file.
419 assert_eq!(line_column(src, 0), (1, 1));
420 }
421
422 #[test]
423 fn line_column_uses_char_columns_for_multibyte() {
424 // "📚x" — the 'x' is one char in, though the emoji is 4 bytes.
425 let src = "📚x";
426 let offset = src.find('x').unwrap();
427 assert_eq!(line_column(src, offset), (1, 2));
428 }
429}