From e5cf070d86536d2bb9fbba36a0316dac6d43cead Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:40:16 +0000 Subject: [PATCH 01/53] One grammar, shared by every tool The compiler, the formatter and the language server now read htmlang through one syntax tree, and the compiler checks the whole file rather than only the code that runs. For htmlang writers: - An attribute list belongs to the element name right before it; any other `[` is text, so `@text Use [ to open` is one line of text. A `>` chains only between element heads. - An indented line under a directive that takes no body (@page, @include, @data, @meta) is an error instead of a silent sibling. - Untaken @if/@else branches, uncalled function bodies and loops over empty lists are checked for unknown elements, functions and attributes. Functions may be used before their definition and from included files. - Every diagnostic has a stable code: `error[unknown-element]: line 3:` on the command line, a `code` (and `column`) field in --format json. - `htmlang fmt` prints the tree back: comments and blank lines are kept, unclosed lists and multi-line JSON are left as written. - The language server resolves relative @include and @data from the file's folder, keys its quick fixes on codes, and takes definitions, folding, semantic tokens, links, lenses, rename and hover from the tree (so CSS inside @style is no longer read as htmlang). How: htmlang-core gains syntax.rs (a lossless tree with spans for heads, chains, attributes, arguments, text segments and trivia), diagnostic.rs (Diagnostic with code, subject and suggestion) and a DIRECTIVES table in ast.rs with each directive's argument grammar and body kind. The evaluator walks the tree; the formatter, the LSP and tests/editor.rs (the VS Code grammar's word lists) all read the same table. Compiled output of every snapshot and example is unchanged. Left out: attribute values are still substituted from the raw token (the tree already exposes key and value), text children of a line are still flattened as siblings, and mixed tab/space indentation is not reported. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc --- CONTRIBUTING.md | 23 +- DESIGN.md | 45 +- README.md | 7 + crates/htmlang-core/src/ast.rs | 81 + crates/htmlang-core/src/diagnostic.rs | 179 ++ crates/htmlang-core/src/lib.rs | 3 +- crates/htmlang-core/src/parser.rs | 2319 ++++++++--------- crates/htmlang-core/src/syntax.rs | 1842 ++++++++++--- editors/vscode/README.md | 17 +- .../vscode/syntaxes/htmlang.tmLanguage.json | 9 +- src/bin/htmlang_lsp/analysis.rs | 1583 +++++------ src/bin/htmlang_lsp/completion.rs | 144 +- src/bin/htmlang_lsp/docs.rs | 23 +- src/bin/htmlang_lsp/hover.rs | 149 +- src/bin/htmlang_lsp/main.rs | 144 +- src/bin/htmlang_lsp/navigation.rs | 314 +-- src/bin/htmlang_lsp/state.rs | 29 +- src/bin/htmlang_lsp/tree.rs | 145 ++ src/fmt.rs | 609 ++--- src/lib.rs | 2 + src/main.rs | 29 +- tests/docs.rs | 18 +- tests/editor.rs | 61 + tests/fmt.rs | 55 + tests/regressions.rs | 160 ++ 25 files changed, 4713 insertions(+), 3277 deletions(-) create mode 100644 crates/htmlang-core/src/diagnostic.rs create mode 100644 src/bin/htmlang_lsp/tree.rs create mode 100644 tests/editor.rs create mode 100644 tests/fmt.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77c89ce..71115f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,14 +19,23 @@ Linux, macOS, and Windows. Your change should pass all three. ## Repository layout - `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here. - - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`. + - `syntax.rs` — the syntax tree: every line as written, with spans. The + compiler evaluates it, the formatter prints it back, and the language + server takes definitions, directives and verbatim bodies from it. + - `parser.rs` — evaluates the tree: definitions, data, loops and + conditions, and the checks over code that doesn't run. + - `diagnostic.rs` — `Diagnostic` and the stable diagnostic codes. + - `ast.rs` — the element kinds; every plain HTML element is one row in + `TAGS`, and every directive one row in `DIRECTIVES` (its argument, its + kind of body, and whether it takes the rest of the line). - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties, HTML attributes) and the state/media prefixes. - `expr.rs` — the expression language for conditions and computed values. - `std.hl` — the standard library, written in htmlang and loaded before every file. - `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground. -- `src/` — CLI, dev server and formatter. +- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax + tree back with its comments and blank lines). - `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its completions come from the compiler's tables; hover and completion text live in `docs.rs`. @@ -35,6 +44,8 @@ Linux, macOS, and Windows. Your change should pass all three. - `tests/regressions.rs` — one test per fixed bug. - `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`, and every page in `examples/`. +- `tests/fmt.rs` — formats every example and snapshot input: the result is + stable, compiles to the same HTML and keeps every comment. - `examples/` — complete pages: a landing page, a blog, a docs page, and a tour of the whole language (`demo.hl`). @@ -57,7 +68,13 @@ Linux, macOS, and Windows. Your change should pass all three. - Prefer enum-based ASTs and pattern matching over stringly-typed dispatch. - Keep `@` prefixes and bracket-attribute syntax consistent with existing - directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`. + directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the + formatter and the language server read that table, and `tests/editor.rs` + fails when the VS Code grammar's word lists drift from it. +- Every diagnostic has a code from `diagnostic::code`; add one there when no + existing code fits, and key tool behavior (quick fixes, tests) on the code + rather than on the message. Put the name the diagnostic is about in + `subject` and a replacement in `suggestion`. - Diagnostics should include `line` and, when practical, `column` and a `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`. - No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a diff --git a/DESIGN.md b/DESIGN.md index 621e134..f070852 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -71,6 +71,14 @@ An attribute can take one of three forms: A comma inside `(...)` or `"..."` doesn't split attributes, so a font stack is written `font-family "Inter, sans-serif"`. +The attribute list belongs to the element name right before it. Anywhere +else, `[` is an ordinary character, so a line of text can contain one: + +``` +@text Use [ to open a list +@paragraph Arrays look like [1, 2, 3]. +``` + ### Text A line that doesn't start with `@` is text. Text after an element's @@ -112,7 +120,8 @@ because `--` later in a line is ordinary text. ### Chains `>` puts elements that have one child each on one line. The last element in -the chain gets the indented children. +the chain gets the indented children. A `>` is a chain only between two +elements (`@name [attributes]`); in text it is just a character. ``` @el [padding 16, background blue, border-radius 8] > @link https://example.com @@ -134,6 +143,40 @@ htmlang, and a `--` line in them is not a comment. ``` +### Directives + +A directive is a built-in word that isn't an element. Each one takes a +fixed kind of argument and a fixed kind of body: + +| Directive | Argument | Body | +|---|---|---| +| `@page` | attributes and a title | none | +| `@let` | a name and a value, bundle or parameters | a function's body | +| `@include` | a file | none | +| `@data` | a variable and a source | none | +| `@meta` | a name and a value | none | +| `@if`, `@else`, `@each` | a condition or a loop | htmlang | +| `@style`, `@head` | none | verbatim | +| `@raw`, `@markdown` | the rest of the line (or a file, for `@markdown`) | verbatim, instead of the argument | + +An indented line under a directive that takes no body is an error, because +it would otherwise silently become a sibling. + +### Diagnostics + +The compiler checks the whole file, including code that doesn't run: the +branch of an `@if` that isn't taken, the body of a function that is never +called and the body of a loop over an empty list. Unknown elements, +functions and attributes are reported there too. A function may be called +before the line that defines it, and functions from included files count. +Only a name that depends on data, such as a variable a loop fills in, is +checked just where the code runs. + +Every diagnostic has a stable code, such as `unknown-element` or +`unused-variable`. The command line prints it as `error[unknown-element]`, +`--format json` has it in a `code` field, and the editor's quick fixes are +keyed on it. + ## Layout ### Rows and columns diff --git a/README.md b/README.md index 3e8a017..e11aa84 100644 --- a/README.md +++ b/README.md @@ -145,3 +145,10 @@ The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax highlighting, snippets, and the language server (`htmlang-lsp`). The server gives diagnostics, completions, hover documentation, go to definition, rename and formatting. + +The compiler, the formatter and the language server read a file with the +same parser, so the editor sees exactly what the compiler sees, relative +`@include` and `@data` paths included. Every diagnostic has a stable code +(`error[unknown-element]` on the command line, a `code` field in +`--format json`), and the whole file is checked, including branches and +functions that don't run. diff --git a/crates/htmlang-core/src/ast.rs b/crates/htmlang-core/src/ast.rs index 6ac58c0..80332ab 100644 --- a/crates/htmlang-core/src/ast.rs +++ b/crates/htmlang-core/src/ast.rs @@ -258,3 +258,84 @@ pub static TAGS: &[TagSpec] = &[ pub fn renders_argument_as_text(kind: &ElementKind) -> bool { *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text) } + +/// How a directive reads the rest of its line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArgGrammar { + /// Nothing may follow the name (`@style`). + None, + /// Free text, taken as written (`@include file.hl`, `@meta name value`). + Text, + /// An optional `[attributes]` list, then text (`@page [lang en] Title`). + AttrsText, + /// An expression (`@if $count > 2`). + Expression, + /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the + /// parameters of a function (`@let`). + Definition, + /// Nothing, or `if EXPR` (`@else`, `@else if`). + Else, + /// `$item in LIST` or `$item, $index in LIST` (`@each`). + Loop, + /// `$name SOURCE`; inline JSON may span several lines (`@data`). + Data, +} + +/// What the lines indented under a directive or element are. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyKind { + /// Nothing: indented lines are an error. + None, + /// htmlang: elements, text and directives. + Htmlang, + /// Foreign text (CSS, HTML, JavaScript, Markdown), kept exactly as + /// written: nothing in it is parsed, and `--` is not a comment. + Verbatim, +} + +/// A directive: a `@name` that isn't an element. The syntax tree +/// (`syntax.rs`), the evaluator, the formatter and the editor tools all +/// read the directives' shapes from this table. +#[derive(Debug, PartialEq)] +pub struct DirectiveSpec { + /// Name in htmlang source, without the `@`. + pub name: &'static str, + pub args: ArgGrammar, + pub body: BodyKind, + /// A verbatim directive may give its content as the rest of its line + /// instead of an indented body (`@raw
`, `@markdown file.md`). + pub one_line: bool, +} + +/// Every directive. +#[rustfmt::skip] +pub static DIRECTIVES: &[DirectiveSpec] = &[ + DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None, one_line: false }, + DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang, one_line: false }, + DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None, one_line: false }, + DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None, one_line: false }, + DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None, one_line: false }, + DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang, one_line: false }, + DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang, one_line: false }, + DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang, one_line: false }, + DirectiveSpec { name: "style", args: ArgGrammar::None, body: BodyKind::Verbatim, one_line: false }, + DirectiveSpec { name: "head", args: ArgGrammar::None, body: BodyKind::Verbatim, one_line: false }, + DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim, one_line: true }, + DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim, one_line: true }, +]; + +/// The directive named `name` (without the `@`). +pub fn directive(name: &str) -> Option<&'static DirectiveSpec> { + DIRECTIVES.iter().find(|d| d.name == name) +} + +/// What the lines indented under `@name` are: the directive's body kind, +/// or an element's (`@script` holds JavaScript, every other element +/// htmlang). +pub fn body_kind(name: &str) -> BodyKind { + match directive(name) { + Some(spec) => spec.body, + None if name == "script" => BodyKind::Verbatim, + None => BodyKind::Htmlang, + } +} diff --git a/crates/htmlang-core/src/diagnostic.rs b/crates/htmlang-core/src/diagnostic.rs new file mode 100644 index 0000000..f64bb6d --- /dev/null +++ b/crates/htmlang-core/src/diagnostic.rs @@ -0,0 +1,179 @@ +//! Diagnostics: what the compiler reports about a source file. +//! +//! Every diagnostic has a stable `code` (see [`code`]), so tools such as the +//! language server key their quick fixes on the code rather than on the +//! wording of the message. `subject` and `suggestion` carry the name the +//! diagnostic is about and the name to use instead, when there is one. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Error, + Warning, + Info, + Help, +} + +#[derive(Debug, Clone)] +pub struct Diagnostic { + /// Stable identifier of the kind of problem, e.g. `unknown-element`. + pub code: &'static str, + pub line: usize, + pub column: Option, + pub message: String, + pub severity: Severity, + pub source_line: Option>, + /// The name the diagnostic is about, as written (`@nosuch` without the + /// `@`, an attribute key, a definition's name). + pub subject: Option>, + /// The name to write instead of `subject` ("did you mean ...?"). + pub suggestion: Option>, +} + +impl Diagnostic { + pub fn new(code: &'static str, severity: Severity, line: usize, message: String) -> Self { + Diagnostic { + code, + line, + column: None, + message, + severity, + source_line: None, + subject: None, + suggestion: None, + } + } + + pub fn error(code: &'static str, line: usize, message: String) -> Self { + Self::new(code, Severity::Error, line, message) + } + + pub fn warning(code: &'static str, line: usize, message: String) -> Self { + Self::new(code, Severity::Warning, line, message) + } + + pub fn column(mut self, column: usize) -> Self { + self.column = Some(column); + self + } + + pub fn source(mut self, source: impl Into) -> Self { + self.source_line = Some(source.into().into_boxed_str()); + self + } + + pub fn subject(mut self, subject: impl Into) -> Self { + self.subject = Some(subject.into().into_boxed_str()); + self + } + + pub fn suggest(mut self, suggestion: Option>) -> Self { + self.suggestion = suggestion.map(|s| s.into().into_boxed_str()); + self + } +} + +/// The diagnostic codes. A code names a kind of problem and never changes, +/// even when a message is reworded. +pub mod code { + // Syntax: found while reading the file, before anything runs + pub const UNCLOSED_BRACKET: &str = "unclosed-bracket"; + pub const UNEXPECTED_BODY: &str = "unexpected-body"; + pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument"; + pub const MISSING_ARGUMENT: &str = "missing-argument"; + pub const INVALID_LOOP: &str = "invalid-loop"; + pub const STRAY_ELSE: &str = "stray-else"; + + // Names + pub const UNKNOWN_ELEMENT: &str = "unknown-element"; + pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute"; + pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form"; + pub const UNKNOWN_PAGE_ATTRIBUTE: &str = "unknown-page-attribute"; + pub const UNDEFINED_VARIABLE: &str = "undefined-variable"; + pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute"; + + // Values + pub const INVALID_VALUE: &str = "invalid-value"; + pub const UNKNOWN_COLOR: &str = "unknown-color"; + pub const INVALID_COLOR: &str = "invalid-color"; + pub const INVALID_EXPRESSION: &str = "invalid-expression"; + pub const INVALID_JSON: &str = "invalid-json"; + + // Definitions and functions + pub const UNUSED_VARIABLE: &str = "unused-variable"; + pub const UNUSED_BUNDLE: &str = "unused-bundle"; + pub const UNUSED_FUNCTION: &str = "unused-function"; + pub const RECURSIVE_CALL: &str = "recursive-call"; + pub const NO_SINGLE_ROOT: &str = "no-single-root"; + + // Files + pub const UNREADABLE_FILE: &str = "unreadable-file"; + pub const CIRCULAR_INCLUDE: &str = "circular-include"; + pub const UNSET_ENVIRONMENT: &str = "unset-environment"; + + // Context: an attribute or element where it has no effect + pub const NO_EFFECT: &str = "no-effect"; + pub const FILL_FALLBACK: &str = "fill-fallback"; + + // Accessibility + pub const MISSING_ALT: &str = "missing-alt"; + pub const MISSING_INPUT_TYPE: &str = "missing-input-type"; + pub const MISSING_LINK_TEXT: &str = "missing-link-text"; + pub const MISSING_LABEL: &str = "missing-label"; + pub const MISSING_TITLE: &str = "missing-title"; + pub const MISSING_BUTTON_TEXT: &str = "missing-button-text"; + pub const MISSING_CAPTIONS: &str = "missing-captions"; + pub const LOW_CONTRAST: &str = "low-contrast"; + pub const POSITIVE_TABINDEX: &str = "positive-tabindex"; + + // `htmlang lint` + pub const DEEP_NESTING: &str = "deep-nesting"; + pub const EMPTY_CONTAINER: &str = "empty-container"; + pub const MISSING_BUTTON_TYPE: &str = "missing-button-type"; + + /// A bug in the compiler itself. + pub const INTERNAL: &str = "internal"; + + /// Every code, for tools and tests. + pub const ALL: &[&str] = &[ + UNCLOSED_BRACKET, + UNEXPECTED_BODY, + UNEXPECTED_ARGUMENT, + MISSING_ARGUMENT, + INVALID_LOOP, + STRAY_ELSE, + UNKNOWN_ELEMENT, + UNKNOWN_ATTRIBUTE, + HTML_ATTRIBUTE_FORM, + UNKNOWN_PAGE_ATTRIBUTE, + UNDEFINED_VARIABLE, + DUPLICATE_ATTRIBUTE, + INVALID_VALUE, + UNKNOWN_COLOR, + INVALID_COLOR, + INVALID_EXPRESSION, + INVALID_JSON, + UNUSED_VARIABLE, + UNUSED_BUNDLE, + UNUSED_FUNCTION, + RECURSIVE_CALL, + NO_SINGLE_ROOT, + UNREADABLE_FILE, + CIRCULAR_INCLUDE, + UNSET_ENVIRONMENT, + NO_EFFECT, + FILL_FALLBACK, + MISSING_ALT, + MISSING_INPUT_TYPE, + MISSING_LINK_TEXT, + MISSING_LABEL, + MISSING_TITLE, + MISSING_BUTTON_TEXT, + MISSING_CAPTIONS, + LOW_CONTRAST, + POSITIVE_TABINDEX, + DEEP_NESTING, + EMPTY_CONTAINER, + MISSING_BUTTON_TYPE, + INTERNAL, + ]; +} diff --git a/crates/htmlang-core/src/lib.rs b/crates/htmlang-core/src/lib.rs index f3e85c8..249b3ee 100644 --- a/crates/htmlang-core/src/lib.rs +++ b/crates/htmlang-core/src/lib.rs @@ -1,8 +1,9 @@ pub mod ast; pub mod codegen; +pub mod diagnostic; pub mod expr; pub mod parser; -mod syntax; +pub mod syntax; pub mod vocab; #[cfg(test)] diff --git a/crates/htmlang-core/src/parser.rs b/crates/htmlang-core/src/parser.rs index 06f1ab4..c0a196b 100644 --- a/crates/htmlang-core/src/parser.rs +++ b/crates/htmlang-core/src/parser.rs @@ -1,48 +1,24 @@ use std::collections::{HashMap, HashSet}; -use std::fmt; use std::path::{Path, PathBuf}; +use std::rc::Rc; use crate::ast::*; -use crate::syntax::{self, Syntax}; +use crate::diagnostic::code; +pub use crate::diagnostic::{Diagnostic, Severity}; +use crate::syntax::{self, DirectiveArgs, LetForm, NodeKind, Segment, Tree}; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Severity { - Error, - Warning, - Info, - Help, -} - -#[derive(Debug, Clone)] -pub struct Diagnostic { - pub line: usize, - pub column: Option, - pub message: String, - pub severity: Severity, - pub source_line: Option, -} - pub struct ParseResult { pub document: Document, pub diagnostics: Vec, pub included_files: Vec, } -#[derive(Debug)] -struct ParseError { - line: usize, - message: String, -} - -impl fmt::Display for ParseError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "line {}: {}", self.line, self.message) - } -} +/// An error that stops one line from being evaluated. +type ParseError = Diagnostic; // --------------------------------------------------------------------------- // Internal types @@ -52,7 +28,7 @@ impl fmt::Display for ParseError { struct FnDef { params: Vec, defaults: HashMap, - body: Vec, + body: Vec, } struct ParseContext { @@ -90,6 +66,18 @@ struct ParseContext { fn_lines: HashMap, /// Line numbers of attribute bundle definitions (name -> line) define_lines: HashMap, + /// Syntax nodes evaluated at least once, by id. + visited: HashSet, + /// The id the next parsed tree starts at, so ids are unique across + /// the file, its includes and the standard library. + next_id: usize, + /// The file's tree and those of the files it includes, with the + /// include chain that leads to each, for the checks of code that never + /// runs. + trees: Vec<(Rc, Option)>, + /// Every function defined anywhere in the file or the files it + /// includes, whether or not its definition runs. + namespace: HashSet, } /// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes. @@ -104,13 +92,14 @@ impl ParseContext { match result { Ok(value) => Some(value), Err(message) => { - self.diagnostics.push(Diagnostic { - line, - column: None, - message: format!("invalid expression: {}", message), - severity: Severity::Error, - source_line: Some(src.to_string()), - }); + self.diagnostics.push( + Diagnostic::error( + code::INVALID_EXPRESSION, + line, + format!("invalid expression: {}", message), + ) + .source(src), + ); None } } @@ -120,6 +109,31 @@ impl ParseContext { fn condition(&mut self, src: &str, line: usize) -> bool { self.eval(src, line).is_some_and(|value| value.truthy()) } + + /// Parse a file's text into a tree whose ids don't clash with the + /// trees parsed before it. + fn parse_tree(&mut self, text: &str) -> Rc { + let tree = syntax::parse_from(text, self.next_id); + self.next_id = tree.end_id; + Rc::new(tree) + } + + /// Read a file (once), relative to the current file. + fn read_file(&mut self, resolved: &Path) -> std::io::Result { + if let Some(cached) = self.file_cache.get(resolved) { + return Ok(cached.clone()); + } + let text = std::fs::read_to_string(resolved)?; + self.file_cache.insert(resolved.to_path_buf(), text.clone()); + Ok(text) + } + + fn resolve(&self, filename: &str) -> PathBuf { + match &self.base_path { + Some(base) => base.join(filename), + None => PathBuf::from(filename), + } + } } /// The standard library (`std.hl`): components and bundles defined in @@ -127,7 +141,9 @@ impl ParseContext { const PRELUDE: &str = include_str!("std.hl"); fn load_prelude(ctx: &mut ParseContext) { - let _ = Evaluator.eval_block(&syntax::parse(PRELUDE), ctx); + let tree = ctx.parse_tree(PRELUDE); + collect_namespace(&tree.nodes, None, &mut HashSet::new(), ctx); + let _ = Evaluator.eval_block(&tree.nodes, ctx); // Library definitions aren't the file's own: never report them unused. ctx.fn_lines.clear(); ctx.define_lines.clear(); @@ -139,7 +155,6 @@ pub fn parse(input: &str) -> ParseResult { } pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult { - let tree = syntax::parse(input); let mut ctx = ParseContext { current_line: 0, scoped_functions: std::collections::HashSet::new(), @@ -166,9 +181,23 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult { let_lines: HashMap::new(), fn_lines: HashMap::new(), define_lines: HashMap::new(), + visited: HashSet::new(), + next_id: 0, + trees: Vec::new(), + namespace: HashSet::new(), }; load_prelude(&mut ctx); - let nodes = Evaluator.eval_block(&tree, &mut ctx); + let tree = ctx.parse_tree(input); + ctx.diagnostics.extend(tree.diagnostics.iter().cloned()); + ctx.trees.push((tree.clone(), None)); + collect_namespace( + &tree.nodes, + base_path.map(Path::to_path_buf), + &mut HashSet::new(), + &mut ctx, + ); + let nodes = Evaluator.eval_block(&tree.nodes, &mut ctx); + check_unevaluated(&mut ctx); validate_tree(&nodes, None, &mut ctx.diagnostics); check_unused(&mut ctx); ParseResult { @@ -191,458 +220,589 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult { } // --------------------------------------------------------------------------- -// Preprocessing: strip comments/blanks, collapse @raw blocks +// Code that never runs: an untaken branch, an uncalled function, a loop over +// an empty list // --------------------------------------------------------------------------- +/// Collect the functions defined anywhere in `nodes` and in the files they +/// include (by a literal path), into `ctx.namespace`. +fn collect_namespace( + nodes: &[syntax::Node], + base: Option, + seen: &mut HashSet, + ctx: &mut ParseContext, +) { + let mut includes = Vec::new(); + for node in nodes { + node.walk(&mut |node| match node.directive().map(|d| &d.args) { + Some(DirectiveArgs::Let(def)) if matches!(def.form, LetForm::Function(_)) => { + ctx.namespace.insert(def.name.clone()); + } + Some(DirectiveArgs::Text(Some(path))) if node.is_directive("include") => { + includes.push(path.raw.clone()); + } + _ => {} + }); + } + for path in includes { + let path = path.trim_matches('"'); + if path.contains('$') { + continue; + } + let resolved = match &base { + Some(base) => base.join(path), + None => PathBuf::from(path), + }; + if !seen.insert(resolved.clone()) { + continue; + } + let Ok(text) = ctx.read_file(&resolved) else { + continue; + }; + let tree = syntax::parse(&text); + let parent = resolved.parent().map(Path::to_path_buf); + collect_namespace(&tree.nodes, parent, seen, ctx); + } +} + +/// Check the lines that were never evaluated: element and function names, +/// and the attributes of built-in elements (those that don't depend on a +/// variable). Names that come from data are only checked when evaluated. +fn check_unevaluated(ctx: &mut ParseContext) { + let trees = std::mem::take(&mut ctx.trees); + let mut seen = HashSet::new(); + for (tree, chain) in &trees { + let before = ctx.diagnostics.len(); + for node in &tree.nodes { + check_unevaluated_node(node, ctx); + } + let mut found = ctx.diagnostics.split_off(before); + if let Some(chain) = chain { + for d in &mut found { + d.message = format!("{}\n in {}", d.message, chain); + } + } + // A file included twice is checked twice: report each problem once + found.retain(|d| seen.insert((d.line, d.message.clone()))); + ctx.diagnostics.extend(found); + } + ctx.trees = trees; +} + +fn check_unevaluated_node(node: &syntax::Node, ctx: &mut ParseContext) { + if !ctx.visited.contains(&node.id) { + let line = node.span.line; + // A reference from code that doesn't run still counts as a use + track_var_refs(&node.source, &mut ctx.used_variables); + let before = ctx.diagnostics.len(); + match &node.kind { + NodeKind::Element(element) => { + for head in &element.chain { + check_head(head, line, false, ctx); + } + if let Some(text) = &element.text { + check_inline_heads(text, line, ctx); + } + } + NodeKind::Text(text) => check_inline_heads(text, line, ctx), + _ => {} + } + for d in &mut ctx.diagnostics[before..] { + d.source_line + .get_or_insert_with(|| node.source.as_str().into()); + } + } + for child in &node.children { + check_unevaluated_node(child, ctx); + } +} + +/// Check a head that was never evaluated: its name, and for a built-in +/// element, its attributes. +fn check_head(head: &syntax::Head, line: usize, inline: bool, ctx: &mut ParseContext) { + let is_function = ctx.namespace.contains(&head.name) || ctx.functions.contains_key(&head.name); + if is_function && !inline { + ctx.used_functions.insert(head.name.clone()); + return; + } + if let Err(mut e) = parse_element_kind(&head.name, line, ctx) { + if inline { + e.severity = Severity::Warning; + } + ctx.diagnostics.push(e); + return; + } + if let Some(list) = &head.attrs { + let literal: Vec = list + .attrs + .iter() + .filter(|a| !a.raw.contains('$') && !a.raw.contains("if(")) + .cloned() + .collect(); + parse_attr_list(&literal, line, ctx, true); + } +} + +fn check_inline_heads(text: &syntax::Text, line: usize, ctx: &mut ParseContext) { + for segment in &text.segments { + if let Segment::Inline(inline) = segment { + check_head(&inline.head, line, true, ctx); + if let Some(text) = &inline.text { + check_inline_heads(text, line, ctx); + } + } + } +} + // --------------------------------------------------------------------------- // Evaluation // --------------------------------------------------------------------------- +/// The `@else` lines that continue the `@if` or `@each` before `from`, and +/// the position after them. +fn else_branches(block: &[syntax::Node], from: usize, is_if: bool) -> (Vec<&syntax::Node>, usize) { + let mut branches = Vec::new(); + let mut next = from; + let mut i = from; + while i < block.len() { + let node = &block[i]; + i += 1; + if node.is_trivia() { + continue; + } + let condition = match node.directive().map(|d| &d.args) { + Some(DirectiveArgs::Else { condition }) => condition.is_some(), + _ => break, + }; + if condition && !is_if { + break; + } + branches.push(node); + next = i; + if !condition { + break; + } + } + (branches, next) +} + impl Evaluator { - fn eval_block(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec { + fn eval_block(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec { let mut nodes = Vec::new(); - for node in block { - match self.eval(node, ctx) { + let mut i = 0; + while i < block.len() { + let node = &block[i]; + i += 1; + if node.is_trivia() { + continue; + } + let result = match node.directive().map(|d| d.name()) { + Some(name @ ("if" | "each")) => { + let (branches, next) = else_branches(block, i, name == "if"); + i = next; + if name == "if" { + Ok(Some(self.eval_if(node, &branches, ctx))) + } else { + self.eval_each(node, branches.first().copied(), ctx) + } + } + // Reported as a syntax error + Some("else") => { + ctx.visited.insert(node.id); + Ok(None) + } + _ => self.eval(node, ctx), + }; + match result { Ok(Some(new_nodes)) => nodes.extend(new_nodes), Ok(None) => {} // Record the error and go on, to report several in one pass - Err(e) => ctx.diagnostics.push(Diagnostic { - line: e.line, - column: None, - message: e.message, - severity: Severity::Error, - source_line: Some(node.source()), - }), + Err(mut e) => { + e.source_line + .get_or_insert_with(|| node.source.as_str().into()); + ctx.diagnostics.push(e); + } } } nodes } /// Evaluate a block in its own scope: `@let` inside doesn't leak out. - fn eval_scoped(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec { + fn eval_scoped(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec { let saved_vars = ctx.variables.clone(); let nodes = self.eval_block(block, ctx); ctx.variables = saved_vars; nodes } + /// `@if` with its `@else if` / `@else` branches. Every condition is + /// checked (and its errors reported); the first that holds picks the + /// branch. + fn eval_if( + &mut self, + node: &syntax::Node, + branches: &[&syntax::Node], + ctx: &mut ParseContext, + ) -> Vec { + let mut chosen = None; + for branch in std::iter::once(node).chain(branches.iter().copied()) { + ctx.visited.insert(branch.id); + ctx.current_line = branch.span.line; + let holds = match branch.directive().map(|d| &d.args) { + Some(DirectiveArgs::Condition(condition)) + | Some(DirectiveArgs::Else { + condition: Some(condition), + }) => !condition.raw.is_empty() && ctx.condition(&condition.raw, branch.span.line), + Some(DirectiveArgs::Else { condition: None }) => true, + // A syntax error, already reported + _ => false, + }; + if holds && chosen.is_none() { + chosen = Some(&branch.children); + } + } + chosen.map_or_else(Vec::new, |body| self.eval_scoped(body, ctx)) + } + fn eval( &mut self, - node: &Syntax, + node: &syntax::Node, ctx: &mut ParseContext, ) -> Result>, ParseError> { - let line_num = node.line(); + ctx.visited.insert(node.id); + let line_num = node.span.line; ctx.current_line = line_num; - let (content, current_indent, children) = match node { - Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])), - Syntax::Function { - name, - params, - defaults, - body, - .. - } => { - self.define_function(name, params, defaults, body, line_num, ctx); - return Ok(None); - } - Syntax::If { branches } => { - // Every condition is checked (and its errors reported); - // the first that holds picks the branch. - let mut chosen = None; - for branch in branches { - let holds = match &branch.condition { - Some(condition) => ctx.condition(condition, branch.line), - None => true, - }; - if holds && chosen.is_none() { - chosen = Some(&branch.body); - } + match &node.kind { + NodeKind::Blank | NodeKind::Comment => Ok(None), + NodeKind::Verbatim(body) => Ok(Some(vec![Node::Raw(body.text.clone())])), + NodeKind::Directive(directive) => { + let mut nodes = self.eval_directive(node, directive, ctx)?; + // A directive that takes no body: the lines indented under + // it (a syntax error) are evaluated as its siblings + let takes_body = match &directive.args { + DirectiveArgs::Let(def) => matches!(def.form, LetForm::Function(_)), + _ => directive.spec.body != BodyKind::None, + }; + if !takes_body { + let siblings = self.eval_block(&node.children, ctx); + nodes.get_or_insert_with(Vec::new).extend(siblings); } - return Ok(chosen.map(|body| self.eval_scoped(body, ctx))); - } - Syntax::Each { - header, - body, - empty, - .. - } => { - return self.eval_each(header, body, empty, line_num, ctx).map(Some); - } - Syntax::Line { - text, - indent, - children, - .. - } => (text.clone(), *indent, children), - }; + Ok(nodes) + } + NodeKind::Element(element) => self.eval_element(node, element, ctx).map(Some), + NodeKind::Text(text) => { + let var_warnings = check_undefined_vars( + &protect_escapes(&text.raw), + &ctx.variables, + line_num, + node.indent, + ); + ctx.diagnostics.extend(var_warnings); + track_var_refs(&text.raw, &mut ctx.used_variables); + let mut nodes = vec![Node::Text(text_segments(text, ctx))]; + // Text takes no body: lines indented under it are its + // siblings + nodes.extend(self.eval_block(&node.children, ctx)); + Ok(Some(nodes)) + } + } + } - // --- Directives --- - - // @page [lang en, favicon /f.png] Title - if let Some(rest) = content.strip_prefix("@page ") { - let rest = rest.trim_start(); - let title = if rest.starts_with('[') { - let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?; - for attr in attrs { - let value = attr.value.unwrap_or_default(); - match attr.key.as_str() { - "lang" => ctx.lang = Some(value), - "favicon" => ctx.favicon = Some(value), - other => ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( - "unknown @page attribute '{}' (expected lang or favicon)", - other + fn eval_directive( + &mut self, + node: &syntax::Node, + directive: &syntax::Directive, + ctx: &mut ParseContext, + ) -> Result>, ParseError> { + let line_num = node.span.line; + let content = &node.source; + match (directive.name(), &directive.args) { + // @page [lang en, favicon /f.png] Title + ("page", DirectiveArgs::Page { attrs, title }) => { + if let Some(list) = attrs { + for attr in parse_attr_list(&list.attrs, line_num, ctx, false) { + let value = attr.value.unwrap_or_default(); + match attr.key.as_str() { + "lang" => ctx.lang = Some(value), + "favicon" => ctx.favicon = Some(value), + other => ctx.diagnostics.push( + Diagnostic::warning( + code::UNKNOWN_PAGE_ATTRIBUTE, + line_num, + format!( + "unknown @page attribute '{}' (expected lang or favicon)", + other + ), + ) + .source(content.clone()) + .subject(other), ), - severity: Severity::Warning, - source_line: Some(content.clone()), - }), + } } } - title - } else { - rest.to_string() - }; - ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables)); - return Ok(None); - } - - if let Some(rest) = content.strip_prefix("@let ") { - let rest = rest.trim(); - - if !children.is_empty() { - return Err(ParseError { - line: line_num, - message: "@let with body requires a name".to_string(), - }); + let title = title.as_ref().map_or("", |t| t.raw.as_str()); + ctx.page_title = Some(substitute_vars(title, &ctx.variables)); + Ok(None) } - if let Some((name, value)) = rest.split_once(' ') { - let value = value.trim(); - // `@let name = EXPR` computes its value (see expr.rs); any - // other value is literal text with `$var` interpolation. - if let Some(expression) = value.strip_prefix('=') { - let value = ctx - .eval(expression.trim(), line_num) - .map(|v| v.to_string()) - .unwrap_or_default(); - if name.starts_with("--") { - ctx.css_vars.push((name.to_string(), value.clone())); + ("let", DirectiveArgs::Let(def)) => { + let name = def.name.as_str(); + match &def.form { + LetForm::Function(params) => { + self.define_function(name, params, &node.children, line_num, ctx); } - ctx.variables.insert(name.to_string(), value); - ctx.let_lines.entry(name.to_string()).or_insert(line_num); - return Ok(None); - } - - // Attribute bundle: @let name [attr1, attr2, ...] - if value.starts_with('[') { - let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?; - ctx.defines.insert(name.to_string(), attrs); - ctx.define_lines.entry(name.to_string()).or_insert(line_num); - return Ok(None); - } - - let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 { - // Support quoted string interpolation: @let greeting "Hello $name" - &value[1..value.len() - 1] - } else { - value - }; - track_var_refs(value, &mut ctx.used_variables); - let value = substitute_vars(value, &ctx.variables); - if name.starts_with("--") { - // CSS custom property - ctx.css_vars.push((name.to_string(), value.clone())); + // `@let name = EXPR` computes its value (see expr.rs) + LetForm::Computed(expression) => { + let value = ctx + .eval(&expression.raw, line_num) + .map(|v| v.to_string()) + .unwrap_or_default(); + set_variable(name, value, line_num, ctx); + } + // Attribute bundle: @let name [attr1, attr2, ...] + LetForm::Bundle(list) => { + let attrs = parse_attr_list(&list.attrs, line_num, ctx, true); + ctx.defines.insert(name.to_string(), attrs); + ctx.define_lines.entry(name.to_string()).or_insert(line_num); + } + // Literal text with `$var` interpolation, quoted or not: + // @let greeting "Hello $name" + LetForm::Value(Some(value)) | LetForm::Quoted(value) => { + track_var_refs(&value.raw, &mut ctx.used_variables); + let value = substitute_vars(&value.raw, &ctx.variables); + set_variable(name, value, line_num, ctx); + } + LetForm::Value(None) => {} } - ctx.variables.insert(name.to_string(), value); - ctx.let_lines.entry(name.to_string()).or_insert(line_num); + Ok(None) } - return Ok(None); - } - // @meta NAME VALUE; `og:` names become Open Graph property tags - if let Some(rest) = content.strip_prefix("@meta ") { - let rest = rest.trim(); - if let Some((name, value)) = rest.split_once(' ') { - let value = substitute_vars(value.trim(), &ctx.variables); - match name.trim().strip_prefix("og:") { - Some(property) => ctx.og_tags.push((property.to_string(), value)), - None => ctx.meta_tags.push((name.trim().to_string(), value)), + // @meta NAME VALUE; `og:` names become Open Graph property tags + ("meta", DirectiveArgs::Text(Some(arg))) => { + if let Some((name, value)) = arg.raw.split_once(' ') { + let value = substitute_vars(value.trim(), &ctx.variables); + match name.trim().strip_prefix("og:") { + Some(property) => ctx.og_tags.push((property.to_string(), value)), + None => ctx.meta_tags.push((name.trim().to_string(), value)), + } } + Ok(None) } - return Ok(None); - } - if content == "@head" || content.starts_with("@head ") { - let trimmed = block_text(children).trim().to_string(); - if !trimmed.is_empty() { - ctx.head_blocks.push(trimmed); + ("head", _) => { + let text = verbatim_text(&node.children); + if !text.trim().is_empty() { + ctx.head_blocks.push(text.trim().to_string()); + } + Ok(None) } - return Ok(None); - } - // --- @style block (raw CSS) --- - if content.trim() == "@style" { - let trimmed = block_text(children).trim().to_string(); - if !trimmed.is_empty() { - ctx.custom_css.push(trimmed); + // @style: raw CSS + ("style", _) => { + let text = verbatim_text(&node.children); + if !text.trim().is_empty() { + ctx.custom_css.push(text.trim().to_string()); + } + Ok(None) } - return Ok(None); - } - // --- @markdown block or file (convert markdown to HTML) --- - if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") { - let arg = content.trim().strip_prefix("@markdown").unwrap().trim(); - if arg.is_empty() { - // Inline markdown block: indented children - let md_lines: Vec = - block_text(children).lines().map(String::from).collect(); - let html = markdown_to_html(&md_lines); - return Ok(Some(vec![Node::Raw(html)])); - } else { - // External markdown file: @markdown file.md - let filename = substitute_vars(arg, &ctx.variables); - let resolved = match &ctx.base_path { - Some(base) => base.join(&filename), - None => PathBuf::from(&filename), + // @markdown: an indented block, or a file + ("markdown", DirectiveArgs::Text(file)) => { + let Some(file) = file else { + let md_lines: Vec = verbatim_text(&node.children) + .lines() + .map(String::from) + .collect(); + return Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))])); }; - let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) { - cached.clone() - } else { - match std::fs::read_to_string(&resolved) { - Ok(text) => { - ctx.file_cache.insert(resolved.clone(), text.clone()); - text - } - Err(e) => { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("cannot read markdown '{}': {}", filename, e), - severity: Severity::Error, - source_line: Some(content.clone()), - }); - return Ok(None); - } + let filename = substitute_vars(&file.raw, &ctx.variables); + let resolved = ctx.resolve(&filename); + let md_text = match ctx.read_file(&resolved) { + Ok(text) => text, + Err(e) => { + ctx.diagnostics.push( + Diagnostic::error( + code::UNREADABLE_FILE, + line_num, + format!("cannot read markdown '{}': {}", filename, e), + ) + .source(content.clone()), + ); + return Ok(None); } }; ctx.included_files.push(resolved); let md_lines: Vec = md_text.lines().map(|l| l.to_string()).collect(); - let html = markdown_to_html(&md_lines); - return Ok(Some(vec![Node::Raw(html)])); + Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))])) } - } - if let Some(rest) = content.strip_prefix("@include ") { - let rest = rest.trim(); - let filename = rest - .strip_prefix('"') - .and_then(|f| f.strip_suffix('"')) - .unwrap_or(rest); - let filename = substitute_vars(filename, &ctx.variables); + ("include", DirectiveArgs::Text(Some(path))) => { + self.eval_include(&path.raw, line_num, content, ctx) + } - let resolved = match &ctx.base_path { - Some(base) => base.join(&filename), - None => PathBuf::from(&filename), - }; + ("data", DirectiveArgs::Data { name, source, .. }) => { + self.eval_data(name, &source.raw, line_num, content, ctx); + Ok(None) + } + + // @raw: the rest of its line, or its indented body, verbatim + ("raw", DirectiveArgs::Text(Some(text))) => Ok(Some(vec![Node::Raw(text.raw.clone())])), + ("raw", _) => { + let text = verbatim_text(&node.children); + let text = text.trim_end_matches('\n'); + Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())])) + } + + // A header with a syntax error (reported), or a directive + // without its argument + _ => Ok(None), + } + } - if ctx.include_stack.contains(&resolved) { - let cycle_chain = format_include_chain(&ctx.include_stack); - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( + fn eval_include( + &mut self, + path: &str, + line_num: usize, + content: &str, + ctx: &mut ParseContext, + ) -> Result>, ParseError> { + let filename = path + .strip_prefix('"') + .and_then(|f| f.strip_suffix('"')) + .unwrap_or(path); + let filename = substitute_vars(filename, &ctx.variables); + let resolved = ctx.resolve(&filename); + + if ctx.include_stack.contains(&resolved) { + let cycle_chain = format_include_chain(&ctx.include_stack); + ctx.diagnostics.push( + Diagnostic::error( + code::CIRCULAR_INCLUDE, + line_num, + format!( "circular include '{}' (cycle: {} → {})", filename, cycle_chain, filename ), - severity: Severity::Error, - source_line: Some(content.clone()), - }); + ) + .source(content), + ); + return Ok(None); + } + + let imported_text = match ctx.read_file(&resolved) { + Ok(text) => text, + Err(e) => { + ctx.diagnostics.push( + Diagnostic::error( + code::UNREADABLE_FILE, + line_num, + format!("cannot include '{}': {}", filename, e), + ) + .source(content), + ); return Ok(None); } + }; - let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) { - cached.clone() - } else { - match std::fs::read_to_string(&resolved) { - Ok(text) => { - ctx.file_cache.insert(resolved.clone(), text.clone()); - text - } - Err(e) => { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("cannot include '{}': {}", filename, e), - severity: Severity::Error, - source_line: Some(content.clone()), - }); - return Ok(None); - } - } - }; - - ctx.included_files.push(resolved.clone()); - ctx.include_stack.push(resolved.clone()); - let saved_base = ctx.base_path.clone(); - ctx.base_path = resolved.parent().map(|p| p.to_path_buf()); - - let diag_count_before = ctx.diagnostics.len(); - - let included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx); + ctx.included_files.push(resolved.clone()); + ctx.include_stack.push(resolved.clone()); + let saved_base = ctx.base_path.clone(); + ctx.base_path = resolved.parent().map(|p| p.to_path_buf()); - // Annotate new diagnostics with import chain - let import_chain = format_include_chain(&ctx.include_stack); - for d in &mut ctx.diagnostics[diag_count_before..] { - d.message = format!("{}\n in {}", d.message, import_chain); - } + let diag_count_before = ctx.diagnostics.len(); + let import_chain = format_include_chain(&ctx.include_stack); + let tree = ctx.parse_tree(&imported_text); + ctx.diagnostics.extend(tree.diagnostics.iter().cloned()); + ctx.trees.push((tree.clone(), Some(import_chain.clone()))); + let included_nodes = self.eval_block(&tree.nodes, ctx); - ctx.base_path = saved_base; - ctx.include_stack.pop(); - return Ok(Some(included_nodes)); + // Annotate new diagnostics with import chain + for d in &mut ctx.diagnostics[diag_count_before..] { + d.message = format!("{}\n in {}", d.message, import_chain); } - // --- @data (load JSON file into variables) --- + ctx.base_path = saved_base; + ctx.include_stack.pop(); + Ok(Some(included_nodes)) + } - // @data $name file.json values as $name.key - // @data $name dir/*.json a list of the files' records - // @data $name [...] / {...} inline JSON - // @data $name env:NAME [DEFAULT] an environment variable - if let Some(rest) = content.strip_prefix("@data ") { - let rest = rest.trim(); - let Some((prefix, filename)) = rest - .strip_prefix('$') - .and_then(|named| named.split_once(char::is_whitespace)) - .map(|(name, source)| (name.to_string(), source.trim().to_string())) - else { - return Err(ParseError { - line: line_num, - message: format!( - "@data needs a name: write `@data $name {}` and use `$name.key`", - rest.trim_start_matches('$') - ), - }); + /// @data $name file.json values as $name.key + /// @data $name dir/*.json a list of the files' records + /// @data $name [...] / {...} inline JSON + /// @data $name env:NAME [DEFAULT] an environment variable + fn eval_data( + &mut self, + prefix: &str, + filename: &str, + line_num: usize, + content: &str, + ctx: &mut ParseContext, + ) { + let prefix = prefix.to_string(); + if let Some(env) = filename.strip_prefix("env:") { + let (var, default) = match env.split_once(char::is_whitespace) { + Some((var, default)) => (var, Some(default.trim())), + None => (env, None), }; - - if let Some(env) = filename.strip_prefix("env:") { - let (var, default) = match env.split_once(char::is_whitespace) { - Some((var, default)) => (var, Some(default.trim())), - None => (env, None), - }; - let value = std::env::var(var) - .ok() - .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables))); - if value.is_none() { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( + let value = std::env::var(var) + .ok() + .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables))); + if value.is_none() { + ctx.diagnostics.push( + Diagnostic::warning( + code::UNSET_ENVIRONMENT, + line_num, + format!( "environment variable '{}' is not set and has no default", var ), - severity: Severity::Warning, - source_line: Some(content.clone()), - }); - } - ctx.variables.insert(prefix, value.unwrap_or_default()); - return Ok(None); + ) + .source(content), + ); } + ctx.variables.insert(prefix, value.unwrap_or_default()); + return; + } - // Inline JSON: @data $links [{"label": "Home", "url": "/"}] - let (json_text, source) = if filename.starts_with(['[', '{']) { - (filename, "the inline data".to_string()) - } else { - let filename = substitute_vars(&filename, &ctx.variables); - if filename.contains('*') { - self.load_data_glob(&prefix, &filename, line_num, &content, ctx); - return Ok(None); + // Inline JSON: @data $links [{"label": "Home", "url": "/"}] + let (json_text, source) = if filename.starts_with(['[', '{']) { + (filename.to_string(), "the inline data".to_string()) + } else { + let filename = substitute_vars(filename, &ctx.variables); + if filename.contains('*') { + self.load_data_glob(&prefix, &filename, line_num, content, ctx); + return; + } + let resolved = ctx.resolve(&filename); + match std::fs::read_to_string(&resolved) { + Ok(text) => { + ctx.included_files.push(resolved); + (text, format!("'{}'", filename)) } - let resolved = match &ctx.base_path { - Some(base) => base.join(&filename), - None => PathBuf::from(&filename), - }; - match std::fs::read_to_string(&resolved) { - Ok(text) => { - ctx.included_files.push(resolved); - (text, format!("'{}'", filename)) - } - Err(e) => { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("cannot load data '{}': {}", filename, e), - severity: Severity::Error, - source_line: Some(content.clone()), - }); - return Ok(None); - } + Err(e) => { + ctx.diagnostics.push( + Diagnostic::error( + code::UNREADABLE_FILE, + line_num, + format!("cannot load data '{}': {}", filename, e), + ) + .source(content), + ); + return; } - }; - match parse_json_with_error(&json_text) { - Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables), - Err(detail) => ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("invalid JSON in {}: {}", source, detail), - severity: Severity::Error, - source_line: Some(content.clone()), - }), - } - return Ok(None); - } - - // --- @if / @else --- - - if content.trim() == "@else" || content.trim().starts_with("@else if ") { - return Err(ParseError { - line: line_num, - message: "@else without matching @if".to_string(), - }); - } - - // --- @each loop --- - - // @raw: its indented body, or the rest of the line, verbatim - if content == "@raw" { - let text = block_text(children); - let text = text.trim_end_matches('\n'); - return Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())])); - } - if let Some(rest) = content.strip_prefix("@raw ") { - return Ok(Some(vec![Node::Raw(rest.to_string())])); - } - - // --- Function call --- - - if content.starts_with('@') { - let name = extract_element_name(&content); - if ctx.functions.contains_key(name) { - ctx.used_functions.insert(name.to_string()); - let nodes = self.expand_fn_call(name, &content, children, line_num, ctx)?; - return Ok(Some(nodes)); } + }; + match parse_json_with_error(&json_text) { + Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables), + Err(detail) => ctx.diagnostics.push( + Diagnostic::error( + code::INVALID_JSON, + line_num, + format!("invalid JSON in {}: {}", source, detail), + ) + .source(content), + ), } - - // --- Elements --- - - if content.starts_with('@') { - let node = self.parse_element_line(&content, children, line_num, ctx)?; - return Ok(Some(vec![inline_svg(node, line_num, ctx)])); - } - - // --- Bare text --- - - let var_warnings = check_undefined_vars( - &protect_escapes(&content), - &ctx.variables, - line_num, - current_indent, - ); - ctx.diagnostics.extend(var_warnings); - track_var_refs(&content, &mut ctx.used_variables); - let segments = parse_text_segments(&content, ctx); - Ok(Some(vec![Node::Text(segments)])) } /// `@data $name dir/*.json`: a list with one record per matching file @@ -676,13 +836,15 @@ impl Evaluator { }) .collect(), Err(e) => { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("cannot read directory for '{}': {}", pattern, e), - severity: Severity::Error, - source_line: Some(content.to_string()), - }); + ctx.diagnostics.push( + Diagnostic::new( + code::UNREADABLE_FILE, + Severity::Error, + line_num, + format!("cannot read directory for '{}': {}", pattern, e), + ) + .source(content.to_string()), + ); return; } }; @@ -697,13 +859,15 @@ impl Evaluator { let text = match std::fs::read_to_string(&file) { Ok(text) => text, Err(e) => { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("cannot load data '{}': {}", file.display(), e), - severity: Severity::Error, - source_line: Some(content.to_string()), - }); + ctx.diagnostics.push( + Diagnostic::new( + code::UNREADABLE_FILE, + Severity::Error, + line_num, + format!("cannot load data '{}': {}", file.display(), e), + ) + .source(content.to_string()), + ); continue; } }; @@ -714,57 +878,54 @@ impl Evaluator { } records.push(JsonValue::Object(pairs)); } - Ok(_) => ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("'{}' should hold a JSON object", file.display()), - severity: Severity::Error, - source_line: Some(content.to_string()), - }), - Err(detail) => ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("invalid JSON in '{}': {}", file.display(), detail), - severity: Severity::Error, - source_line: Some(content.to_string()), - }), + Ok(_) => ctx.diagnostics.push( + Diagnostic::new( + code::INVALID_JSON, + Severity::Error, + line_num, + format!("'{}' should hold a JSON object", file.display()), + ) + .source(content.to_string()), + ), + Err(detail) => ctx.diagnostics.push( + Diagnostic::new( + code::INVALID_JSON, + Severity::Error, + line_num, + format!("invalid JSON in '{}': {}", file.display(), detail), + ) + .source(content.to_string()), + ), } ctx.included_files.push(file); } flatten_json(name, &JsonValue::Array(records), &mut ctx.variables); } - /// `@each $item in LIST` or `@each $item, $index in LIST`. A LIST from - /// JSON binds each record or value to `$item`; any other list is text, - /// split on commas, or a range `A..B [step N]`. + /// `@each $item in LIST` or `@each $item, $index in LIST`, with the + /// `@else` that follows it. A LIST from JSON binds each record or value + /// to `$item`; any other list is text, split on commas, or a range + /// `A..B [step N]`. fn eval_each( &mut self, - header: &str, - body: &[Syntax], - empty: &[Syntax], - line_num: usize, + node: &syntax::Node, + empty_branch: Option<&syntax::Node>, ctx: &mut ParseContext, - ) -> Result, ParseError> { - let Some((names, list_src)) = header.split_once(" in ") else { - return Err(ParseError { - line: line_num, - message: "@each requires: @each $item in LIST".to_string(), - }); - }; - let names: Vec<&str> = names - .split(',') - .map(|v| v.trim().trim_start_matches('$')) - .collect(); - if names.len() > 2 { - return Err(ParseError { - line: line_num, - message: "@each takes `$item` or `$item, $index`: to loop over records, \ - load them with @data and use `$item.key`" - .to_string(), - }); + ) -> Result>, ParseError> { + ctx.visited.insert(node.id); + if let Some(branch) = empty_branch { + ctx.visited.insert(branch.id); } - let (item, index) = (names[0], names.get(1).copied()); - let list_src = list_src.trim(); + let line_num = node.span.line; + ctx.current_line = line_num; + // A malformed header is a syntax error, already reported + let Some(DirectiveArgs::Each(header)) = node.directive().map(|d| &d.args) else { + return Ok(None); + }; + let body = &node.children; + let empty: &[syntax::Node] = empty_branch.map_or(&[], |b| &b.children); + let (item, index) = (header.item.as_str(), header.index.as_deref()); + let list_src = header.list.raw.as_str(); track_var_refs(list_src, &mut ctx.used_variables); // A list loaded from JSON, by name @@ -788,13 +949,15 @@ impl Evaluator { (None, Some(name)) => { let root = name.split('.').next().unwrap_or(name); if !ctx.variables.contains_key(root) { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("undefined variable '${}'", name), - severity: Severity::Warning, - source_line: Some(format!("@each {}", header)), - }); + ctx.diagnostics.push( + Diagnostic::warning( + code::UNDEFINED_VARIABLE, + line_num, + format!("undefined variable '${}'", name), + ) + .source(node.source.clone()) + .subject(name), + ); } Vec::new() } @@ -802,11 +965,11 @@ impl Evaluator { }; let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len); - if body.is_empty() { - return Ok(Vec::new()); + if body.iter().all(syntax::Node::is_trivia) { + return Ok(Some(Vec::new())); } if count == 0 { - return Ok(self.eval_scoped(empty, ctx)); + return Ok(Some(self.eval_scoped(empty, ctx))); } let saved_vars = ctx.variables.clone(); let mut nodes = Vec::new(); @@ -824,29 +987,27 @@ impl Evaluator { nodes.extend(self.eval_block(body, ctx)); } ctx.variables = saved_vars; - Ok(nodes) + Ok(Some(nodes)) } fn define_function( &mut self, name: &str, - params: &[String], - defaults: &HashMap, - body: &[Syntax], + params: &[syntax::Param], + body: &[syntax::Node], line_num: usize, ctx: &mut ParseContext, ) { // An @style block at the top of the body is scoped to the // function: its rules apply inside a `.hl-NAME` wrapper. - let (style, body): (Vec<&Syntax>, Vec<&Syntax>) = body - .iter() - .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style")); + let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) = + body.iter().partition(|node| node.is_directive("style")); if !style.is_empty() { let css: String = style .iter() - .filter_map(|node| match node { - Syntax::Line { children, .. } => Some(block_text(children)), - _ => None, + .map(|node| { + ctx.visited.insert(node.id); + verbatim_text(&node.children) }) .collect(); // Nested under the scope class, so any CSS works (multi-line @@ -861,60 +1022,125 @@ impl Evaluator { ctx.functions.insert( name.to_string(), FnDef { - params: params.to_vec(), - defaults: defaults.clone(), + params: params.iter().map(|p| p.name.clone()).collect(), + defaults: params + .iter() + .filter_map(|p| Some((p.name.clone(), p.default.clone()?))) + .collect(), body: body.into_iter().cloned().collect(), }, ); } + /// An element line: a chain of elements and function calls, the last of + /// which gets the indented children. + fn eval_element( + &mut self, + node: &syntax::Node, + element: &syntax::ElementLine, + ctx: &mut ParseContext, + ) -> Result, ParseError> { + // An unclosed attribute list is a syntax error, already reported + if element + .chain + .iter() + .any(|h| h.attrs.as_ref().is_some_and(|a| !a.closed)) + { + return Ok(Vec::new()); + } + enum Link { + Element(Element), + Call { + name: String, + args: Vec, + text: Option, + }, + } + let line_num = node.span.line; + let last = element.chain.len() - 1; + let mut links = Vec::new(); + for (i, head) in element.chain.iter().enumerate() { + let text = if i == last { + element.text.as_ref() + } else { + None + }; + if ctx.functions.contains_key(&head.name) { + ctx.used_functions.insert(head.name.clone()); + let args = head.attrs.as_ref().map_or_else(Vec::new, |list| { + parse_attr_list(&list.attrs, line_num, ctx, false) + }); + links.push(Link::Call { + name: head.name.clone(), + args, + text: text.cloned(), + }); + } else { + links.push(Link::Element(parse_single_element( + head, text, line_num, ctx, + )?)); + } + } + + // Children belong to the innermost element; build the chain + // right-to-left, each link wrapping the next. + let mut current = self.eval_block(&node.children, ctx); + for link in links.into_iter().rev() { + current = match link { + Link::Element(mut elem) => { + elem.children.extend(current); + vec![inline_svg(Node::Element(elem), line_num, ctx)] + } + Link::Call { name, args, text } => self.expand_fn_call( + &name, + args, + text.as_ref(), + current, + line_num, + &node.source, + ctx, + )?, + }; + } + Ok(current) + } + + #[allow(clippy::too_many_arguments)] fn expand_fn_call( &mut self, name: &str, - content: &str, - children: &[Syntax], + args: Vec, + trailing_text: Option<&syntax::Text>, + all_caller_children: Vec, line_num: usize, + content: &str, ctx: &mut ParseContext, ) -> Result, ParseError> { // Recursive function cycle detection if ctx.fn_call_stack.contains(&name.to_string()) { - return Err(ParseError { - line: line_num, - message: format!( + return Err(Diagnostic::error( + code::RECURSIVE_CALL, + line_num, + format!( "recursive function call to @{} (call stack: {})", name, ctx.fn_call_stack.join(" -> ") ), - }); + ) + .subject(name)); } - ctx.fn_call_stack.push(name.to_string()); - - // Parse [param value, ...] arguments - let rest = &content[1 + name.len()..]; - let rest = rest.trim_start(); - - let (args, trailing_text) = if rest.starts_with('[') { - parse_attr_brackets_no_validate(rest, line_num, ctx)? - } else { - (Vec::new(), rest.to_string()) - }; - // Clone function definition (releases borrow on ctx). If the function is - // missing (shouldn't normally happen — callers check ctx.functions.contains_key - // first — but we avoid panicking on malformed state). - let fn_def = match ctx.functions.get(name) { - Some(def) => def.clone(), - None => { - ctx.fn_call_stack.pop(); - return Err(ParseError { - line: line_num, - message: format!("undefined function @{}", name), - }); - } + // Clone function definition (releases borrow on ctx) + let Some(fn_def) = ctx.functions.get(name).cloned() else { + return Err(Diagnostic::error( + code::INTERNAL, + line_num, + format!("undefined function @{}", name), + )); }; + ctx.fn_call_stack.push(name.to_string()); - // Parse caller's children, separating named slots from default children - let all_caller_children = self.eval_block(children, ctx); + // Separate the caller's named slots from its other children let mut slot_contents: HashMap> = HashMap::new(); let mut caller_children = Vec::new(); for child in all_caller_children { @@ -932,9 +1158,8 @@ impl Evaluator { } // Text after the call, as in `@card [color red] New`, is content: // it goes first among the caller's children. - let trailing_text = trailing_text.trim(); - if !trailing_text.is_empty() { - caller_children.insert(0, Node::Text(parse_text_segments(trailing_text, ctx))); + if let Some(text) = trailing_text { + caller_children.insert(0, Node::Text(text_segments(text, ctx))); } // Save variable state, inject function parameters @@ -1040,50 +1265,37 @@ impl Evaluator { } } for message in problems { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message, - severity: Severity::Warning, - source_line: Some(content.to_string()), - }); + ctx.diagnostics.push( + Diagnostic::warning(code::NO_SINGLE_ROOT, line_num, message) + .source(content) + .subject(name), + ); } } Ok(result_nodes) } +} - fn parse_element_line( - &mut self, - content: &str, - children: &[Syntax], - line_num: usize, - ctx: &mut ParseContext, - ) -> Result { - let segments = split_chain(content); - - // Parse each segment into an Element - let mut elements: Vec = Vec::new(); - for seg in &segments { - let elem = parse_single_element(seg.trim(), line_num, ctx)?; - elements.push(elem); - } - - // Parse indented children (belong to the innermost element) - let children = self.eval_block(children, ctx); +/// Define `$name` (and, for `--name`, the CSS custom property). +fn set_variable(name: &str, value: String, line_num: usize, ctx: &mut ParseContext) { + if name.starts_with("--") { + ctx.css_vars.push((name.to_string(), value.clone())); + } + ctx.variables.insert(name.to_string(), value); + ctx.let_lines.entry(name.to_string()).or_insert(line_num); +} - // Build chain right-to-left: rightmost gets children, each wraps the next - let mut current_children = children; - for mut elem in elements.into_iter().rev() { - elem.children.extend(current_children); - current_children = vec![Node::Element(elem)]; +/// The text of a verbatim body (`@head`, `@style`, `@markdown`, `@raw`). +fn verbatim_text(children: &[syntax::Node]) -> String { + let mut text = String::new(); + for child in children { + if let NodeKind::Verbatim(body) = &child.kind { + text.push_str(&body.text); + text.push('\n'); } - - current_children.into_iter().next().ok_or(ParseError { - line: line_num, - message: "element chain produced no nodes (this is a parser bug)".to_string(), - }) } + text } // --------------------------------------------------------------------------- @@ -1133,88 +1345,34 @@ fn replace_children_and_slots( // Element parsing // --------------------------------------------------------------------------- -fn extract_element_name(content: &str) -> &str { - let without_at = &content[1..]; - match without_at.find([' ', '[']) { - Some(i) => &without_at[..i], - None => without_at, - } -} - +/// Build the element for `@name [attrs] text` (on its own line, in a chain, +/// or inline in text). fn parse_single_element( - content: &str, + head: &syntax::Head, + text: Option<&syntax::Text>, line_num: usize, ctx: &mut ParseContext, ) -> Result { - let (kind, rest) = if let Some(without_at) = content.strip_prefix('@') { - match without_at.find([' ', '[']) { - Some(i) => { - let kind_str = &without_at[..i]; - let rest = if without_at.as_bytes()[i] == b'[' { - without_at[i..].to_string() - } else { - without_at[i + 1..].to_string() - }; - match parse_element_kind(kind_str, line_num) { - Ok(kind) => (kind, rest), - Err(mut e) => { - // Also suggest user-defined functions - if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) { - e.message = - format!("{}, or did you mean @{}?", e.message, fn_suggestion); - } - return Err(e); - } - } - } - None => match parse_element_kind(without_at, line_num) { - Ok(kind) => (kind, String::new()), - Err(mut e) => { - if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) { - e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion); - } - return Err(e); - } - }, - } - } else { - return Err(ParseError { - line: line_num, - message: format!("expected @element or [attrs], got: {}", content), - }); - }; - - // Parse optional [attrs] - let (attrs, rest) = if rest.starts_with('[') { - parse_attr_brackets(&rest, line_num, ctx)? - } else { - (Vec::new(), rest) - }; - - let rest = rest.trim().to_string(); - track_var_refs(&rest, &mut ctx.used_variables); + let kind = parse_element_kind(&head.name, line_num, ctx)?; + let attrs = head.attrs.as_ref().map_or_else(Vec::new, |list| { + parse_attr_list(&list.attrs, line_num, ctx, true) + }); - // For @link, first token of rest is URL, remainder is inline text + // For @link, the first word is the URL and the rest is its text let mut children = Vec::new(); - let argument = if rest.is_empty() { - None - } else if kind == ElementKind::Link { - let rest_sub = substitute_vars(&protect_escapes(&rest), &ctx.variables); - if let Some((url, text)) = rest_sub.split_once(' ') { - let text = text.trim(); - if !text.is_empty() { - children.push(Node::Text(parse_text_segments(text, ctx))); - } - Some(restore_escapes(url)) + let argument = text.map(|text| { + track_var_refs(&text.raw, &mut ctx.used_variables); + let raw = if kind == ElementKind::Link { + let (url, rest) = text.split_first_word(); + if let Some(rest) = rest { + children.push(Node::Text(text_segments(&rest, ctx))); + } + url } else { - Some(restore_escapes(&rest_sub)) - } - } else { - Some(restore_escapes(&substitute_vars( - &protect_escapes(&rest), - &ctx.variables, - ))) - }; + text.raw.clone() + }; + restore_escapes(&substitute_vars(&protect_escapes(&raw), &ctx.variables)) + }); // For @slot, the argument is the slot name let kind = if let ElementKind::Slot(_) = kind { @@ -1225,12 +1383,14 @@ fn parse_single_element( // Every other element treats its argument as leading text content, as // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`. - let argument = match argument { - Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => { - children.insert(0, Node::Text(parse_text_segments(&rest, ctx))); + let argument = match (argument, text) { + (Some(_), Some(text)) + if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => + { + children.insert(0, Node::Text(text_segments(text, ctx))); None } - other => other, + (argument, _) => argument, }; Ok(Element { @@ -1253,32 +1413,31 @@ fn argument_is_special(kind: &ElementKind) -> bool { .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_))) } -/// The directive names (without `@`) the parser recognizes, for tools such -/// as the language server. -pub fn known_directives() -> &'static [&'static str] { - KNOWN_DIRECTIVES -} - -const KNOWN_DIRECTIVES: &[&str] = &[ - "page", "let", "include", "raw", "if", "else", "each", "meta", "head", "style", "markdown", - "data", -]; - -fn parse_element_kind(s: &str, line_num: usize) -> Result { - if let Some(kind) = ElementKind::from_name(s) { +/// The element named `name`, or an "unknown element" error that suggests +/// the closest element, directive or function. +fn parse_element_kind( + name: &str, + line_num: usize, + ctx: &ParseContext, +) -> Result { + if let Some(kind) = ElementKind::from_name(name) { return Ok(kind); } let all_known: Vec<&str> = ElementKind::all_names() - .chain(KNOWN_DIRECTIVES.iter().copied()) + .chain(DIRECTIVES.iter().map(|d| d.name)) .collect(); - let message = match suggest_closest(s, &all_known) { - Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest), - None => format!("unknown element @{}", s), + let closest = suggest_closest(name, &all_known); + let mut message = match closest { + Some(closest) => format!("unknown element @{}, did you mean @{}?", name, closest), + None => format!("unknown element @{}", name), }; - Err(ParseError { - line: line_num, - message, - }) + let function = suggest_fn_name(name, ctx); + if let Some(function) = &function { + message = format!("{}, or did you mean @{}?", message, function); + } + Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message) + .subject(name) + .suggest(closest.map(str::to_string).or(function))) } /// Format include/import stack as a readable chain for error messages. @@ -1349,25 +1508,24 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> { best } -/// Suggest the closest user-defined function name for typos. +/// Suggest the closest user-defined function name for typos, from the +/// functions defined so far and those defined anywhere in the file. fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option { let input_chars: Vec = input.chars().collect(); let max_allowed = 2usize.min(input_chars.len().saturating_sub(1)); - let mut best: Option = None; - let mut best_dist = usize::MAX; - for name in ctx.functions.keys() { + let mut best: Option<(usize, &String)> = None; + for name in ctx.functions.keys().chain(ctx.namespace.iter()) { let nlen = name.chars().count(); if nlen.abs_diff(input_chars.len()) > max_allowed { continue; } let name_chars: Vec = name.chars().collect(); let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed); - if dist < best_dist && dist <= max_allowed { - best_dist = dist; - best = Some(name.clone()); + if dist <= max_allowed && best.is_none_or(|b| (dist, name) < b) { + best = Some((dist, name)); } } - best + best.map(|(_, name)| name.clone()) } /// Suggest the closest variable name for undefined `$var` references. @@ -1430,16 +1588,21 @@ fn check_undefined_vars( && !vars.contains_key(&name) && let Some(closest) = suggest_var_name(&name, vars) { - warnings.push(Diagnostic { - line: line_num, - column: Some(indent + col), - message: format!( - "undefined variable '${}', did you mean '${}'?", - name, closest - ), - severity: Severity::Warning, - source_line: Some(format!("{}{}", " ".repeat(indent), input)), - }); + warnings.push( + Diagnostic::new( + code::UNDEFINED_VARIABLE, + Severity::Warning, + line_num, + format!( + "undefined variable '${}', did you mean '${}'?", + name, closest + ), + ) + .column(indent + col) + .source(format!("{}{}", " ".repeat(indent), input)) + .subject(name.clone()) + .suggest(Some(closest)), + ); } i = end; } else { @@ -1503,16 +1666,15 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext // All space-separated parts must be numeric or have a CSS unit for part in val.split_whitespace() { if part.parse::().is_err() && !has_css_unit(part) { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + format!( "'{}' expects a numeric value (with optional unit), got '{}'", attr.key, val ), - severity: Severity::Warning, - source_line: None, - }); + )); return; } } @@ -1521,48 +1683,44 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext let is_numeric = val.parse::().is_ok(); let has_unit = has_css_unit(val); if !is_keyword && !is_numeric && !has_unit { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + format!( "'{}' expects a number or one of [{}], got '{}'", attr.key, SIZE_KEYWORDS.join(", "), val ), - severity: Severity::Warning, - source_line: None, - }); + )); } } else if base_key == "opacity" { if let Ok(v) = val.parse::() { if !(0.0..=1.0).contains(&v) { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("'opacity' should be between 0 and 1, got '{}'", val), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + format!("'opacity' should be between 0 and 1, got '{}'", val), + )); } } else { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("'opacity' expects a numeric value, got '{}'", val), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + format!("'opacity' expects a numeric value, got '{}'", val), + )); } } else if base_key == "z-index" { if val.parse::().is_err() { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("'z-index' expects an integer, got '{}'", val), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + format!("'z-index' expects an integer, got '{}'", val), + )); } } else if base_key == "display" { const DISPLAY_VALUES: &[&str] = &[ @@ -1587,13 +1745,12 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s), None => format!("unknown display value '{}'", val), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + msg, + )); } } else if base_key == "position" { const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"]; @@ -1603,13 +1760,12 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s), None => format!("unknown position value '{}'", val), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + msg, + )); } } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" { const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"]; @@ -1619,13 +1775,12 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s), None => format!("unknown overflow value '{}'", val), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + msg, + )); } } else if base_key == "text-align" { const TEXT_ALIGN_VALUES: &[&str] = @@ -1636,13 +1791,12 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s), None => format!("unknown text-align value '{}'", val), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + msg, + )); } } else if base_key == "cursor" { const CURSOR_VALUES: &[&str] = &[ @@ -1682,13 +1836,12 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s), None => format!("unknown cursor value '{}'", val), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + msg, + )); } } else if base_key == "font-weight" { const WEIGHT_VALUES: &[&str] = &[ @@ -1696,16 +1849,15 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext "700", "800", "900", ]; if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_VALUE, + Severity::Warning, + line_num, + format!( "'font-weight' expects a weight keyword or number 100-900, got '{}'", val ), - severity: Severity::Warning, - source_line: None, - }); + )); } } else if base_key == "color" || base_key == "background" { // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.) @@ -1800,70 +1952,15 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s), None => format!("unknown color '{}'", val), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); - } - } - } - } -} - -fn parse_attr_brackets( - input: &str, - line_num: usize, - ctx: &mut ParseContext, -) -> Result<(Vec, String), ParseError> { - parse_attr_brackets_inner(input, line_num, ctx, true) -} - -fn parse_attr_brackets_no_validate( - input: &str, - line_num: usize, - ctx: &mut ParseContext, -) -> Result<(Vec, String), ParseError> { - parse_attr_brackets_inner(input, line_num, ctx, false) -} - -fn parse_attr_brackets_inner( - input: &str, - line_num: usize, - ctx: &mut ParseContext, - validate: bool, -) -> Result<(Vec, String), ParseError> { - let mut depth = 0; - let mut end_pos = 0; - - for (i, c) in input.char_indices() { - match c { - '[' => depth += 1, - ']' => { - depth -= 1; - if depth == 0 { - end_pos = i; - break; + ctx.diagnostics.push( + Diagnostic::warning(code::UNKNOWN_COLOR, line_num, msg) + .subject(val.as_str()) + .suggest(suggestion), + ); } } - _ => {} } } - - if depth != 0 { - return Err(ParseError { - line: line_num, - message: "unclosed '[' in attribute list".to_string(), - }); - } - - let attrs_inner = &input[1..end_pos]; - let remaining = input[end_pos + 1..].trim().to_string(); - let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate); - - Ok((attrs, remaining)) } fn is_valid_hex_color(s: &str) -> bool { @@ -1874,8 +1971,11 @@ fn is_valid_hex_color(s: &str) -> bool { matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit()) } +/// Evaluate the attributes of a list: bundles are spliced in, `if()` +/// chooses, variables are substituted. With `validate`, unknown names and +/// invalid values are reported. fn parse_attr_list( - input: &str, + tokens: &[syntax::Attr], line_num: usize, ctx: &mut ParseContext, validate: bool, @@ -1883,8 +1983,8 @@ fn parse_attr_list( let mut attrs = Vec::new(); let mut seen_keys: Vec = Vec::new(); - for part in split_commas(input) { - let part = part.trim(); + for token in tokens { + let part = token.raw.as_str(); // A whole attribute `if(cond, a, b)` is the chosen branch's text. let chosen; let part = match choose_if(part, ctx, line_num) { @@ -1916,7 +2016,7 @@ fn parse_attr_list( }; let part = substitute_vars(&part, &ctx.variables); - let attr = if let Some((key, value)) = split_html_attribute(&part) { + let attr = if let Some((key, value)) = syntax::split_html_attribute(&part) { Attribute { key: key.to_string(), value: Some(value.to_string()), @@ -1941,13 +2041,12 @@ fn parse_attr_list( // variants like `border` and `hover:border` are not conflated) if validate { if seen_keys.contains(&attr.key) { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("duplicate attribute '{}'", attr.key), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::DUPLICATE_ATTRIBUTE, + Severity::Warning, + line_num, + format!("duplicate attribute '{}'", attr.key), + )); } else { seen_keys.push(attr.key.clone()); } @@ -1962,13 +2061,12 @@ fn parse_attr_list( && val.starts_with('#') && !is_valid_hex_color(val) { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("invalid hex color '{}'", val), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::INVALID_COLOR, + Severity::Warning, + line_num, + format!("invalid hex color '{}'", val), + )); } } @@ -1984,18 +2082,20 @@ fn parse_attr_list( || base_key.starts_with("aria-") || base_key.starts_with("data-") { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!( - "'{}' is an HTML attribute: write `{}={}`", - attr.key, - attr.key, - attr.value.as_deref().unwrap_or("") - ), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push( + Diagnostic::new( + code::HTML_ATTRIBUTE_FORM, + Severity::Warning, + line_num, + format!( + "'{}' is an HTML attribute: write `{}={}`", + attr.key, + attr.key, + attr.value.as_deref().unwrap_or("") + ), + ) + .subject(attr.key.as_str()), + ); } else { let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes()); let msg = match suggestion { @@ -2007,13 +2107,11 @@ fn parse_attr_list( } None => format!("unknown attribute '{}'", attr.key), }; - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: msg, - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push( + Diagnostic::warning(code::UNKNOWN_ATTRIBUTE, line_num, msg) + .subject(base_key) + .suggest(suggestion), + ); } } @@ -2023,86 +2121,6 @@ fn parse_attr_list( attrs } -/// Split an HTML attribute written `key=value` (`alt=`, `type=email`, -/// `aria-label=Close menu`). Returns `None` for style attributes. -fn split_html_attribute(part: &str) -> Option<(&str, &str)> { - let first_token = part.split(char::is_whitespace).next()?; - let eq = first_token.find('=')?; - let key = &part[..eq]; - let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic()) - && key - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); - valid_key.then(|| (key, part[eq + 1..].trim())) -} - -fn split_commas(input: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0; - let mut depth = 0; - let mut in_quotes = false; - - for (i, c) in input.char_indices() { - match c { - '"' => in_quotes = !in_quotes, - _ if in_quotes => {} - '[' | '(' => depth += 1, - ']' | ')' => depth -= 1, - ',' if depth == 0 => { - parts.push(&input[start..i]); - start = i + 1; - } - _ => {} - } - } - parts.push(&input[start..]); - parts -} - -// --------------------------------------------------------------------------- -// Chain splitting (the > operator) -// --------------------------------------------------------------------------- - -fn split_chain(content: &str) -> Vec { - let mut segments = Vec::new(); - let mut current = String::new(); - let chars: Vec = content.chars().collect(); - let mut i = 0; - let mut bracket_depth = 0; - - while i < chars.len() { - if chars[i] == '[' { - bracket_depth += 1; - } - if chars[i] == ']' { - bracket_depth -= 1; - } - - // Match " > @" outside brackets - if bracket_depth == 0 - && i + 3 < chars.len() - && chars[i] == ' ' - && chars[i + 1] == '>' - && chars[i + 2] == ' ' - && chars[i + 3] == '@' - { - segments.push(current.trim().to_string()); - current = String::new(); - i += 3; // skip " > ", keep the "@" - continue; - } - - current.push(chars[i]); - i += 1; - } - - if !current.trim().is_empty() { - segments.push(current.trim().to_string()); - } - - segments -} - // --------------------------------------------------------------------------- // Text segment parsing (inline {...} elements) // --------------------------------------------------------------------------- @@ -2137,77 +2155,49 @@ fn restore_escapes(text: &str) -> String { out } -fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec { +/// Evaluate text: substitute variables in its plain runs and build its +/// inline elements. +fn text_segments(text: &syntax::Text, ctx: &mut ParseContext) -> Vec { let mut segments = Vec::new(); - let mut current_text = String::new(); - let chars: Vec = protect_escapes(input).chars().collect(); - let mut i = 0; - - while i < chars.len() { - if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' { - // Flush accumulated plain text - if !current_text.is_empty() { - segments.push(TextSegment::Plain(restore_escapes(&substitute_vars( - ¤t_text, - &ctx.variables, - )))); - current_text.clear(); - } - - // Find matching } - let mut depth = 0; - let start = i + 1; // after { - i += 1; - loop { - if i >= chars.len() { - break; - } - if chars[i] == '{' { - depth += 1; - } else if chars[i] == '}' { - if depth == 0 { - break; + let mut plain = String::new(); + let flush = |plain: &mut String, segments: &mut Vec, ctx: &ParseContext| { + if !plain.is_empty() { + let text = substitute_vars(&protect_escapes(plain), &ctx.variables); + segments.push(TextSegment::Plain(restore_escapes(&text))); + plain.clear(); + } + }; + for segment in &text.segments { + match segment { + Segment::Plain { raw, .. } => plain.push_str(raw), + Segment::Inline(inline) => { + match parse_single_element( + &inline.head, + inline.text.as_ref(), + ctx.current_line, + ctx, + ) { + Ok(elem) => { + flush(&mut plain, &mut segments, ctx); + segments.push(TextSegment::Inline(elem)); } - depth -= 1; - } - i += 1; - } - - let inner: String = chars[start..i].iter().collect(); - i += 1; // skip } - - match parse_single_element(&inner, ctx.current_line, ctx) { - Ok(elem) => segments.push(TextSegment::Inline(elem)), - Err(e) => { - // Keep the braces as literal text, but flag what looks - // like a mistyped inline element. - if inner.trim_start().starts_with('@') { - ctx.diagnostics.push(Diagnostic { - line: ctx.current_line, - column: None, - message: e.message, - severity: Severity::Warning, - source_line: Some(input.to_string()), - }); + Err(mut e) => { + // Keep the braces as literal text, but flag what + // looks like a mistyped inline element. + e.severity = Severity::Warning; + e.source_line = Some(text.raw.as_str().into()); + ctx.diagnostics.push(e); + plain.push('{'); + plain.push_str(&inline.raw); + if inline.closed { + plain.push('}'); + } } - current_text.push('{'); - current_text.push_str(&inner); - current_text.push('}'); } } - } else { - current_text.push(chars[i]); - i += 1; } } - - if !current_text.is_empty() { - segments.push(TextSegment::Plain(restore_escapes(&substitute_vars( - ¤t_text, - &ctx.variables, - )))); - } - + flush(&mut plain, &mut segments, ctx); segments } @@ -2381,29 +2371,29 @@ pub fn lint(nodes: &[Node]) -> Vec { fn walk(nodes: &[Node], depth: usize, out: &mut Vec) { for node in nodes { let Node::Element(elem) = node else { continue }; - let mut warn = |message: String| { - out.push(Diagnostic { - line: elem.line_num, - column: None, - message, - severity: Severity::Warning, - source_line: None, - }) + let mut warn = |code: &'static str, message: String| { + out.push(Diagnostic::warning(code, elem.line_num, message)) }; if depth > 10 { - warn(format!( - "deeply nested element ({} levels): consider simplifying", - depth - )); + warn( + code::DEEP_NESTING, + format!( + "deeply nested element ({} levels): consider simplifying", + depth + ), + ); } if matches!(elem.kind, ElementKind::Row | ElementKind::El) && elem.children.is_empty() { - warn(format!( - "empty container (@{}) has no children", - elem.kind.name() - )); + warn( + code::EMPTY_CONTAINER, + format!("empty container (@{}) has no children", elem.kind.name()), + ); } if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") { - warn("@button missing 'type' attribute (defaults to submit)".to_string()); + warn( + code::MISSING_BUTTON_TYPE, + "@button missing 'type' attribute (defaults to submit)".to_string(), + ); } walk(&elem.children, depth + 1, out); } @@ -2426,87 +2416,79 @@ fn validate_tree( && attr.value.as_deref() == Some("fill") && !matches!(parent_kind, Some(ElementKind::Row)) { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "'width fill' works best inside @row; using 100% as fallback" - .to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::FILL_FALLBACK, + Severity::Warning, + elem.line_num, + "'width fill' works best inside @row; using 100% as fallback".to_string(), + )); } if base == "height" && attr.value.as_deref() == Some("fill") && !parent_kind.is_some_and(ElementKind::is_column) { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "'height fill' works best inside @el; using 100% as fallback" - .to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::FILL_FALLBACK, + Severity::Warning, + elem.line_num, + "'height fill' works best inside @el; using 100% as fallback".to_string(), + )); } // Container-only attributes on non-container elements if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new( + code::NO_EFFECT, + Severity::Warning, + elem.line_num, + format!( "'{}' has no effect on {} (only works on @row, @el, @el)", base, element_kind_name(&elem.kind) ), - severity: Severity::Warning, - source_line: None, - }); + )); } // Form-specific: placeholder only on @input/@textarea if base == "placeholder" && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea")) { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new( + code::NO_EFFECT, + Severity::Warning, + elem.line_num, + format!( "'placeholder' has no effect on {} (only works on @input, @textarea)", element_kind_name(&elem.kind) ), - severity: Severity::Warning, - source_line: None, - }); + )); } // 'for' only on @label if base == "for" && !elem.kind.is_tag("label") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new( + code::NO_EFFECT, + Severity::Warning, + elem.line_num, + format!( "'for' has no effect on {} (only works on @label)", element_kind_name(&elem.kind) ), - severity: Severity::Warning, - source_line: None, - }); + )); } // 'rows'/'cols' only on @textarea if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new( + code::NO_EFFECT, + Severity::Warning, + elem.line_num, + format!( "'{}' has no effect on {} (only works on @textarea)", base, element_kind_name(&elem.kind) ), - severity: Severity::Warning, - source_line: None, - }); + )); } // Media-specific attributes only on @video/@audio @@ -2521,38 +2503,35 @@ fn validate_tree( | "preload" ) && !matches!(elem.kind.name(), "video" | "audio") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new( + code::NO_EFFECT, + Severity::Warning, + elem.line_num, + format!( "'{}' has no effect on {} (only works on @video, @audio)", base, element_kind_name(&elem.kind) ), - severity: Severity::Warning, - source_line: None, - }); + )); } } // Missing alt text on @image if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "@image missing 'alt' attribute (accessibility)".to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::MISSING_ALT, + Severity::Warning, + elem.line_num, + "@image missing 'alt' attribute (accessibility)".to_string(), + )); } if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "@input missing 'type' attribute (defaults to 'text')".to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::MISSING_INPUT_TYPE, + Severity::Warning, + elem.line_num, + "@input missing 'type' attribute (defaults to 'text')".to_string(), + )); } if matches!(elem.kind, ElementKind::Link) { // For @link, argument is the URL, not text content @@ -2563,14 +2542,12 @@ fn validate_tree( .iter() .any(|a| a.key == "aria-label" || a.key == "title") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "@link has no visible text or aria-label (accessibility)" - .to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::MISSING_LINK_TEXT, + Severity::Warning, + elem.line_num, + "@link has no visible text or aria-label (accessibility)".to_string(), + )); } } @@ -2594,16 +2571,10 @@ fn validate_tree( { let ratio = contrast_ratio(bg_rgb, fg_rgb); if ratio < 4.5 { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new(code::LOW_CONTRAST, Severity::Warning, elem.line_num, format!( "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)", ratio, fg, bg - ), - severity: Severity::Warning, - source_line: None, - }); + ))); } } } @@ -2618,28 +2589,21 @@ fn validate_tree( let has_title = elem.attrs.iter().any(|a| a.key == "title"); let in_label = parent_kind.is_some_and(|k| k.is_tag("label")); if !has_id && !has_aria_label && !has_title && !in_label { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!( + diagnostics.push(Diagnostic::new(code::MISSING_LABEL, Severity::Warning, elem.line_num, format!( "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)", element_kind_name(&elem.kind) - ), - severity: Severity::Warning, - source_line: None, - }); + ))); } } // @iframe should have title attribute if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "@iframe missing 'title' attribute (accessibility)".to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::MISSING_TITLE, + Severity::Warning, + elem.line_num, + "@iframe missing 'title' attribute (accessibility)".to_string(), + )); } // @button should have accessible text @@ -2647,14 +2611,12 @@ fn validate_tree( let has_text = elem.argument.is_some() || !elem.children.is_empty(); let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label"); if !has_text && !has_aria { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "@button has no text content or aria-label (accessibility)" - .to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::MISSING_BUTTON_TEXT, + Severity::Warning, + elem.line_num, + "@button has no text content or aria-label (accessibility)".to_string(), + )); } } @@ -2669,14 +2631,12 @@ fn validate_tree( .iter() .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source"))); if !has_aria && !has_track { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: "@video should have aria-label or captions for accessibility" - .to_string(), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new( + code::MISSING_CAPTIONS, + Severity::Warning, + elem.line_num, + "@video should have aria-label or captions for accessibility".to_string(), + )); } } @@ -2686,13 +2646,7 @@ fn validate_tree( && let Ok(n) = val.parse::() && n > 0 { - diagnostics.push(Diagnostic { - line: elem.line_num, - column: None, - message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n), - severity: Severity::Warning, - source_line: None, - }); + diagnostics.push(Diagnostic::new(code::POSITIVE_TABINDEX, Severity::Warning, elem.line_num, format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n))); } validate_tree(&elem.children, Some(&elem.kind), diagnostics); @@ -2791,13 +2745,12 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node { let mut svg = match std::fs::read_to_string(&resolved) { Ok(text) => text.trim().to_string(), Err(e) => { - ctx.diagnostics.push(Diagnostic { - line: line_num, - column: None, - message: format!("cannot load SVG '{}': {}", filename, e), - severity: Severity::Error, - source_line: None, - }); + ctx.diagnostics.push(Diagnostic::new( + code::UNREADABLE_FILE, + Severity::Error, + line_num, + format!("cannot load SVG '{}': {}", filename, e), + )); return node; } }; @@ -2880,42 +2833,48 @@ fn check_unused(ctx: &mut ParseContext) { continue; // CSS vars are always used } if !ctx.used_variables.contains(name) { - ctx.diagnostics.push(Diagnostic { - line, - column: None, - message: format!("unused variable '${}' (defined but never referenced)", name), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push( + Diagnostic::new( + code::UNUSED_VARIABLE, + Severity::Warning, + line, + format!("unused variable '${}' (defined but never referenced)", name), + ) + .subject(name.as_str()), + ); } } // Check unused attribute bundles (@let name [...]) for (name, &line) in &ctx.define_lines { if !ctx.used_defines.contains(name) { - ctx.diagnostics.push(Diagnostic { - line, - column: None, - message: format!( - "unused attribute bundle '${}' (defined but never referenced)", - name - ), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push( + Diagnostic::new( + code::UNUSED_BUNDLE, + Severity::Warning, + line, + format!( + "unused attribute bundle '${}' (defined but never referenced)", + name + ), + ) + .subject(name.as_str()), + ); } } // Check unused functions (@let name ... with body) for (name, &line) in &ctx.fn_lines { if !ctx.used_functions.contains(name) { - ctx.diagnostics.push(Diagnostic { - line, - column: None, - message: format!("unused function '@{}' (defined but never called)", name), - severity: Severity::Warning, - source_line: None, - }); + ctx.diagnostics.push( + Diagnostic::new( + code::UNUSED_FUNCTION, + Severity::Warning, + line, + format!("unused function '@{}' (defined but never called)", name), + ) + .subject(name.as_str()), + ); } } } @@ -3001,34 +2960,6 @@ fn parse_json_with_error(input: &str) -> Result { } } -/// Visit the lines of a block in source order, children after their line. -fn for_each_line<'a>(block: &'a [Syntax], f: &mut impl FnMut(&'a Syntax)) { - for node in block { - f(node); - if let Syntax::Line { children, .. } = node { - for_each_line(children, f); - } - } -} - -/// The text of a block whose lines are content, not htmlang (`@head`, -/// `@style`, `@markdown`): each line trimmed, verbatim text as is. -fn block_text(block: &[Syntax]) -> String { - let mut text = String::new(); - for_each_line(block, &mut |node| match node { - Syntax::Line { text: line, .. } => { - text.push_str(line.trim()); - text.push('\n'); - } - Syntax::Raw { text: raw, .. } => { - text.push_str(raw); - text.push('\n'); - } - _ => {} - }); - text -} - fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> { pos = skip_ws(chars, pos); if pos >= chars.len() { diff --git a/crates/htmlang-core/src/syntax.rs b/crates/htmlang-core/src/syntax.rs index d355fec..4edabd1 100644 --- a/crates/htmlang-core/src/syntax.rs +++ b/crates/htmlang-core/src/syntax.rs @@ -1,438 +1,1624 @@ -//! Parsing: source text to a syntax tree, before anything is evaluated. +//! The syntax tree: source text read into lines, element heads, attribute +//! lists and text, before anything is evaluated. //! -//! This step knows the shape of the language: indentation, continuation -//! lines of attribute lists, verbatim bodies (of `@raw`, `@style`, `@head`, -//! `@script` and `@markdown`), and the directives whose -//! structure spans several lines (`@if` / `@else` chains, `@each` with an -//! `@else`, function definitions). It reads no files and substitutes no -//! variables; `parser.rs` evaluates the tree. +//! The tree is lossless enough to print the file back: comments and blank +//! lines are nodes, every piece keeps its source [`Span`], and verbatim +//! bodies keep their text. It reads no files and substitutes no variables. +//! The evaluator (`parser.rs`), the formatter and the language server all +//! work on this one tree, and what a directive accepts comes from the +//! [`DIRECTIVES`](crate::ast::DIRECTIVES) table. +//! +//! The grammar, line by line: +//! - A line starting with `--` is a comment; an empty line is blank. +//! - A line starting with `@NAME` is a directive when `NAME` is in the +//! directive table, and an element (or function call) otherwise. An +//! element line is a chain of heads `@name [attributes]` joined by `>`, +//! then text. A `[` opens an attribute list only right after a head's +//! name, or where a directive's grammar has one (`@page [..]`, +//! `@let name [..]`); anywhere else it is text. +//! - Any other line is text, in which `{@name [attributes] text}` is an +//! inline element. +//! - An attribute list (or `@data`'s inline JSON) may continue onto the +//! following lines until it closes. +//! - Lines indented deeper than a line are its body. What the body may be +//! is the directive's or element's [`BodyKind`]. + +use crate::ast::{self, ArgGrammar, BodyKind, DirectiveSpec}; +use crate::diagnostic::{Diagnostic, code}; -use std::collections::HashMap; +/// A range of the source. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Span { + /// Byte offsets into the source. + pub start: usize, + pub end: usize, + /// 1-based line and 0-based byte column of `start`. + pub line: usize, + pub column: usize, +} +/// A parsed file. #[derive(Clone, Debug)] -pub(crate) enum LineContent { - Normal(String), - Raw(String), +pub struct Tree { + pub nodes: Vec, + /// Syntax errors: they are reported for every line, whether or not it + /// is ever evaluated. + pub diagnostics: Vec, + /// One past the largest node id. + pub(crate) end_id: usize, } -/// One logical source line (continuations joined), without its indented -/// children. +/// One line of the file (with its continuation lines), and the lines +/// indented under it. #[derive(Clone, Debug)] -pub(crate) struct Line { +pub struct Node { + /// Unique within a parse, in source order. + pub id: usize, + /// Width of the leading whitespace of its first line, in bytes. pub indent: usize, - pub content: LineContent, - pub line_num: usize, + /// The node's own line(s), without its children. + pub span: Span, + /// How many source lines `span` covers. + pub line_count: usize, + /// The node's own lines joined into one (continuation lines joined with + /// a space), without surrounding whitespace. + pub source: String, + pub kind: NodeKind, + /// The lines indented under it, trivia included. + pub children: Vec, +} + +#[derive(Clone, Debug)] +pub enum NodeKind { + /// An empty line. + Blank, + /// A `--` comment line; its text is the node's `source`. + Comment, + /// A line of text. + Text(Text), + /// `@name [attrs] > @name [attrs] text`: an element or a function call. + Element(ElementLine), + Directive(Directive), + /// The body of a verbatim directive or element (`@style`, `@script`). + Verbatim(Verbatim), +} + +/// Text: plain runs and inline elements. +#[derive(Clone, Debug)] +pub struct Text { + pub raw: String, + pub span: Span, + pub segments: Vec, +} + +#[derive(Clone, Debug)] +pub enum Segment { + /// Text as written: escapes are still in it and `$name`s are not + /// substituted. + Plain { raw: String, span: Span }, + /// `{@name [attrs] text}`. + Inline(Box), +} + +#[derive(Clone, Debug)] +pub struct Inline { + /// What is between the braces. + pub raw: String, + /// Including the braces. + pub span: Span, + pub head: Head, + pub text: Option, + /// Whether the closing `}` was found. + pub closed: bool, +} + +/// `@name [attributes]`. +#[derive(Clone, Debug)] +pub struct Head { + /// Without the `@`. + pub name: String, + /// Including the `@`. + pub name_span: Span, + pub attrs: Option, + pub span: Span, +} + +/// `[attr, attr]`. +#[derive(Clone, Debug)] +pub struct AttrList { + pub attrs: Vec, + /// Including the brackets. + pub span: Span, + /// Whether the closing `]` was found. + pub closed: bool, } +/// One attribute of a list, as written. #[derive(Clone, Debug)] -pub(crate) enum Syntax { - /// Verbatim text. - Raw { line: usize, text: String }, - /// An element, text, a function call or a single-line directive, with - /// the lines indented under it. - Line { - line: usize, - indent: usize, - text: String, - children: Vec, +pub struct Attr { + /// The attribute's text, trimmed. + pub raw: String, + pub key: String, + pub value: Option, + /// Written `key=value`. + pub html: bool, + pub span: Span, +} + +/// A directive's argument text, as written. +#[derive(Clone, Debug)] +pub struct Arg { + pub raw: String, + pub span: Span, +} + +#[derive(Clone, Debug)] +pub struct ElementLine { + /// The heads joined by `>`; the last one gets the children. + pub chain: Vec, + /// Text after the last head. + pub text: Option, +} + +#[derive(Clone, Debug)] +pub struct Directive { + pub spec: &'static DirectiveSpec, + /// Including the `@`. + pub name_span: Span, + pub args: DirectiveArgs, +} + +impl Directive { + pub fn name(&self) -> &'static str { + self.spec.name + } +} + +/// A directive's arguments, read by its [`ArgGrammar`]. +#[derive(Clone, Debug)] +pub enum DirectiveArgs { + None, + Text(Option), + Page { + attrs: Option, + title: Option, }, - /// `@let NAME $param $param=default` with an indented body. - Function { - line: usize, - name: String, - params: Vec, - defaults: HashMap, - body: Vec, + Let(LetDef), + Condition(Arg), + Else { + condition: Option, }, - /// `@if` with its `@else if` and `@else` branches. - If { branches: Vec }, - /// `@each $var in LIST` with its body and the `@else` for an empty list. - Each { - line: usize, - header: String, - body: Vec, - empty: Vec, + Each(Loop), + Data { + name: String, + name_span: Span, + source: Arg, }, + /// The header has a syntax error, which is already reported. + Invalid, } +/// `@let NAME ...`. #[derive(Clone, Debug)] -pub(crate) struct Branch { - pub line: usize, - /// `None` for `@else`. - pub condition: Option, - pub body: Vec, -} - -impl Syntax { - pub fn line(&self) -> usize { - match self { - Syntax::Raw { line, .. } - | Syntax::Line { line, .. } - | Syntax::Function { line, .. } - | Syntax::Each { line, .. } => *line, - Syntax::If { branches } => branches.first().map_or(0, |b| b.line), +pub struct LetDef { + pub name: String, + pub name_span: Span, + pub form: LetForm, +} + +#[derive(Clone, Debug)] +pub enum LetForm { + /// `@let name text` (`None` for a bare `@let name`). + Value(Option), + /// `@let name "text"`: the text between the quotes. + Quoted(Arg), + /// `@let name = EXPR`: the expression. + Computed(Arg), + /// `@let name [attrs]`. + Bundle(AttrList), + /// `@let name $param $param=default` with an indented body. + Function(Vec), +} + +#[derive(Clone, Debug)] +pub struct Param { + pub name: String, + pub default: Option, + pub span: Span, +} + +/// `@each $item, $index in LIST`. +#[derive(Clone, Debug)] +pub struct Loop { + pub item: String, + pub index: Option, + pub list: Arg, +} + +#[derive(Clone, Debug)] +pub struct Verbatim { + /// The body's lines without their common indentation. + pub text: String, +} + +// --------------------------------------------------------------------------- +// Queries used by the evaluator and the tools +// --------------------------------------------------------------------------- + +impl Node { + /// Blank lines and comments. + pub fn is_trivia(&self) -> bool { + matches!(self.kind, NodeKind::Blank | NodeKind::Comment) + } + + /// The directive on this line, if it is one. + pub fn directive(&self) -> Option<&Directive> { + match &self.kind { + NodeKind::Directive(d) => Some(d), + _ => None, } } - /// The node's first source line, for diagnostics. - pub fn source(&self) -> String { - match self { - Syntax::Raw { text, .. } | Syntax::Line { text, .. } => text.clone(), - Syntax::Function { - name, - params, - defaults, - .. - } => { - let mut out = format!("@let {}", name); - for param in params { - match defaults.get(param) { - Some(default) => out.push_str(&format!(" ${}={}", param, default)), - None => out.push_str(&format!(" ${}", param)), - } + /// Whether this line is the directive `@name`. + pub fn is_directive(&self, name: &str) -> bool { + self.directive().is_some_and(|d| d.name() == name) + } + + /// Children other than blank lines and comments. + pub fn body(&self) -> impl Iterator { + self.children.iter().filter(|c| !c.is_trivia()) + } + + /// The last source line of the node and its children. + pub fn end_line(&self) -> usize { + let own = self.span.line + self.source_lines() - 1; + self.children + .iter() + .filter(|c| !matches!(c.kind, NodeKind::Blank)) + .map(Node::end_line) + .fold(own, usize::max) + } + + fn source_lines(&self) -> usize { + self.line_count.max(1) + } + + /// Visit this node and everything under it, in source order. + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { + f(self); + for child in &self.children { + child.walk(f); + } + } + + /// The heads on this line: an element's chain, and the inline + /// elements in its text. + pub fn heads(&self) -> Vec<&Head> { + let mut heads = Vec::new(); + match &self.kind { + NodeKind::Element(line) => { + heads.extend(line.chain.iter()); + if let Some(text) = &line.text { + text.inline_heads(&mut heads); } - out } - Syntax::If { branches } => branches - .first() - .map(|b| format!("@if {}", b.condition.as_deref().unwrap_or(""))) - .unwrap_or_default(), - Syntax::Each { header, .. } => format!("@each {}", header), + NodeKind::Text(text) => text.inline_heads(&mut heads), + _ => {} } + heads } } -/// Parse source text into a syntax tree. -pub(crate) fn parse(input: &str) -> Vec { - let lines = preprocess(input); - let mut pos = 0; - build(&lines, &mut pos, None) -} - -/// Build the nodes for the lines from `pos` indented deeper than `parent`. -fn build(lines: &[Line], pos: &mut usize, parent: Option) -> Vec { - let mut nodes = Vec::new(); - let deeper = |line: &Line| parent.is_none_or(|p| line.indent > p); - while *pos < lines.len() && deeper(&lines[*pos]) { - let line = &lines[*pos]; - *pos += 1; - let text = match &line.content { - LineContent::Raw(text) => { - nodes.push(Syntax::Raw { - line: line.line_num, - text: text.clone(), - }); - continue; - } - LineContent::Normal(text) => text.clone(), - }; - // Text and one-line directives take no body: lines indented under - // them are their siblings. - if !takes_body(&text) { - nodes.push(Syntax::Line { - line: line.line_num, - indent: line.indent, - text, - children: Vec::new(), - }); - continue; - } - let children = build(lines, pos, Some(line.indent)); - - if let Some(condition) = text.strip_prefix("@if ") { - let mut branches = vec![Branch { - line: line.line_num, - condition: Some(condition.trim().to_string()), - body: children, - }]; - while let Some((next_line, next)) = sibling(lines, *pos, line.indent) { - let condition = if let Some(condition) = next.strip_prefix("@else if ") { - Some(condition.trim().to_string()) - } else if next == "@else" { - None - } else { - break; - }; - *pos += 1; - let last = condition.is_none(); - branches.push(Branch { - line: next_line, - condition, - body: build(lines, pos, Some(line.indent)), - }); - if last { - break; +impl Text { + fn inline_heads<'a>(&'a self, out: &mut Vec<&'a Head>) { + for segment in &self.segments { + if let Segment::Inline(inline) = segment { + out.push(&inline.head); + if let Some(text) = &inline.text { + text.inline_heads(out); } } - nodes.push(Syntax::If { branches }); - continue; } + } - if let Some(header) = text.strip_prefix("@each ") { - let mut empty = Vec::new(); - if let Some((_, "@else")) = sibling(lines, *pos, line.indent) { - *pos += 1; - empty = build(lines, pos, Some(line.indent)); - } - nodes.push(Syntax::Each { - line: line.line_num, - header: header.trim().to_string(), - body: children, - empty, - }); - continue; + /// Split off the first word (a URL), with the rest as text: + /// `@link /about About us`. + pub fn split_first_word(&self) -> (String, Option) { + let Some(space) = self.raw.find(char::is_whitespace) else { + return (self.raw.clone(), None); + }; + let word = self.raw[..space].to_string(); + let rest_start = self.raw.len() - self.raw[space..].trim_start().len(); + if rest_start >= self.raw.len() { + return (word, None); + } + let shifted = Shifted { base: self.span }; + let reader = Reader { + text: &self.raw, + spans: &shifted, + }; + (word, Some(reader.text(rest_start, self.raw.len()).0)) + } +} + +impl Tree { + /// Visit every node, in source order. + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { + for node in &self.nodes { + node.walk(f); } + } - if let Some(rest) = text.strip_prefix("@let ") - && !children.is_empty() - && let Some((name, params)) = rest.split_whitespace().collect::>().split_first() - { - let mut names = Vec::new(); - let mut defaults = HashMap::new(); - for param in params { - let param = param.strip_prefix('$').unwrap_or(param); - match param.split_once('=') { - Some((param, default)) => { - names.push(param.to_string()); - defaults.insert(param.to_string(), default.to_string()); - } - None => names.push(param.to_string()), + /// Every `@let` in the file, wherever it is. + pub fn definitions(&self) -> Vec> { + let mut out = Vec::new(); + self.walk(&mut |node| { + let Some(Directive { + args: DirectiveArgs::Let(def), + .. + }) = node.directive() + else { + return; + }; + let (kind, params, value): (_, &[Param], _) = match &def.form { + LetForm::Value(value) => ( + DefinitionKind::Value, + &[], + value.as_ref().map(|v| v.raw.clone()), + ), + LetForm::Quoted(value) => ( + DefinitionKind::Value, + &[], + Some(format!("\"{}\"", value.raw)), + ), + LetForm::Computed(expr) => { + (DefinitionKind::Value, &[], Some(format!("= {}", expr.raw))) } - } - nodes.push(Syntax::Function { - line: line.line_num, - name: name.to_string(), - params: names, - defaults, - body: children, + LetForm::Bundle(attrs) => ( + DefinitionKind::Bundle, + &[], + Some( + attrs + .attrs + .iter() + .map(|a| a.raw.as_str()) + .collect::>() + .join(", "), + ), + ), + LetForm::Function(params) => (DefinitionKind::Function, params.as_slice(), None), + }; + out.push(Definition { + name: &def.name, + name_span: def.name_span, + kind, + params, + value, + node, }); - continue; - } + }); + out + } - nodes.push(Syntax::Line { - line: line.line_num, - indent: line.indent, - text, - children, + /// The innermost node whose own lines include `line` (1-based). + pub fn node_at_line(&self, line: usize) -> Option<&Node> { + let mut found = None; + self.walk(&mut |node| { + let last = node.span.line + node.source_lines() - 1; + if (node.span.line..=last).contains(&line) && !matches!(node.kind, NodeKind::Blank) { + found = Some(node); + } }); + found } - nodes } -/// Whether the lines indented under `text` belong to it. -fn takes_body(text: &str) -> bool { - const ONE_LINE: &[&str] = &["@page", "@meta", "@data", "@include"]; - let name = text.split([' ', '[']).next().unwrap_or(""); - text.starts_with('@') && !ONE_LINE.contains(&name) +/// What a `@let` defines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DefinitionKind { + /// A value (`@let x 1`, `@let x = 1 + 1`, `@let x "text"`), used as `$x`. + Value, + /// An attribute bundle (`@let x [...]`), used as `[$x]`. + Bundle, + /// A function (`@let x $a` with a body), called as `@x`. + Function, +} + +/// A `@let`, for tools. +#[derive(Clone, Debug)] +pub struct Definition<'a> { + pub name: &'a str, + pub name_span: Span, + pub kind: DefinitionKind, + pub params: &'a [Param], + /// The value as written, for a value or a bundle. + pub value: Option, + /// The `@let` line, with the function's body as its children. + pub node: &'a Node, } -/// The line at `pos` if it is a sibling at `indent`, with its text. -fn sibling(lines: &[Line], pos: usize, indent: usize) -> Option<(usize, &str)> { - let line = lines.get(pos).filter(|l| l.indent == indent)?; - match &line.content { - LineContent::Normal(text) => Some((line.line_num, text.trim())), - LineContent::Raw(_) => None, +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse source text into a syntax tree. +pub fn parse(source: &str) -> Tree { + parse_from(source, 0) +} + +/// Parse, numbering the nodes from `first_id`. +pub(crate) fn parse_from(source: &str, first_id: usize) -> Tree { + let lines = physical_lines(source); + let index = LineIndex { + starts: lines.iter().map(|l| l.0).collect(), + }; + let mut diagnostics = Vec::new(); + let entries = scan(&lines, &index, &mut diagnostics); + let mut next_id = first_id; + let mut pos = 0; + let mut nodes = build(entries, &mut pos, None, &mut next_id); + check(&mut nodes, &mut diagnostics); + Tree { + nodes, + diagnostics, + end_id: next_id, } } -pub(crate) fn preprocess(input: &str) -> Vec { - let raw_lines: Vec<&str> = input.lines().collect(); +/// The source's lines with their byte offsets, like `str::lines`. +fn physical_lines(source: &str) -> Vec<(usize, &str)> { let mut lines = Vec::new(); - let mut i = 0; + let mut start = 0; + for piece in source.split('\n') { + lines.push((start, piece.strip_suffix('\r').unwrap_or(piece))); + start += piece.len() + 1; + } + if source.is_empty() || source.ends_with('\n') { + lines.pop(); + } + lines +} - while i < raw_lines.len() { - let line = raw_lines[i]; - let trimmed = line.trim(); +struct LineIndex { + starts: Vec, +} - if trimmed.is_empty() || trimmed.starts_with("--") { +impl LineIndex { + fn span(&self, start: usize, end: usize) -> Span { + let line = self.starts.partition_point(|&s| s <= start).max(1); + Span { + start, + end, + line, + column: start - self.starts.get(line - 1).copied().unwrap_or(0), + } + } +} + +fn leading_whitespace(line: &str) -> usize { + line.len() - line.trim_start().len() +} + +/// A node before the tree is built: one logical line. +struct Entry { + indent: usize, + span: Span, + span_lines: usize, + source: String, + kind: NodeKind, +} + +/// Read the lines into entries: comments, blank lines, headers (with their +/// continuation lines joined) and verbatim bodies. +fn scan( + lines: &[(usize, &str)], + index: &LineIndex, + diagnostics: &mut Vec, +) -> Vec { + let mut entries: Vec = Vec::new(); + let mut i = 0; + while i < lines.len() { + let (start, line) = lines[i]; + let trimmed = line.trim(); + let indent = leading_whitespace(line); + let begin = start + indent; + if trimmed.is_empty() { + entries.push(Entry { + // Resolved below, from the next line + indent: 0, + span: index.span(start, start + line.len()), + span_lines: 1, + source: String::new(), + kind: NodeKind::Blank, + }); i += 1; continue; } - - let indent = line.len() - line.trim_start().len(); - - // The bodies of `@markdown`, `@script`, `@style`, `@head` and `@raw` - // are foreign text, kept verbatim: in Markdown, blank lines separate - // paragraphs and `---` is a rule (not a comment); in CSS, `--name` is - // a custom property; in JavaScript, newlines, `{...}` and `$` must - // reach the output untouched. - let verbatim_body = ["@markdown", "@script", "@style", "@head", "@raw"].contains(&trimmed) - || trimmed.starts_with("@script ") - || trimmed.starts_with("@script[") - || trimmed.starts_with("@head "); - if verbatim_body { - lines.push(Line { + if trimmed.starts_with("--") { + entries.push(Entry { indent, - content: LineContent::Normal(trimmed.to_string()), - line_num: i + 1, + span: index.span(begin, begin + trimmed.len()), + span_lines: 1, + source: trimmed.to_string(), + kind: NodeKind::Comment, }); - let body_start = i + 1; - let mut body_end = body_start; - let mut j = body_start; - while j < raw_lines.len() { - let l = raw_lines[j]; + i += 1; + continue; + } + + // A header, joined with its continuation lines while an attribute + // list (or inline JSON) is open. Blank lines and comments inside it + // are skipped. + let mut logical = Logical::default(); + logical.push(trimmed, begin); + let (mut kind, mut open, mut problems) = logical.header(index); + let mut last = i; + while open && last + 1 < lines.len() { + last += 1; + let (next_start, next) = lines[last]; + let t = next.trim(); + if t.is_empty() || t.starts_with("--") { + continue; + } + logical.push(t, next_start + leading_whitespace(next)); + (kind, open, problems) = logical.header(index); + } + diagnostics.extend(problems); + let end = lines[last].0 + lines[last].1.len(); + let body = match &kind { + NodeKind::Directive(d) => d.spec.body, + NodeKind::Element(line) => line + .chain + .last() + .map_or(BodyKind::Htmlang, |h| ast::body_kind(&h.name)), + _ => BodyKind::Htmlang, + }; + entries.push(Entry { + indent, + span: index.span(begin, end), + span_lines: last - i + 1, + source: logical.text.clone(), + kind, + }); + i = last + 1; + + // A verbatim body: every following line indented deeper (or blank), + // up to the last such non-blank line. + if body == BodyKind::Verbatim { + let mut body_end = i; + let mut k = i; + while k < lines.len() { + let l = lines[k].1; + k += 1; if l.trim().is_empty() { - j += 1; continue; } - if l.len() - l.trim_start().len() <= indent { + if leading_whitespace(l) <= indent { break; } - j += 1; - body_end = j; + body_end = k; } - let body = &raw_lines[body_start..body_end]; - let body_indent = body - .iter() - .filter(|l| !l.trim().is_empty()) - .map(|l| l.len() - l.trim_start().len()) - .min() - .unwrap_or(0); - if !body.is_empty() { + if body_end > i { + let body = &lines[i..body_end]; + let common = body + .iter() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(_, l)| leading_whitespace(l)) + .min() + .unwrap_or(0); let text: Vec<&str> = body .iter() - .map(|l| l.get(body_indent..).unwrap_or("").trim_end()) + .map(|(_, l)| l.get(common..).unwrap_or("").trim_end()) .collect(); - lines.push(Line { + let (last_start, last_line) = lines[body_end - 1]; + entries.push(Entry { indent: indent + 1, - content: LineContent::Raw(text.join("\n")), - line_num: body_start + 1, + span: index.span(lines[i].0, last_start + last_line.len()), + span_lines: body_end - i, + source: String::new(), + kind: NodeKind::Verbatim(Verbatim { + text: text.join("\n"), + }), }); + i = body_end; } - i = body_end.max(body_start); + } + } + + // A blank line belongs where the next line does. + let mut next_indent = 0; + for entry in entries.iter_mut().rev() { + if matches!(entry.kind, NodeKind::Blank) { + entry.indent = next_indent; + } else { + next_indent = entry.indent; + } + } + entries +} + +/// Build the nodes for the entries from `pos` indented deeper than `parent`. +fn build( + entries: Vec, + pos: &mut usize, + parent: Option, + next_id: &mut usize, +) -> Vec { + // Entries are taken out of the vector one by one, in order. + let mut entries: Vec> = entries.into_iter().map(Some).collect(); + build_from(&mut entries, pos, parent, next_id) +} + +fn build_from( + entries: &mut [Option], + pos: &mut usize, + parent: Option, + next_id: &mut usize, +) -> Vec { + let mut nodes = Vec::new(); + while let Some(Some(entry)) = entries.get(*pos) { + if parent.is_some_and(|p| entry.indent <= p) { + break; + } + let Some(entry) = entries[*pos].take() else { + break; + }; + *pos += 1; + let id = *next_id; + *next_id += 1; + let takes_children = !matches!( + entry.kind, + NodeKind::Blank | NodeKind::Comment | NodeKind::Verbatim(_) + ); + let children = if takes_children { + build_from(entries, pos, Some(entry.indent), next_id) + } else { + Vec::new() + }; + nodes.push(Node { + id, + indent: entry.indent, + span: entry.span, + line_count: entry.span_lines, + source: entry.source, + kind: entry.kind, + children, + }); + } + nodes +} + +/// Checks on the built tree: what each line's body may be, and `@else` +/// placement. +fn check(nodes: &mut [Node], diagnostics: &mut Vec) { + #[derive(PartialEq)] + enum Opener { + If, + Each, + } + let mut opener = None; + for node in nodes.iter_mut() { + if node.is_trivia() { continue; } + let line = node.span.line; + match node.directive().map(|d| (d.name(), &d.args)) { + Some(("if", _)) => opener = Some(Opener::If), + Some(("each", _)) => opener = Some(Opener::Each), + Some(("else", args)) => { + let else_if = matches!(args, DirectiveArgs::Else { condition: Some(_) }); + opener = match opener { + Some(Opener::If) if else_if => Some(Opener::If), + Some(Opener::If) | Some(Opener::Each) if !else_if => None, + _ => { + diagnostics.push( + Diagnostic::error( + code::STRAY_ELSE, + line, + "@else without matching @if".to_string(), + ) + .source(node.source.clone()), + ); + None + } + }; + } + _ => opener = None, + } + check_body(node, diagnostics); + check(&mut node.children, diagnostics); + } +} - // Join continuation lines for multi-line attribute brackets - let first_line_num = i + 1; - let mut full = trimmed.to_string(); - while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() { - i += 1; - let next = raw_lines[i].trim(); - if next.is_empty() || next.starts_with("--") { - continue; +/// Whether the lines indented under `node` fit what it takes. +fn check_body(node: &mut Node, diagnostics: &mut Vec) { + let first_child = node.body().next().map(|c| (c.span.line, c.source.clone())); + let has_verbatim = node + .children + .iter() + .any(|c| matches!(c.kind, NodeKind::Verbatim(_))); + let (line, source) = (node.span.line, node.source.clone()); + let NodeKind::Directive(directive) = &mut node.kind else { + return; + }; + let name = directive.spec.name; + let mut problem = |(line, source): (usize, String), message: String| { + diagnostics.push(Diagnostic::error(code::UNEXPECTED_BODY, line, message).source(source)) + }; + match (&mut directive.args, directive.spec.body) { + // A `@let` with a body is a function; its words are parameters. + (DirectiveArgs::Let(def), _) => { + let Some(child) = first_child else { + return; + }; + match &def.form { + LetForm::Value(value) => { + let params = value.as_ref().map_or_else(Vec::new, params); + def.form = LetForm::Function(params); + } + LetForm::Quoted(_) | LetForm::Computed(_) | LetForm::Bundle(_) => problem( + child, + format!( + "`@let {}` defines a value, which takes no indented block \ + (a function is `@let {} $param` with a body)", + def.name, def.name + ), + ), + LetForm::Function(_) => {} + } + } + (_, BodyKind::None) => { + if let Some(child) = first_child { + problem( + child, + format!( + "@{} takes no indented block: this line isn't part of it, so unindent it", + name + ), + ); } - full.push(' '); - full.push_str(next); } + (DirectiveArgs::Text(Some(_)), BodyKind::Verbatim) if has_verbatim => problem( + (line, source), + format!( + "@{} takes its content either on its own line or in an indented block, not both", + name + ), + ), + _ => {} + } +} - lines.push(Line { - indent, - content: LineContent::Normal(full), - line_num: first_line_num, +/// The parameters of `@let name $a $b=default`. +fn params(words: &Arg) -> Vec { + let mut out = Vec::new(); + let mut offset = 0; + for word in words.raw.split_whitespace() { + let at = offset + words.raw[offset..].find(word).unwrap_or(0); + offset = at + word.len(); + let bare = word.strip_prefix('$').unwrap_or(word); + let (name, default) = match bare.split_once('=') { + Some((name, default)) => (name, Some(default.to_string())), + None => (bare, None), + }; + out.push(Param { + name: name.to_string(), + default, + span: Span { + start: words.span.start + at, + end: words.span.start + at + word.len(), + line: words.span.line, + column: words.span.column + at, + }, }); - i += 1; } + out +} - lines +// --------------------------------------------------------------------------- +// Reading one logical line +// --------------------------------------------------------------------------- + +/// Maps positions in a text to source spans. +trait Spans { + fn span(&self, start: usize, end: usize) -> Span; +} + +/// One logical line: physical lines joined with a space, and where each +/// piece came from. +#[derive(Default)] +struct Logical { + text: String, + /// (offset in `text`, offset in the source, length) + pieces: Vec<(usize, usize, usize)>, +} + +impl Logical { + fn push(&mut self, piece: &str, source_offset: usize) { + if !self.text.is_empty() { + self.text.push(' '); + } + self.pieces + .push((self.text.len(), source_offset, piece.len())); + self.text.push_str(piece); + } + + fn source_offset(&self, pos: usize) -> usize { + let i = self.pieces.partition_point(|p| p.0 <= pos).max(1) - 1; + let (at, source, len) = self.pieces[i]; + source + (pos - at).min(len) + } + + /// Parse the line: its kind, whether an attribute list (or inline JSON) + /// is still open at its end, and its syntax errors. + fn header(&self, index: &LineIndex) -> (NodeKind, bool, Vec) { + let spans = LogicalSpans { + logical: self, + index, + }; + let reader = Reader { + text: &self.text, + spans: &spans, + }; + reader.line() + } +} + +struct LogicalSpans<'a> { + logical: &'a Logical, + index: &'a LineIndex, } -/// Bracket depth left open at the end of `line`, counting only attribute -/// lists: a `[` that starts the line or follows an `@name` token (optionally -/// with one more word, as in `@let name [`). Brackets in text content, such -/// as `@text [bold] Use [ to open`, are ignored. -pub(crate) fn open_attr_depth(line: &str) -> i32 { - if !line.starts_with('@') { - // Text: an inline `{@name [` whose list runs onto the next line - let Some(start) = line.rfind("{@") else { - return 0; +impl Spans for LogicalSpans<'_> { + fn span(&self, start: usize, end: usize) -> Span { + let start_src = self.logical.source_offset(start); + let end_src = self.logical.source_offset(end).max(start_src); + self.index.span(start_src, end_src) + } +} + +/// Spans of a single-line text that starts at `base`. +struct Shifted { + base: Span, +} + +impl Spans for Shifted { + fn span(&self, start: usize, end: usize) -> Span { + Span { + start: self.base.start + start, + end: self.base.start + end, + line: self.base.line, + column: self.base.column + start, + } + } +} + +/// The grammar of one logical line. +struct Reader<'a> { + text: &'a str, + spans: &'a dyn Spans, +} + +/// The escape sequences of text: the character(s) after the backslash +/// stand for themselves. +pub(crate) const ESCAPES: &[&str] = &["\\\\", "\\$", "\\{", "\\@", "\\--"]; + +impl Reader<'_> { + fn span(&self, start: usize, end: usize) -> Span { + self.spans.span(start, end) + } + + fn skip_ws(&self, mut pos: usize, limit: usize) -> usize { + while pos < limit { + match self.text[pos..].chars().next() { + Some(c) if c.is_whitespace() => pos += c.len_utf8(), + _ => break, + } + } + pos + } + + /// The end of a name starting at `pos`: the first whitespace or `[`. + fn name_end(&self, pos: usize, limit: usize) -> usize { + self.text[pos..limit] + .find(|c: char| c.is_whitespace() || c == '[') + .map_or(limit, |i| pos + i) + } + + fn arg(&self, start: usize, end: usize) -> Option { + let piece = &self.text[start..end]; + let trimmed = piece.trim(); + if trimmed.is_empty() { + return None; + } + let s = start + (piece.len() - piece.trim_start().len()); + Some(Arg { + raw: trimmed.to_string(), + span: self.span(s, s + trimmed.len()), + }) + } + + fn line(&self) -> (NodeKind, bool, Vec) { + let mut problems = Vec::new(); + let (kind, open) = if self.text.starts_with('@') { + let end = self.name_end(1, self.text.len()); + match ast::directive(&self.text[1..end]) { + Some(spec) => { + let (args, open) = self.directive_args(spec, end, &mut problems); + let directive = Directive { + spec, + name_span: self.span(0, end), + args, + }; + (NodeKind::Directive(directive), open) + } + None => { + let (line, open) = self.element_line(); + (NodeKind::Element(line), open) + } + } + } else { + let (text, open) = self.text(0, self.text.len()); + (NodeKind::Text(text), open) + }; + if open { + problems.push(self.error( + code::UNCLOSED_BRACKET, + "unclosed '[' in attribute list".to_string(), + )); + } + (kind, open, problems) + } + + fn error(&self, code: &'static str, message: String) -> Diagnostic { + Diagnostic::error(code, self.span(0, 0).line, message).source(self.text.to_string()) + } + + /// `@name [attrs] > @name [attrs] text`. + fn element_line(&self) -> (ElementLine, bool) { + let len = self.text.len(); + let mut chain = Vec::new(); + let mut pos = 0; + loop { + let (head, end) = self.head(pos, len); + let unclosed = head.attrs.as_ref().is_some_and(|a| !a.closed); + chain.push(head); + if unclosed { + return (ElementLine { chain, text: None }, true); + } + let after = self.skip_ws(end, len); + if let Some(rest) = self.text[after..].strip_prefix('>') { + let next = self.skip_ws(after + 1, len); + if next > after + 1 && rest.trim_start().starts_with('@') { + pos = next; + continue; + } + } + let (text, open) = if after < len { + let (text, open) = self.text(after, len); + (Some(text), open) + } else { + (None, false) + }; + return (ElementLine { chain, text }, open); + } + } + + /// `@name [attrs]` at `pos`, and where it ends. + fn head(&self, pos: usize, limit: usize) -> (Head, usize) { + let name_end = self.name_end(pos + 1, limit); + let name = self.text[pos + 1..name_end].to_string(); + let bracket = self.skip_ws(name_end, limit); + let (attrs, end) = if self.text[bracket..limit].starts_with('[') { + let list = self.attr_list(bracket, limit); + let end = list.1; + (Some(list.0), end) + } else { + (None, name_end) + }; + let head = Head { + name, + name_span: self.span(pos, name_end), + attrs, + span: self.span(pos, end), + }; + (head, end) + } + + /// `[attr, attr]` starting at `open`, and where it ends. Brackets + /// inside `"..."` don't count. + fn attr_list(&self, open: usize, limit: usize) -> (AttrList, usize) { + let mut depth = 0; + let mut quoted = false; + let mut escaped = false; + let mut close = None; + for (i, c) in self.text[open..limit].char_indices() { + if quoted { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => quoted = false, + _ => {} + } + continue; + } + match c { + '"' => quoted = true, + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + close = Some(open + i); + break; + } + } + _ => {} + } + } + let inner_end = close.unwrap_or(limit); + let attrs = self + .split_commas(open + 1, inner_end) + .into_iter() + .filter_map(|(s, e)| self.attr(s, e)) + .collect(); + let end = close.map_or(limit, |c| c + 1); + let list = AttrList { + attrs, + span: self.span(open, end), + closed: close.is_some(), + }; + (list, end) + } + + /// Ranges between the commas that aren't inside `(...)`, `[...]`, + /// `{...}` or `"..."`. + fn split_commas(&self, start: usize, end: usize) -> Vec<(usize, usize)> { + let mut parts = Vec::new(); + let mut from = start; + let mut depth = 0i32; + let mut quoted = false; + for (i, c) in self.text[start..end].char_indices() { + match c { + '"' => quoted = !quoted, + _ if quoted => {} + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + ',' if depth <= 0 => { + parts.push((from, start + i)); + from = start + i + 1; + } + _ => {} + } + } + parts.push((from, end)); + parts + } + + fn attr(&self, start: usize, end: usize) -> Option { + let arg = self.arg(start, end)?; + let (key, value, html) = split_attribute(&arg.raw); + Some(Attr { + key, + value, + html, + raw: arg.raw, + span: arg.span, + }) + } + + /// Text from `start` to `end` (trimmed), and whether an inline + /// element's attribute list is still open at its end. + fn text(&self, start: usize, end: usize) -> (Text, bool) { + let piece = &self.text[start..end]; + let s = start + (piece.len() - piece.trim_start().len()); + let e = s + piece.trim().len(); + let mut segments = Vec::new(); + let mut open = false; + let mut plain = s; + let mut i = s; + let flush = |segments: &mut Vec, from: usize, to: usize| { + if to > from { + segments.push(Segment::Plain { + raw: self.text[from..to].to_string(), + span: self.span(from, to), + }); + } + }; + while i < e { + let rest = &self.text[i..e]; + if rest.starts_with('\\') { + i += ESCAPES + .iter() + .find(|esc| rest.starts_with(**esc)) + .map_or(1, |esc| esc.len()); + continue; + } + if rest.starts_with("{@") { + flush(&mut segments, plain, i); + let (inline, inline_open, next) = self.inline(i, e); + open |= inline_open; + segments.push(Segment::Inline(Box::new(inline))); + i = next; + plain = i; + continue; + } + i += rest.chars().next().map_or(1, char::len_utf8); + } + flush(&mut segments, plain, e); + let text = Text { + raw: self.text[s..e].to_string(), + span: self.span(s, e), + segments, }; - let inline = &line[start + 1..]; - return if inline.contains('}') { - 0 + (text, open) + } + + /// `{@name [attrs] text}` at `start`: the element, whether its + /// attribute list is open at `limit`, and where it ends. + fn inline(&self, start: usize, limit: usize) -> (Inline, bool, usize) { + let mut depth = 0; + let mut close = None; + let mut i = start + 1; + while i < limit { + let rest = &self.text[i..limit]; + if rest.starts_with('\\') { + i += ESCAPES + .iter() + .find(|esc| rest.starts_with(**esc)) + .map_or(1, |esc| esc.len()); + continue; + } + match rest.chars().next() { + Some('{') => depth += 1, + Some('}') if depth == 0 => { + close = Some(i); + break; + } + Some('}') => depth -= 1, + _ => {} + } + i += rest.chars().next().map_or(1, char::len_utf8); + } + let inner_end = close.unwrap_or(limit); + let (head, head_end) = self.head(start + 1, inner_end); + let attrs_open = head.attrs.as_ref().is_some_and(|a| !a.closed); + let after = self.skip_ws(head_end, inner_end); + let (text, text_open) = if after < inner_end { + let (text, open) = self.text(after, inner_end); + (Some(text), open) } else { - open_attr_depth(inline) + (None, false) }; + let end = close.map_or(limit, |c| c + 1); + let inline = Inline { + raw: self.text[start + 1..inner_end].to_string(), + span: self.span(start, end), + head, + text, + closed: close.is_some(), + }; + (inline, close.is_none() && (attrs_open || text_open), end) } - let bytes = line.as_bytes(); - let mut depth: i32 = 0; - for (i, &b) in bytes.iter().enumerate() { - match b { - b'[' if depth > 0 => depth += 1, - b'[' => { - // Is this the start of an attribute list? - let before = line[..i].trim_end(); - let last_directive = before.rsplit([']', '>']).next().unwrap_or("").trim(); - let tokens: Vec<&str> = last_directive.split_whitespace().collect(); - let starts_list = match tokens.as_slice() { - [] => before.is_empty() || before.ends_with('>'), - [name] => name.starts_with('@'), - [name, _] => name.starts_with('@'), - _ => false, + + /// A directive's arguments, by its grammar, and whether they continue + /// onto the next line. + fn directive_args( + &self, + spec: &'static DirectiveSpec, + name_end: usize, + problems: &mut Vec, + ) -> (DirectiveArgs, bool) { + let len = self.text.len(); + let rest = self.text[name_end..].trim(); + let name = spec.name; + let mut missing = |what: &str| { + problems.push(self.error(code::MISSING_ARGUMENT, format!("@{} needs {}", name, what))); + (DirectiveArgs::Invalid, false) + }; + match spec.args { + ArgGrammar::None => { + if !rest.is_empty() { + problems.push(self.error( + code::UNEXPECTED_ARGUMENT, + format!( + "@{} takes nothing on its line: its content is the indented block under it", + name + ), + )); + } + (DirectiveArgs::None, false) + } + ArgGrammar::Text => (DirectiveArgs::Text(self.arg(name_end, len)), false), + ArgGrammar::AttrsText => { + let bracket = self.skip_ws(name_end, len); + let (attrs, after) = if self.text[bracket..].starts_with('[') { + let (list, end) = self.attr_list(bracket, len); + (Some(list), end) + } else { + (None, name_end) }; - if starts_list { - depth = 1; - } else if before.ends_with(']') { - // Text after a closed attribute list: stop scanning. - return 0; + let open = attrs.as_ref().is_some_and(|a| !a.closed); + let title = if open { None } else { self.arg(after, len) }; + (DirectiveArgs::Page { attrs, title }, open) + } + ArgGrammar::Expression => match self.arg(name_end, len) { + Some(condition) => (DirectiveArgs::Condition(condition), false), + None => missing("a condition"), + }, + ArgGrammar::Else => { + if rest.is_empty() { + return (DirectiveArgs::Else { condition: None }, false); + } + let at = self.skip_ws(name_end, len); + let after_if = at + 2; + let is_if = self.text[at..].starts_with("if") + && self.text[after_if..] + .chars() + .next() + .is_none_or(char::is_whitespace); + if !is_if { + problems.push( + self.error( + code::UNEXPECTED_ARGUMENT, + "@else takes nothing on its line (a condition is `@else if COND`)" + .to_string(), + ), + ); + return (DirectiveArgs::Else { condition: None }, false); + } + match self.arg(after_if, len) { + Some(condition) => ( + DirectiveArgs::Else { + condition: Some(condition), + }, + false, + ), + None => { + problems.push(self.error( + code::MISSING_ARGUMENT, + "@else if needs a condition".to_string(), + )); + ( + DirectiveArgs::Else { + condition: Some(Arg { + raw: String::new(), + span: self.span(after_if, after_if), + }), + }, + false, + ) + } } } - b']' if depth > 0 => depth -= 1, + ArgGrammar::Loop => { + let Some((names, list)) = rest.split_once(" in ") else { + problems.push(self.error( + code::INVALID_LOOP, + "@each requires: @each $item in LIST".to_string(), + )); + return (DirectiveArgs::Invalid, false); + }; + let names: Vec<&str> = names + .split(',') + .map(|v| v.trim().trim_start_matches('$')) + .collect(); + if names.len() > 2 { + problems.push( + self.error( + code::INVALID_LOOP, + "@each takes `$item` or `$item, $index`: to loop over records, \ + load them with @data and use `$item.key`" + .to_string(), + ), + ); + return (DirectiveArgs::Invalid, false); + } + let list_start = len - list.trim_start().len(); + let list = Arg { + raw: list.trim().to_string(), + span: self.span(list_start, len), + }; + let each = Loop { + item: names[0].to_string(), + index: names.get(1).map(|s| s.to_string()), + list, + }; + (DirectiveArgs::Each(each), false) + } + ArgGrammar::Data => { + let at = self.skip_ws(name_end, len); + let named = self.text[at..].strip_prefix('$').and_then(|named| { + let (name, source) = named.split_once(char::is_whitespace)?; + Some((name, source.trim())) + }); + let Some((data_name, source)) = named.filter(|(_, source)| !source.is_empty()) + else { + problems.push(self.error( + code::MISSING_ARGUMENT, + format!( + "@data needs a name: write `@data $name {}` and use `$name.key`", + rest.trim_start_matches('$') + ), + )); + return (DirectiveArgs::Invalid, false); + }; + let name_start = at + 1; + let source_start = len - source.len(); + let open = source.starts_with(['[', '{']) && !json_balanced(source); + let args = DirectiveArgs::Data { + name: data_name.to_string(), + name_span: self.span(name_start, name_start + data_name.len()), + source: Arg { + raw: source.to_string(), + span: self.span(source_start, len), + }, + }; + (args, open) + } + ArgGrammar::Definition => { + let at = self.skip_ws(name_end, len); + if at >= len { + return missing("a name: `@let name value`"); + } + let def_name_end = self.text[at..] + .find(char::is_whitespace) + .map_or(len, |i| at + i); + let def_name = self.text[at..def_name_end].to_string(); + let value_at = self.skip_ws(def_name_end, len); + let value = &self.text[value_at..]; + let mut open = false; + let form = if value.is_empty() { + LetForm::Value(None) + } else if let Some(expr) = value.strip_prefix('=') { + let expr_at = len - expr.trim_start().len(); + LetForm::Computed(Arg { + raw: expr.trim().to_string(), + span: self.span(expr_at, len), + }) + } else if value.starts_with('[') { + let (list, _) = self.attr_list(value_at, len); + open = !list.closed; + LetForm::Bundle(list) + } else if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + LetForm::Quoted(Arg { + raw: value[1..value.len() - 1].to_string(), + span: self.span(value_at + 1, len - 1), + }) + } else { + LetForm::Value(self.arg(value_at, len)) + }; + let def = LetDef { + name: def_name, + name_span: self.span(at, def_name_end), + form, + }; + (DirectiveArgs::Let(def), open) + } + } + } +} + +/// An attribute's key, value, and whether it is written `key=value`: +/// `padding 20`, `type=email`, `required`. +fn split_attribute(raw: &str) -> (String, Option, bool) { + if let Some((key, value)) = split_html_attribute(raw) { + return (key.to_string(), Some(value.to_string()), true); + } + match raw.split_once(' ') { + Some((key, value)) => ( + key.trim().to_string(), + Some(value.trim().to_string()), + false, + ), + None => (raw.to_string(), None, false), + } +} + +/// Split an HTML attribute written `key=value` (`alt=`, `type=email`, +/// `aria-label=Close menu`). Returns `None` for style attributes. +pub(crate) fn split_html_attribute(part: &str) -> Option<(&str, &str)> { + let first_token = part.split(char::is_whitespace).next()?; + let eq = first_token.find('=')?; + let key = &part[..eq]; + let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic()) + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + valid_key.then(|| (key, part[eq + 1..].trim())) +} + +/// Whether the brackets and braces of inline JSON are balanced. +fn json_balanced(json: &str) -> bool { + let mut depth = 0i32; + let mut in_string = false; + let mut escaped = false; + for c in json.chars() { + if in_string { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + continue; + } + match c { + '"' => in_string = true, + '[' | '{' => depth += 1, + ']' | '}' => depth -= 1, _ => {} } } - depth + depth <= 0 } #[cfg(test)] mod tests { use super::*; - fn kinds(nodes: &[Syntax]) -> Vec<&'static str> { + fn kinds(nodes: &[Node]) -> Vec<&'static str> { nodes .iter() - .map(|n| match n { - Syntax::Raw { .. } => "raw", - Syntax::Line { .. } => "line", - Syntax::Function { .. } => "function", - Syntax::If { .. } => "if", - Syntax::Each { .. } => "each", + .map(|n| match &n.kind { + NodeKind::Blank => "blank", + NodeKind::Comment => "comment", + NodeKind::Text(_) => "text", + NodeKind::Element(_) => "element", + NodeKind::Directive(d) => d.name(), + NodeKind::Verbatim(_) => "verbatim", }) .collect() } + fn codes(tree: &Tree) -> Vec<&'static str> { + tree.diagnostics.iter().map(|d| d.code).collect() + } + + #[test] + fn lines_become_nodes_with_trivia() { + let tree = parse("-- intro\n@el [padding 4]\n Hello\n\n @text x\n@text y\n"); + assert_eq!(kinds(&tree.nodes), ["comment", "element", "element"]); + assert_eq!(kinds(&tree.nodes[1].children), ["text", "blank", "element"]); + assert!(tree.diagnostics.is_empty()); + } + + #[test] + fn heads_attributes_and_spans() { + let src = "@row [spacing 8, id=main] > @link [color red] /home Home\n"; + let tree = parse(src); + let NodeKind::Element(line) = &tree.nodes[0].kind else { + panic!() + }; + let names: Vec<_> = line.chain.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["row", "link"]); + let attrs = &line.chain[0].attrs.as_ref().unwrap().attrs; + assert_eq!(attrs[0].key, "spacing"); + assert_eq!(attrs[0].value.as_deref(), Some("8")); + assert!(attrs[1].html); + assert_eq!(&src[attrs[1].span.start..attrs[1].span.end], "id=main"); + assert_eq!(attrs[1].span.column, 17); + assert_eq!(line.text.as_ref().unwrap().raw, "/home Home"); + } + + #[test] + fn a_bracket_opens_a_list_only_after_a_head() { + let tree = parse("@text Use [ to open\n@text next\n"); + assert_eq!(kinds(&tree.nodes), ["element", "element"]); + assert!(tree.diagnostics.is_empty()); + let tree = parse("@el [\n padding 4,\n -- note\n color red\n]\n child\n"); + assert_eq!(kinds(&tree.nodes), ["element"]); + let NodeKind::Element(line) = &tree.nodes[0].kind else { + panic!() + }; + let list = line.chain[0].attrs.as_ref().unwrap(); + assert_eq!(list.attrs.len(), 2); + assert_eq!(list.attrs[1].span.line, 4); + assert_eq!(kinds(&tree.nodes[0].children), ["text"]); + } + + #[test] + fn a_chain_needs_complete_heads() { + let tree = parse("@text Ask me > @support\n"); + let NodeKind::Element(line) = &tree.nodes[0].kind else { + panic!() + }; + assert_eq!(line.chain.len(), 1); + assert_eq!(line.text.as_ref().unwrap().raw, "Ask me > @support"); + } + #[test] - fn groups_control_flow_and_definitions() { + fn inline_elements_and_escapes() { + let tree = parse("Read {@link /docs the {@b docs}} \\{@not} now\n"); + let NodeKind::Text(text) = &tree.nodes[0].kind else { + panic!() + }; + assert_eq!(text.segments.len(), 3); + let Segment::Inline(inline) = &text.segments[1] else { + panic!() + }; + assert_eq!(inline.head.name, "link"); + assert_eq!(inline.text.as_ref().unwrap().segments.len(), 2); + } + + #[test] + fn inline_attribute_lists_continue() { + let tree = parse("Press {@kbd [font-weight bold,\n color red] K} to go\n@text x\n"); + assert_eq!(kinds(&tree.nodes), ["text", "element"]); + assert!(tree.diagnostics.is_empty()); + } + + #[test] + fn directives_follow_the_table() { let tree = parse( - "@let card $title $icon=x\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n@text done\n", + "@let card $title $icon=x\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n", ); - assert_eq!(kinds(&tree), ["function", "if", "each", "line"]); - let Syntax::Function { - name, - params, - defaults, - body, - .. - } = &tree[0] - else { - unreachable!() - }; assert_eq!( - (name.as_str(), params.len(), defaults["icon"].as_str()), - ("card", 2, "x") + kinds(&tree.nodes), + ["let", "if", "else", "else", "each", "else"] ); - assert_eq!(kinds(body), ["line"]); - let Syntax::If { branches } = &tree[1] else { - unreachable!() + let defs = tree.definitions(); + assert_eq!(defs[0].kind, DefinitionKind::Function); + assert_eq!(defs[0].params[1].default.as_deref(), Some("x")); + assert!(tree.diagnostics.is_empty(), "{:?}", tree.diagnostics); + } + + #[test] + fn verbatim_bodies() { + let tree = parse("@style\n :root {\n --brand: red;\n }\n\n@raw
\n@text x\n"); + assert_eq!(kinds(&tree.nodes), ["style", "blank", "raw", "element"]); + let [child] = tree.nodes[0].children.as_slice() else { + panic!() }; - let conditions: Vec<_> = branches.iter().map(|b| b.condition.as_deref()).collect(); - assert_eq!(conditions, [Some("$a"), Some("$b"), None]); - let Syntax::Each { empty, .. } = &tree[2] else { - unreachable!() + let NodeKind::Verbatim(body) = &child.kind else { + panic!() }; - assert_eq!(kinds(empty), ["line"]); + assert_eq!(body.text, ":root {\n --brand: red;\n}"); } #[test] - fn text_bodies_are_verbatim() { - let tree = parse("@style\n :root {\n --brand: red;\n }\n@text x\n"); - let Syntax::Line { children, .. } = &tree[0] else { - unreachable!() - }; - let [Syntax::Raw { text, .. }] = children.as_slice() else { - panic!("{:?}", children) + fn multi_line_inline_json() { + let tree = parse("@data $x {\n \"a\": [1, 2],\n \"b\": \"]\"\n}\n@text $x.a\n"); + assert_eq!(kinds(&tree.nodes), ["data", "element"]); + let Some(Directive { + args: DirectiveArgs::Data { source, .. }, + .. + }) = tree.nodes[0].directive() + else { + panic!() }; - assert_eq!(text, ":root {\n --brand: red;\n}"); - assert_eq!(kinds(&tree), ["line", "line"]); + assert!(source.raw.ends_with('}')); + } + + #[test] + fn body_kinds_are_checked() { + let tree = parse("@page Home\n @text x\n@meta a b\n-- fine\n"); + assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]); + assert_eq!(tree.diagnostics[0].line, 2); + let tree = parse("@let x = 1\n @text y\n"); + assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]); + let tree = parse("@raw
\n
\n"); + assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]); + let tree = parse("@style x\n a {}\n"); + assert_eq!(codes(&tree), [code::UNEXPECTED_ARGUMENT]); + } + + #[test] + fn syntax_errors() { + assert_eq!(codes(&parse("@else\n x\n")), [code::STRAY_ELSE]); + assert_eq!(codes(&parse("@if\n x\n")), [code::MISSING_ARGUMENT]); + assert_eq!(codes(&parse("@each $x\n x\n")), [code::INVALID_LOOP]); + assert_eq!( + codes(&parse("@el [padding 4\n@text x\n")), + [code::UNCLOSED_BRACKET] + ); + assert_eq!(codes(&parse("@data x.json\n")), [code::MISSING_ARGUMENT]); + } + + #[test] + fn node_lookup_and_extent() { + let tree = parse("@el [\n padding 4\n]\n @text a\n\n@text b\n"); + assert_eq!(tree.nodes[0].end_line(), 4); + assert_eq!(tree.node_at_line(2).map(|n| n.span.line), Some(1)); + assert_eq!(tree.node_at_line(4).map(|n| n.span.line), Some(4)); } } diff --git a/editors/vscode/README.md b/editors/vscode/README.md index bd0a1fb..adb7f0e 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -8,9 +8,11 @@ Syntax highlighting, snippets, and language server support for - **Syntax highlighting**: `@` elements and directives, `$variables`, `[attribute]` lists with state and media prefixes, `key=value` HTML attributes, `{@inline}` elements, colors, numbers and `-- comments`. -- **Diagnostics**: parse errors, unknown elements and attributes with "did - you mean" suggestions, undefined and unused definitions, and accessibility - warnings. +- **Diagnostics**: the compiler's own, each with its code: parse errors, + unknown elements and attributes with "did you mean" suggestions, undefined + and unused definitions, and accessibility warnings. The whole file is + checked, including branches and functions that don't run, and relative + `@include` and `@data` paths resolve from the file's folder. - **Completion** of elements, directives, layout attributes, CSS properties, HTML attributes, prefixes, variables and functions. It triggers on `@`, `$`, `[` and `,`. @@ -18,9 +20,12 @@ Syntax highlighting, snippets, and language server support for properties link to MDN), function signatures, variable values, and color swatches. - **Navigation**: go to definition, find references and rename for - `$variables`, bundles and `@let` functions. `@include` paths are links. -- **Code actions**: fixes for common diagnostics, removing unused definitions, - and extracting a selection into a `@let` function or attribute bundle. + `$variables`, bundles and `@let` functions. `@include`, `@markdown` and + `@data` file paths are links. +- **Code actions**: fixes keyed on diagnostic codes (replace a misspelled + name, add a missing `alt` or `type`, include the file that defines an + unknown function), removing unused definitions, and extracting a selection + into a `@let` function or attribute bundle. - **Outline and symbols**: the document outline, and `Ctrl-T` workspace search across every `.hl` file. - **Formatting**: `Format Document` and `Format Selection` use the same diff --git a/editors/vscode/syntaxes/htmlang.tmLanguage.json b/editors/vscode/syntaxes/htmlang.tmLanguage.json index d23bcf8..1625189 100644 --- a/editors/vscode/syntaxes/htmlang.tmLanguage.json +++ b/editors/vscode/syntaxes/htmlang.tmLanguage.json @@ -15,6 +15,9 @@ { "include": "#include-directive" }, + { + "include": "#directive" + }, { "include": "#element" }, @@ -75,9 +78,13 @@ } } }, + "directive": { + "name": "keyword.control.directive.htmlang", + "match": "@(?:page|let|include|data|meta|if|else|each|style|head|raw|markdown)(?![a-zA-Z0-9_-])" + }, "element": { "name": "entity.name.tag.htmlang", - "match": "@(?:row|el|text|paragraph|image|link|raw|children|slot|fragment|script|nav|header|footer|main|section|article|aside|address|search|form|details|summary|dialog|figure|figcaption|blockquote|cite|fieldset|legend|noscript|datalist|ul|ol|li|dl|dt|dd|table|thead|tbody|tr|td|th|select|picture|progress|meter|output|canvas|button|label|option|textarea|time|mark|abbr|code|kbd|pre|h1|h2|h3|h4|h5|h6|input|hr|source|video|audio|iframe|grid|in-front|behind)(?![a-zA-Z0-9_-])" + "match": "@(?:row|el|text|paragraph|image|link|children|slot|fragment|script|nav|header|footer|main|section|article|aside|address|search|form|details|summary|dialog|figure|figcaption|blockquote|cite|fieldset|legend|noscript|datalist|ul|ol|li|dl|dt|dd|table|thead|tbody|tr|td|th|select|picture|progress|meter|output|canvas|button|label|option|textarea|time|mark|abbr|code|kbd|pre|h1|h2|h3|h4|h5|h6|input|hr|source|video|audio|iframe|grid|in-front|behind)(?![a-zA-Z0-9_-])" }, "function-call": { "name": "entity.name.function.htmlang", diff --git a/src/bin/htmlang_lsp/analysis.rs b/src/bin/htmlang_lsp/analysis.rs index 13fa574..276fd59 100644 --- a/src/bin/htmlang_lsp/analysis.rs +++ b/src/bin/htmlang_lsp/analysis.rs @@ -1,9 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use htmlang::diagnostic::code; use htmlang::parser::ParseResult; +use htmlang::syntax::{self, DefinitionKind, NodeKind, Tree}; use tower_lsp::lsp_types::*; use crate::completion::in_brackets; +use crate::tree::{self as lsp_tree, Def}; // --------------------------------------------------------------------------- // Document symbols (outline view) @@ -11,658 +14,447 @@ use crate::completion::in_brackets; #[allow(deprecated)] // SymbolInformation::deprecated is deprecated but needed for the struct pub(crate) fn document_symbols(text: &str) -> Vec { - let mut symbols = Vec::new(); - - for (i, line) in text.lines().enumerate() { - let trimmed = line.trim(); - let line_num = i as u32; - - // @let definitions (variables, attribute bundles, and components) - if let Some(rest) = trimmed.strip_prefix("@let ") { - let rest_trimmed = rest.trim(); - let parts: Vec<&str> = rest_trimmed.split_whitespace().collect(); - if let Some(&name) = parts.first() { - let value_part = rest_trimmed.get(name.len()..).unwrap_or("").trim_start(); - let has_body = text - .lines() - .nth(i + 1) - .map(|l| l.starts_with(" ") || l.starts_with('\t')) - .unwrap_or(false); + let lines: Vec<&str> = text.lines().collect(); + let whole_line = |line: u32| { + let len = lines.get(line as usize).map_or(0, |l| l.len()) as u32; + Range::new(Position::new(line, 0), Position::new(line, len)) + }; + let symbol = |name: String, kind, line, detail| SymbolInformation { + name, + kind, + tags: None, + deprecated: None, + location: Location { + uri: Url::parse("file:///").unwrap(), // replaced by caller + range: whole_line(line), + }, + container_name: detail, + }; - if has_body && (value_part.is_empty() || value_part.starts_with('$')) { - // Component/function definition - let params = parts[1..].join(" "); - let detail = if params.is_empty() { - None - } else { - Some(format!("({})", params)) - }; - symbols.push(SymbolInformation { - name: format!("@{}", name), - kind: SymbolKind::FUNCTION, - tags: None, - deprecated: None, - location: Location { - uri: Url::parse("file:///").unwrap(), // replaced by caller - range: Range::new( - Position::new(line_num, 0), - Position::new(line_num, line.len() as u32), - ), - }, - container_name: detail, - }); - } else if value_part.starts_with('[') { - // Attribute bundle - symbols.push(SymbolInformation { - name: format!("${}", name), - kind: SymbolKind::CONSTANT, - tags: None, - deprecated: None, - location: Location { - uri: Url::parse("file:///").unwrap(), - range: Range::new( - Position::new(line_num, 0), - Position::new(line_num, line.len() as u32), - ), - }, - container_name: Some("attribute bundle".to_string()), - }); - } else if !value_part.is_empty() { - // Scalar variable - symbols.push(SymbolInformation { - name: format!("${}", name), - kind: SymbolKind::VARIABLE, - tags: None, - deprecated: None, - location: Location { - uri: Url::parse("file:///").unwrap(), - range: Range::new( - Position::new(line_num, 0), - Position::new(line_num, line.len() as u32), - ), - }, - container_name: Some(format!("= {}", value_part)), - }); + let mut symbols = Vec::new(); + // @let definitions (variables, attribute bundles, and functions) + for def in lsp_tree::definitions(text) { + match def.kind { + DefinitionKind::Function => { + let params: Vec = def.params.iter().map(param_label).collect(); + let detail = (!params.is_empty()).then(|| format!("({})", params.join(" "))); + symbols.push(symbol( + format!("@{}", def.name), + SymbolKind::FUNCTION, + def.line, + detail, + )); + } + DefinitionKind::Bundle => symbols.push(symbol( + format!("${}", def.name), + SymbolKind::CONSTANT, + def.line, + Some("attribute bundle".to_string()), + )), + DefinitionKind::Value => { + if let Some(value) = def.value.filter(|v| !v.is_empty()) { + symbols.push(symbol( + format!("${}", def.name), + SymbolKind::VARIABLE, + def.line, + Some(format!("= {}", value.trim_start_matches("= "))), + )); } } } + } - // @keyframes rules (in @style blocks) - if let Some(rest) = trimmed.strip_prefix("@keyframes ") { + // @keyframes rules, in @style bodies + let tree = syntax::parse(text); + let verbatim = lsp_tree::verbatim_lines(&tree); + for (i, line) in lines.iter().enumerate() { + if !verbatim.contains(&(i as u32)) { + continue; + } + if let Some(rest) = line.trim().strip_prefix("@keyframes ") { let name = rest.split(['{', ' ']).next().unwrap_or(""); if !name.is_empty() { - symbols.push(SymbolInformation { - name: format!("@keyframes {}", name), - kind: SymbolKind::EVENT, - tags: None, - deprecated: None, - location: Location { - uri: Url::parse("file:///").unwrap(), - range: Range::new( - Position::new(line_num, 0), - Position::new(line_num, line.len() as u32), - ), - }, - container_name: Some("animation".to_string()), - }); + symbols.push(symbol( + format!("@keyframes {}", name), + SymbolKind::EVENT, + i as u32, + Some("animation".to_string()), + )); } } } - + symbols.sort_by_key(|s| s.location.range.start.line); symbols } +/// A parameter as written: `$tone` or `$tone=info`. +fn param_label(param: &lsp_tree::Param) -> String { + match ¶m.default { + Some(default) => format!("${}={}", param.name, default), + None => format!("${}", param.name), + } +} + // --------------------------------------------------------------------------- -// Code actions (quick-fixes for typo suggestions) +// Code actions: quick fixes keyed on diagnostic codes, and refactorings // --------------------------------------------------------------------------- +/// The compiler's code for an LSP diagnostic. +fn diagnostic_code(diag: &Diagnostic) -> Option<&str> { + match &diag.code { + Some(NumberOrString::String(code)) => Some(code), + _ => None, + } +} + +/// A field of the diagnostic's data: `subject` (what it is about) or +/// `suggestion` (what to write instead). +fn diagnostic_field<'a>(diag: &'a Diagnostic, key: &str) -> Option<&'a str> { + diag.data.as_ref()?.get(key)?.as_str() +} + +fn quick_fix( + title: String, + diag: &Diagnostic, + uri: &Url, + edits: Vec, +) -> CodeActionOrCommand { + let mut changes = HashMap::new(); + changes.insert(uri.clone(), edits); + CodeActionOrCommand::CodeAction(CodeAction { + title, + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + edit: Some(WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }), + ..Default::default() + }) +} + +fn insert(line: u32, column: usize, text: &str) -> TextEdit { + let at = Position::new(line, column as u32); + TextEdit { + range: Range::new(at, at), + new_text: text.to_string(), + } +} + +/// Where `word` appears in `line` as a whole name. +fn find_word(line: &str, word: &str) -> Option { + let is_name = |c: char| c.is_alphanumeric() || c == '-' || c == '_'; + line.match_indices(word).map(|(at, _)| at).find(|&at| { + let before = line[..at].chars().next_back(); + let after = line[at + word.len()..].chars().next(); + // A `@name` or `$name` starts with its sigil; a bare word must not + // be the tail of a longer name. + let starts = word.starts_with(['@', '$']) || !before.is_some_and(is_name); + starts && !after.is_some_and(is_name) + }) +} + pub(crate) fn code_actions( text: &str, + tree: &Tree, selection: &Range, diagnostics: &[Diagnostic], uri: &Url, ) -> Vec { let mut actions = Vec::new(); + let lines: Vec<&str> = text.lines().collect(); + let defs = lsp_tree::definitions(text); for diag in diagnostics { - let msg = &diag.message; - - // Extract "did you mean 'X'?" or "did you mean @X?" suggestions - if let Some(suggestion) = extract_suggestion(msg) { - let line = diag.range.start.line as usize; - let lines: Vec<&str> = text.lines().collect(); - if let Some(source_line) = lines.get(line) { - // Determine what to replace - let (old_text, new_text) = if msg.contains("unknown element") { - // Replace @wrong with @suggestion - let old = extract_between(msg, "unknown element @", ",") - .or_else(|| extract_between(msg, "unknown element @", "")); - if let Some(old) = old { - (format!("@{}", old), format!("@{}", suggestion)) - } else { - continue; - } - } else if msg.contains("unknown attribute") { - // Replace wrong with suggestion in attribute list - let old = extract_between(msg, "unknown attribute '", "'"); - if let Some(old) = old { - (old.to_string(), suggestion.to_string()) - } else { - continue; - } - } else { + let Some(code) = diagnostic_code(diag) else { + continue; + }; + let line = diag.range.start.line; + let source_line = lines.get(line as usize).copied().unwrap_or(""); + let subject = diagnostic_field(diag, "subject"); + let suggestion = diagnostic_field(diag, "suggestion"); + + match code { + // Replace a misspelled name with the closest known one. + code::UNKNOWN_ELEMENT + | code::UNKNOWN_ATTRIBUTE + | code::UNKNOWN_COLOR + | code::UNDEFINED_VARIABLE => { + let (Some(subject), Some(suggestion)) = (subject, suggestion) else { continue; }; - - if let Some(col) = source_line.find(&old_text) { - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, col as u32), - Position::new(diag.range.start.line, (col + old_text.len()) as u32), - ), - new_text: new_text.clone(), - }; - - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: format!("Replace with '{}'", new_text), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } - } - } - - // Quick-fix: remove unused @let variable - if msg.contains("unused variable") { - let line = diag.range.start.line as usize; - let lines: Vec<&str> = text.lines().collect(); - if let Some(source_line) = lines.get(line) { - let trimmed = source_line.trim_start(); - if trimmed.starts_with("@let ") { - let var_name = trimmed - .strip_prefix("@let ") - .and_then(|r| r.split_whitespace().next()) - .unwrap_or("?"); + let sigil = match code { + code::UNKNOWN_ELEMENT => "@", + code::UNDEFINED_VARIABLE => "$", + _ => "", + }; + let old = format!("{}{}", sigil, subject); + let new = format!("{}{}", sigil, suggestion); + if let Some(col) = find_word(source_line, &old) { let edit = TextEdit { range: Range::new( - Position::new(diag.range.start.line, 0), - Position::new(diag.range.start.line + 1, 0), + Position::new(line, col as u32), + Position::new(line, (col + old.len()) as u32), ), - new_text: String::new(), + new_text: new.clone(), }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: format!("Remove unused variable '${}'", var_name), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); + actions.push(quick_fix( + format!("Replace with '{}'", new), + diag, + uri, + vec![edit], + )); } } - } - // Quick-fix: remove unused attribute bundle (@let name [...]) - if msg.contains("unused attribute bundle") { - let line = diag.range.start.line as usize; - let lines: Vec<&str> = text.lines().collect(); - if let Some(source_line) = lines.get(line) { - let trimmed = source_line.trim_start(); - if trimmed.starts_with("@let ") { - let def_name = trimmed - .strip_prefix("@let ") - .and_then(|r| { - let r = r.trim(); - r.find('[') - .map(|b| r[..b].trim()) - .or_else(|| r.split_whitespace().next()) - }) - .unwrap_or("?"); - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, 0), - Position::new(diag.range.start.line + 1, 0), - ), - new_text: String::new(), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: format!("Remove unused attribute bundle '${}'", def_name), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } + // Remove a definition nothing uses, with its body. + code::UNUSED_VARIABLE | code::UNUSED_BUNDLE | code::UNUSED_FUNCTION => { + let Some(def) = defs + .iter() + .find(|d| d.line == line && subject.is_none_or(|s| s == d.name)) + else { + continue; + }; + let (what, shown) = match def.kind { + DefinitionKind::Value => ("variable", format!("${}", def.name)), + DefinitionKind::Bundle => ("attribute bundle", format!("${}", def.name)), + DefinitionKind::Function => ("function", format!("@{}", def.name)), + }; + let edit = TextEdit { + range: Range::new( + Position::new(def.line, 0), + Position::new(def.end_line + 1, 0), + ), + new_text: String::new(), + }; + actions.push(quick_fix( + format!("Remove unused {} '{}'", what, shown), + diag, + uri, + vec![edit], + )); } - } - // Quick-fix: remove unused function (@let name ... with body) - if msg.contains("unused function") { - let line = diag.range.start.line as usize; - let lines: Vec<&str> = text.lines().collect(); - if let Some(source_line) = lines.get(line) { - let trimmed = source_line.trim_start(); - if trimmed.starts_with("@let ") { - let fn_name = trimmed - .strip_prefix("@let ") - .and_then(|r| r.split_whitespace().next()) - .unwrap_or("?"); - // Find the end of the function body (indented lines below) - let start_indent = source_line.len() - trimmed.len(); - let mut end_line = line; - let mut j = line + 1; - while j < lines.len() { - let l = lines[j]; - if l.trim().is_empty() { - j += 1; - continue; - } - let indent = l.len() - l.trim_start().len(); - if indent <= start_indent { - break; - } - end_line = j; - j += 1; + // Add a missing attribute to the element the diagnostic is on. + code::MISSING_ALT | code::MISSING_INPUT_TYPE => { + let (element, attr, title) = if code == code::MISSING_ALT { + ("image", "alt", "Add alt attribute") + } else { + ("input", "type text", "Add type=\"text\" attribute") + }; + let Some(node) = lsp_tree::node_at(tree, line) else { + continue; + }; + let Some(head) = node.heads().into_iter().find(|h| h.name == element) else { + continue; + }; + let edit = match &head.attrs { + Some(list) => { + let at = lsp_tree::range(list.span).start; + let sep = if list.attrs.is_empty() { "" } else { ", " }; + insert( + at.line, + at.character as usize + 1, + &format!("{}{}", attr, sep), + ) } - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, 0), - Position::new(end_line as u32 + 1, 0), - ), - new_text: String::new(), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: format!("Remove unused function '@{}'", fn_name), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } - } - } - - // Quick-fix: add alt attribute to @image - if msg.contains("@image should have") && msg.contains("alt") { - let line = diag.range.start.line as usize; - let lines: Vec<&str> = text.lines().collect(); - if let Some(source_line) = lines.get(line) { - if let Some(bracket_pos) = source_line.find('[') { - let insert_pos = bracket_pos + 1; - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, insert_pos as u32), - Position::new(diag.range.start.line, insert_pos as u32), - ), - new_text: "alt , ".into(), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Add alt attribute".into(), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } else if source_line.contains("@image") { - // No brackets yet, add them - if let Some(img_pos) = source_line.find("@image") { - let after_image = img_pos + "@image".len(); - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, after_image as u32), - Position::new(diag.range.start.line, after_image as u32), - ), - new_text: " [alt ]".into(), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Add alt attribute".into(), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); + None => { + let end = lsp_tree::range(head.name_span).end; + insert(end.line, end.character as usize, &format!(" [{}]", attr)) } - } + }; + actions.push(quick_fix(title.to_string(), diag, uri, vec![edit])); } - } - // Quick-fix: add missing type to @input - if msg.contains("@input missing 'type'") { - let line = diag.range.start.line as usize; - let lines: Vec<&str> = text.lines().collect(); - if let Some(source_line) = lines.get(line) { - if let Some(bracket_pos) = source_line.find('[') { - let insert_pos = bracket_pos + 1; - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, insert_pos as u32), - Position::new(diag.range.start.line, insert_pos as u32), - ), - new_text: "type text, ".into(), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Add type=\"text\" attribute".into(), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } else if source_line.contains("@input") - && let Some(pos) = source_line.find("@input") - { - let after = pos + "@input".len(); - let edit = TextEdit { - range: Range::new( - Position::new(diag.range.start.line, after as u32), - Position::new(diag.range.start.line, after as u32), - ), - new_text: " [type text]".into(), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Add type=\"text\" attribute".into(), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } + code::LOW_CONTRAST => { + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: "Acknowledged: low contrast ratio".into(), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + is_preferred: Some(false), + ..Default::default() + })); } - } - // Quick-fix: low contrast ratio — suggest swapping to a high-contrast pair - if msg.contains("low contrast ratio") { - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Acknowledged: low contrast ratio".into(), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - is_preferred: Some(false), - ..Default::default() - })); + _ => {} } - // Quick-fix: auto-import suggestion for unknown element @name - // Searches current directory and subdirectories for component definitions - if msg.contains("unknown element @") - && let Some(fn_name) = extract_between(msg, "unknown element @", ",") - .or_else(|| extract_between(msg, "unknown element @", "")) + // An unknown element may be a function defined in a nearby file: + // offer to include it. + if code == code::UNKNOWN_ELEMENT + && let Some(name) = subject { - let fn_name = fn_name.trim(); - if !fn_name.is_empty() - && let Ok(file_path) = uri.to_file_path() - && let Some(dir) = file_path.parent() - { - // Search current dir and subdirs for .hl files defining this function - let mut search_dirs = vec![dir.to_path_buf()]; - // Also search parent dir's subdirs (for project-wide imports) - if let Some(parent) = dir.parent() - && let Ok(entries) = std::fs::read_dir(parent) - { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() && p != dir { - search_dirs.push(p); - } - } - } - for search_dir in &search_dirs { - if let Ok(entries) = std::fs::read_dir(search_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("hl") { - continue; - } - if path == file_path { - continue; - } - if let Ok(content) = std::fs::read_to_string(&path) { - let defines_fn = content.lines().any(|l| { - let t = l.trim(); - if let Some(rest) = t.strip_prefix("@let ") { - rest.split_whitespace().next() == Some(fn_name) - } else { - false - } - }); - if defines_fn { - // Compute relative path from current file's dir - let rel = path - .strip_prefix(dir) - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string() - }); - let already_included = text - .lines() - .any(|l| l.trim() == format!("@include {}", rel)); - if !already_included { - let import_line = format!("@include {}\n", rel); - let edit = TextEdit { - range: Range::new( - Position::new(0, 0), - Position::new(0, 0), - ), - new_text: import_line, - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: format!( - "Add '@include {}' for @{}", - rel, fn_name - ), - kind: Some(CodeActionKind::QUICKFIX), - diagnostics: Some(vec![diag.clone()]), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } - } - } - } - } - } - } + actions.extend(auto_imports(name, tree, diag, uri)); } } - // Refactoring: extract selection to @let component - if selection.start.line != selection.end.line { - let lines: Vec<&str> = text.lines().collect(); - let start_line = selection.start.line as usize; - let end_line = (selection.end.line as usize).min(lines.len().saturating_sub(1)); - if start_line < lines.len() && end_line < lines.len() { - // Collect selected lines - let selected: Vec<&str> = lines[start_line..=end_line].to_vec(); - if !selected.is_empty() { - // Determine the minimum indentation of selected lines (ignoring blank lines) - let min_indent = selected - .iter() - .filter(|l| !l.trim().is_empty()) - .map(|l| l.len() - l.trim_start().len()) - .min() - .unwrap_or(0); - - // Build the function body with two-space indentation relative to @let - let fn_body: String = selected - .iter() - .map(|l| { - if l.trim().is_empty() { - String::from("\n") - } else { - let stripped = if l.len() > min_indent { - &l[min_indent..] - } else { - l.trim_start() - }; - format!(" {}\n", stripped) - } - }) - .collect(); - - let fn_def = format!("@let extracted\n{}", fn_body); - let indent = " ".repeat(min_indent); - let fn_call = format!("{}@extracted", indent); + actions.extend(extract_component(&lines, selection, uri)); + actions.extend(extract_bundle(tree, selection, uri)); + actions +} - // Build edits: replace selected lines with @extracted call, and insert @let definition at top - let replace_edit = TextEdit { - range: Range::new( - Position::new(selection.start.line, 0), - Position::new(selection.end.line + 1, 0), - ), - new_text: format!("{}\n", fn_call), - }; - let insert_edit = TextEdit { - range: Range::new(Position::new(0, 0), Position::new(0, 0)), - new_text: format!("{}\n", fn_def), - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![insert_edit, replace_edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Extract to @let component".into(), - kind: Some(CodeActionKind::REFACTOR_EXTRACT), - diagnostics: None, - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); +/// `@include` fixes for a function `name` defined in a `.hl` file in this +/// directory or a sibling directory. +fn auto_imports(name: &str, tree: &Tree, diag: &Diagnostic, uri: &Url) -> Vec { + let mut actions = Vec::new(); + let Ok(file_path) = uri.to_file_path() else { + return actions; + }; + let Some(dir) = file_path.parent() else { + return actions; + }; + let mut search_dirs = vec![dir.to_path_buf()]; + if let Some(parent) = dir.parent() + && let Ok(entries) = std::fs::read_dir(parent) + { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() && p != dir { + search_dirs.push(p); } } } - - // Refactoring: extract attributes to @let attribute bundle - // Works on a single line with [attrs] — extracts attrs into a @let - { - let lines: Vec<&str> = text.lines().collect(); - let line_idx = selection.start.line as usize; - if line_idx < lines.len() { - let line = lines[line_idx]; - if let Some(bracket_start) = line.find('[') - && let Some(bracket_end) = line[bracket_start..].find(']') - { - let attrs_str = &line[bracket_start + 1..bracket_start + bracket_end]; - // Only offer if there are at least 2 attributes - let attr_count = attrs_str.split(',').count(); - if attr_count >= 2 { - let define_name = "extracted-style"; - let define_line = format!("@let {} [{}]\n", define_name, attrs_str.trim()); - let indent = " ".repeat(line.len() - line.trim_start().len()); - - // Replace [attrs] with [$extracted-style] - let new_line = format!( - "{}{}[${}]{}", - indent, - &line.trim_start()[..line.trim_start().find('[').unwrap_or(0)], - define_name, - &line[bracket_start + bracket_end + 1..] - ); - - let replace_edit = TextEdit { - range: Range::new( - Position::new(line_idx as u32, 0), - Position::new(line_idx as u32, line.len() as u32), - ), - new_text: new_line, - }; - let insert_edit = TextEdit { - range: Range::new(Position::new(0, 0), Position::new(0, 0)), - new_text: define_line, - }; - let mut changes = HashMap::new(); - changes.insert(uri.clone(), vec![insert_edit, replace_edit]); - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Extract to @let attribute bundle".into(), - kind: Some(CodeActionKind::REFACTOR_EXTRACT), - diagnostics: None, - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), - ..Default::default() - })); - } + let mut included = HashSet::new(); + tree.walk(&mut |node| { + if let Some((path, _)) = lsp_tree::file_argument(node) + && node.is_directive("include") + { + included.insert(path); + } + }); + for search_dir in &search_dirs { + let Ok(entries) = std::fs::read_dir(search_dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("hl") || path == file_path { + continue; } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let defines = syntax::parse(&content) + .definitions() + .iter() + .any(|d| d.kind == DefinitionKind::Function && d.name == name); + if !defines { + continue; + } + let rel = path + .strip_prefix(dir) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| { + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string() + }); + if included.contains(&rel) { + continue; + } + let edit = insert(0, 0, &format!("@include {}\n", rel)); + actions.push(quick_fix( + format!("Add '@include {}' for @{}", rel, name), + diag, + uri, + vec![edit], + )); } } - actions } -fn extract_suggestion(msg: &str) -> Option<&str> { - // "did you mean @X?" or "did you mean 'X'?" - if let Some(idx) = msg.find("did you mean @") { - let start = idx + "did you mean @".len(); - let rest = &msg[start..]; - let end = rest.find('?').unwrap_or(rest.len()); - return Some(&rest[..end]); +/// Refactoring: extract the selected lines into a `@let` function. +fn extract_component(lines: &[&str], selection: &Range, uri: &Url) -> Option { + if selection.start.line == selection.end.line { + return None; } - if let Some(idx) = msg.find("did you mean '") { - let start = idx + "did you mean '".len(); - let rest = &msg[start..]; - let end = rest.find('\'')?; - return Some(&rest[..end]); + let start_line = selection.start.line as usize; + let end_line = (selection.end.line as usize).min(lines.len().saturating_sub(1)); + if start_line >= lines.len() || start_line > end_line { + return None; } - None + let selected = &lines[start_line..=end_line]; + // The smallest indentation of the selected lines, blank lines aside + let min_indent = selected + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len()) + .min() + .unwrap_or(0); + + // The body, indented two spaces under the @let + let fn_body: String = selected + .iter() + .map(|l| { + if l.trim().is_empty() { + String::from("\n") + } else { + let stripped = l.get(min_indent..).unwrap_or_else(|| l.trim_start()); + format!(" {}\n", stripped) + } + }) + .collect(); + + let fn_def = format!("@let extracted\n{}", fn_body); + let fn_call = format!("{}@extracted", " ".repeat(min_indent)); + let replace_edit = TextEdit { + range: Range::new( + Position::new(selection.start.line, 0), + Position::new(selection.end.line + 1, 0), + ), + new_text: format!("{}\n", fn_call), + }; + let insert_edit = insert(0, 0, &format!("{}\n", fn_def)); + let mut changes = HashMap::new(); + changes.insert(uri.clone(), vec![insert_edit, replace_edit]); + Some(CodeActionOrCommand::CodeAction(CodeAction { + title: "Extract to @let component".into(), + kind: Some(CodeActionKind::REFACTOR_EXTRACT), + diagnostics: None, + edit: Some(WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }), + ..Default::default() + })) } -fn extract_between<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { - let start = msg.find(prefix)? + prefix.len(); - let rest = &msg[start..]; - if suffix.is_empty() { - Some(rest.trim()) - } else { - let end = rest.find(suffix)?; - Some(&rest[..end]) +/// Refactoring: move an element's attribute list (two attributes or more) +/// into a `@let` attribute bundle. +fn extract_bundle(tree: &Tree, selection: &Range, uri: &Url) -> Option { + let node = lsp_tree::node_at(tree, selection.start.line)?; + // Only a line on its own: the list's span is then a plain range. + if node.line_count > 1 { + return None; } + let list = node.heads().into_iter().find_map(|head| { + head.attrs + .as_ref() + .filter(|list| list.closed && list.attrs.len() >= 2) + .filter(|list| list.span.line == selection.start.line as usize + 1) + })?; + let name = "extracted-style"; + let attrs: Vec<&str> = list.attrs.iter().map(|a| a.raw.as_str()).collect(); + let define_line = format!("@let {} [{}]\n", name, attrs.join(", ")); + let replace_edit = TextEdit { + range: lsp_tree::range(list.span), + new_text: format!("[${}]", name), + }; + let insert_edit = insert(0, 0, &define_line); + let mut changes = HashMap::new(); + changes.insert(uri.clone(), vec![insert_edit, replace_edit]); + Some(CodeActionOrCommand::CodeAction(CodeAction { + title: "Extract to @let attribute bundle".into(), + kind: Some(CodeActionKind::REFACTOR_EXTRACT), + diagnostics: None, + edit: Some(WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }), + ..Default::default() + })) } // --------------------------------------------------------------------------- @@ -860,99 +652,51 @@ fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> { // Folding ranges // --------------------------------------------------------------------------- -pub(crate) fn folding_ranges(text: &str) -> Vec { +pub(crate) fn folding_ranges(tree: &Tree) -> Vec { + let fold = |start: usize, end: usize, kind| FoldingRange { + start_line: start.saturating_sub(1) as u32, + start_character: None, + end_line: end.saturating_sub(1) as u32, + end_character: None, + kind: Some(kind), + collapsed_text: None, + }; let mut ranges = Vec::new(); - let lines: Vec<&str> = text.lines().collect(); - let mut i = 0; - while i < lines.len() { - let trimmed = lines[i].trim(); - // Fold blocks that start with @let (with body), @if, @else, @each, @style, @head - if trimmed.starts_with("@let ") - || trimmed.starts_with("@if ") - || trimmed == "@else" - || trimmed.starts_with("@else if ") - || trimmed.starts_with("@each ") - || trimmed == "@style" - || trimmed == "@head" - { - let start_indent = lines[i].len() - lines[i].trim_start().len(); - let start_line = i; - let mut end_line = i; - let mut j = i + 1; - while j < lines.len() { - let l = lines[j]; - if l.trim().is_empty() { - j += 1; - continue; + let mut comment_run: Option<(usize, usize)> = None; + let mut comments = Vec::new(); + tree.walk(&mut |node| { + match node.kind { + NodeKind::Comment => comments.push(node.span.line), + // A verbatim body folds with the line that opens it. + NodeKind::Blank | NodeKind::Verbatim(_) => {} + _ => { + let end = node.end_line(); + if end > node.span.line { + // A block with a body, or a header over several lines + ranges.push(fold(node.span.line, end, FoldingRangeKind::Region)); } - let indent = l.len() - l.trim_start().len(); - if indent <= start_indent { - break; - } - end_line = j; - j += 1; - } - if end_line > start_line { - ranges.push(FoldingRange { - start_line: start_line as u32, - start_character: None, - end_line: end_line as u32, - end_character: None, - kind: Some(FoldingRangeKind::Region), - collapsed_text: None, - }); } } - // Fold comment blocks (lines starting with --) - if trimmed.starts_with("--") { - let start_line = i; - let mut end_line = i; - let mut j = i + 1; - while j < lines.len() && lines[j].trim().starts_with("--") { - end_line = j; - j += 1; - } - if end_line > start_line { - ranges.push(FoldingRange { - start_line: start_line as u32, - start_character: None, - end_line: end_line as u32, - end_character: None, - kind: Some(FoldingRangeKind::Comment), - collapsed_text: None, - }); - } - } - i += 1; - } - - // Fold any multi-line `[...]` attribute list. We track bracket depth - // across lines so nested brackets and brackets that close later in the - // file are handled the same way. - let mut stack: Vec<(u32, u32)> = Vec::new(); // (line, col) of each unmatched `[` - for (line_idx, line) in lines.iter().enumerate() { - for (col, ch) in line.char_indices() { - match ch { - '[' => stack.push((line_idx as u32, col as u32)), - ']' => { - if let Some((start_line, start_col)) = stack.pop() - && start_line as usize != line_idx - { - ranges.push(FoldingRange { - start_line, - start_character: Some(start_col), - end_line: line_idx as u32, - end_character: Some(col as u32 + 1), - kind: Some(FoldingRangeKind::Region), - collapsed_text: None, - }); - } + }); + // Runs of consecutive comment lines + comments.sort_unstable(); + for line in comments { + comment_run = match comment_run { + Some((start, end)) if line == end + 1 => Some((start, line)), + Some((start, end)) => { + if end > start { + ranges.push(fold(start, end, FoldingRangeKind::Comment)); } - _ => {} + Some((line, line)) } - } + None => Some((line, line)), + }; + } + if let Some((start, end)) = comment_run + && end > start + { + ranges.push(fold(start, end, FoldingRangeKind::Comment)); } - ranges } @@ -960,106 +704,92 @@ pub(crate) fn folding_ranges(text: &str) -> Vec { // Semantic tokens // --------------------------------------------------------------------------- -pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec { - let mut tokens = Vec::new(); - let mut prev_line: u32 = 0; - let mut prev_start: u32 = 0; +const TOKEN_KEYWORD: u32 = 0; +const TOKEN_VARIABLE: u32 = 1; +const TOKEN_FUNCTION: u32 = 2; +const TOKEN_COMMENT: u32 = 4; +const MODIFIER_UNUSED: u32 = 1; - // Build set of unused variables by parsing diagnostics - let mut unused_vars: std::collections::HashSet = std::collections::HashSet::new(); - for d in &result.diagnostics { - if d.message.contains("unused variable '$") - && let Some(start) = d.message.find("'$") - { - let rest = &d.message[start + 2..]; - if let Some(end) = rest.find('\'') { - unused_vars.insert(rest[..end].to_string()); - } - } - if d.message.contains("unused function '@") - && let Some(start) = d.message.find("'@") - { - let rest = &d.message[start + 2..]; - if let Some(end) = rest.find('\'') { - unused_vars.insert(format!("@{}", &rest[..end])); +pub(crate) fn semantic_tokens(text: &str, tree: &Tree, result: &ParseResult) -> Vec { + // Definitions the compiler reported unused: `$name` or `@name` + let unused: HashSet = result + .diagnostics + .iter() + .filter_map(|d| { + let sigil = match d.code { + code::UNUSED_VARIABLE | code::UNUSED_BUNDLE => "$", + code::UNUSED_FUNCTION => "@", + _ => return None, + }; + Some(format!("{}{}", sigil, d.subject.as_deref()?)) + }) + .collect(); + + // (line, column, length, type, modifiers), from the tree + let mut found: Vec<(u32, u32, u32, u32, u32)> = Vec::new(); + let mut push = |span: syntax::Span, token: u32, modifier: u32| { + let r = lsp_tree::range(span); + found.push(( + r.start.line, + r.start.character, + r.end.character - r.start.character, + token, + modifier, + )); + }; + let mut comments: HashSet = HashSet::new(); + tree.walk(&mut |node| { + match &node.kind { + NodeKind::Comment => { + comments.insert(node.span.line.saturating_sub(1) as u32); } - } - if d.message.contains("unused define '$") - && let Some(start) = d.message.find("'$") - { - let rest = &d.message[start + 2..]; - if let Some(end) = rest.find('\'') { - unused_vars.insert(rest[..end].to_string()); + NodeKind::Directive(directive) => { + push(directive.name_span, TOKEN_KEYWORD, 0); + if let syntax::DirectiveArgs::Let(def) = &directive.args { + let function = matches!(def.form, syntax::LetForm::Function(_)); + let (token, key) = if function { + (TOKEN_FUNCTION, format!("@{}", def.name)) + } else { + (TOKEN_VARIABLE, format!("${}", def.name)) + }; + let modifier = if unused.contains(&key) { + MODIFIER_UNUSED + } else { + 0 + }; + push(def.name_span, token, modifier); + } } + _ => {} } - if d.message.contains("unused mixin '") - && let Some(start) = d.message.find("unused mixin '") - { - let rest = &d.message[start + 14..]; - if let Some(end) = rest.find('\'') { - unused_vars.insert(rest[..end].to_string()); - } + for head in node.heads() { + // Built-in elements are keywords; any other `@name` is a call. + let token = if is_builtin_name(&head.name) { + TOKEN_KEYWORD + } else { + TOKEN_FUNCTION + }; + push(head.name_span, token, 0); } - } + }); + // `$name` references, anywhere but in comments and verbatim bodies + let verbatim = lsp_tree::verbatim_lines(tree); for (line_idx, line) in text.lines().enumerate() { - let trimmed = line.trim(); let line_num = line_idx as u32; - - // Detect comments - if trimmed.starts_with("--") { - let col = (line.len() - trimmed.len()) as u32; - push_token( - &mut tokens, - &mut prev_line, - &mut prev_start, - line_num, - col, - trimmed.len() as u32, - 4, - 0, - ); + if comments.contains(&line_num) { + let trimmed = line.trim(); + let col = (line.len() - line.trim_start().len()) as u32; + found.push((line_num, col, trimmed.len() as u32, TOKEN_COMMENT, 0)); + continue; + } + if verbatim.contains(&line_num) { continue; } - - // Scan for @keywords let bytes = line.as_bytes(); let mut i = 0; while i < bytes.len() { - if bytes[i] == b'@' { - let start = i; - i += 1; - while i < bytes.len() - && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') - { - i += 1; - } - let word = &line[start..i]; - // Directives and built-in elements are keywords; any other - // `@name` is a function call. - let token_type = if is_builtin_name(&word[1..]) { 0 } else { 2 }; - // Mark unused definitions with deprecated modifier (dimmed) - let modifier = if trimmed.starts_with("@let ") && word != "@let" { - let name_part = &word[1..]; // strip @ - if unused_vars.contains(&format!("@{}", name_part)) { - 1 - } else { - 0 - } - } else { - 0 - }; - push_token( - &mut tokens, - &mut prev_line, - &mut prev_start, - line_num, - start as u32, - (i - start) as u32, - token_type, - modifier, - ); - } else if bytes[i] == b'$' { + if bytes[i] == b'$' { let start = i; i += 1; while i < bytes.len() @@ -1068,39 +798,49 @@ pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec start + 1 { - // Check if this is an unused variable definition on a @let line - let var_name = &line[start + 1..i]; - let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name) - { - 1 - } else { - 0 - }; - push_token( - &mut tokens, - &mut prev_line, - &mut prev_start, + found.push(( line_num, start as u32, (i - start) as u32, - 1, - modifier, - ); // variable + TOKEN_VARIABLE, + 0, + )); } } else { i += 1; } } } + + found.sort_by_key(|t| (t.0, t.1)); + found.dedup_by_key(|t| (t.0, t.1)); + let mut tokens = Vec::new(); + let mut prev_line: u32 = 0; + let mut prev_start: u32 = 0; + let mut prev_end: Option<(u32, u32)> = None; + for (line, start, length, token_type, modifiers) in found { + // Overlapping tokens aren't allowed + if prev_end.is_some_and(|(l, end)| l == line && start < end) { + continue; + } + push_token( + &mut tokens, + &mut prev_line, + &mut prev_start, + line, + start, + length, + token_type, + modifiers, + ); + prev_end = Some((line, start + length)); + } tokens } /// A directive or built-in element name (without `@`), from the compiler. fn is_builtin_name(name: &str) -> bool { - htmlang::parser::known_directives().contains(&name) - || htmlang::ast::ElementKind::from_name(name).is_some() - // Directives the parser handles outside its directive list - || name == "data" + htmlang::ast::directive(name).is_some() || htmlang::ast::ElementKind::from_name(name).is_some() } #[allow(clippy::too_many_arguments)] @@ -1135,23 +875,33 @@ fn push_token( // Inlay hints // --------------------------------------------------------------------------- -pub(crate) fn inlay_hints(text: &str) -> Vec { - // Build variable map: name -> value - let mut vars: HashMap<&str, &str> = HashMap::new(); - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("@let ") - && let Some((name, value)) = rest.trim().split_once(' ') - { - vars.insert(name, value.trim()); +pub(crate) fn inlay_hints(text: &str, tree: &Tree) -> Vec { + // Values and bundles by name, and the lines that define them + let mut values: HashMap = HashMap::new(); + let mut skip: HashSet = lsp_tree::verbatim_lines(tree); + for def in lsp_tree::definitions(text) { + skip.insert(def.line); + match def.kind { + DefinitionKind::Value => { + if let Some(value) = def.value { + values.insert(def.name, value); + } + } + DefinitionKind::Bundle => { + values.insert(def.name, format!("[{}]", def.value.unwrap_or_default())); + } + DefinitionKind::Function => {} } } + tree.walk(&mut |node| { + if matches!(node.kind, NodeKind::Comment) { + skip.insert(node.span.line.saturating_sub(1) as u32); + } + }); let mut hints = Vec::new(); for (line_idx, line) in text.lines().enumerate() { - let trimmed = line.trim(); - // Skip @let definition lines — the value is already visible there - if trimmed.starts_with("@let ") { + if skip.contains(&(line_idx as u32)) { continue; } let bytes = line.as_bytes(); @@ -1165,20 +915,19 @@ pub(crate) fn inlay_hints(text: &str) -> Vec { { i += 1; } - if i > start + 1 { - let var_name = &line[start + 1..i]; - if let Some(value) = vars.get(var_name) { - hints.push(InlayHint { - position: Position::new(line_idx as u32, i as u32), - label: InlayHintLabel::String(format!(" \u{2192} {}", value)), - kind: None, - text_edits: None, - tooltip: None, - padding_left: Some(false), - padding_right: Some(true), - data: None, - }); - } + if i > start + 1 + && let Some(value) = values.get(&line[start + 1..i]) + { + hints.push(InlayHint { + position: Position::new(line_idx as u32, i as u32), + label: InlayHintLabel::String(format!(" \u{2192} {}", value)), + kind: None, + text_edits: None, + tooltip: None, + padding_left: Some(false), + padding_right: Some(true), + data: None, + }); } } else { i += 1; @@ -1196,16 +945,11 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option = text.lines().collect(); let line = lines.get(position.line as usize)?; let col = (position.character as usize).min(line.len()); - let before = &line[..col]; + let before = line.get(..col)?; // Check if we're inside a function call: @funcname [... let trimmed = before.trim_start(); - if !trimmed.starts_with('@') { - return None; - } - - // Extract the function name - let after_at = &trimmed[1..]; + let after_at = trimmed.strip_prefix('@')?; let name_end = after_at .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') .unwrap_or(after_at.len()); @@ -1220,71 +964,160 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option = params_str - .split_whitespace() - .filter(|p| p.starts_with('$')) - .collect(); + let def: Def = lsp_tree::definitions(text) + .into_iter() + .find(|d| d.kind == DefinitionKind::Function && d.name == fn_name)?; + if def.params.is_empty() { + return None; + } + let param_labels: Vec = def + .params + .iter() + .map(|p| ParameterInformation { + label: ParameterLabel::Simple(p.name.clone()), + documentation: p + .default + .as_ref() + .map(|d| Documentation::String(format!("Default: {}", d))), + }) + .collect(); + let written: Vec = def.params.iter().map(param_label).collect(); + let sig_label = format!("@{} {}", fn_name, written.join(" ")); + + // The active parameter: commas before the cursor inside the brackets, + // or the first parameter before the argument list is entered. + let active_param = if inside_args { + let bracket_start = before.rfind('[').unwrap_or(0); + before[bracket_start..].matches(',').count() as u32 + } else { + 0 + }; - if params.is_empty() { - return None; - } + Some(SignatureHelp { + signatures: vec![SignatureInformation { + label: sig_label, + documentation: Some(Documentation::String(format!( + "Defined at line {}", + def.line + 1 + ))), + parameters: Some(param_labels), + active_parameter: Some(active_param), + }], + active_signature: Some(0), + active_parameter: Some(active_param), + }) +} - let param_labels: Vec = params - .iter() - .map(|p| { - let name = p.trim_start_matches('$'); - let (label, doc) = if let Some((n, default)) = name.split_once('=') { - (n.to_string(), Some(format!("Default: {}", default))) - } else { - (name.to_string(), None) - }; - ParameterInformation { - label: ParameterLabel::Simple(label), - documentation: doc.map(Documentation::String), - } - }) - .collect(); +#[cfg(test)] +mod tests { + use super::*; + + fn lsp_diagnostics(text: &str) -> Vec { + let result = htmlang::parser::parse(text); + result + .diagnostics + .iter() + .map(|d| Diagnostic { + range: Range::new( + Position::new(d.line.saturating_sub(1) as u32, 0), + Position::new(d.line.saturating_sub(1) as u32, 1000), + ), + code: Some(NumberOrString::String(d.code.into())), + message: d.message.clone(), + data: Some(serde_json::json!({ "subject": d.subject, "suggestion": d.suggestion })), + ..Default::default() + }) + .collect() + } - let sig_label = format!("@{} {}", fn_name, params.join(" ")); + fn fixes(text: &str) -> Vec<(String, Vec)> { + let uri = Url::parse("file:///tmp/none/page.hl").unwrap(); + let tree = syntax::parse(text); + let point = Range::new(Position::new(0, 0), Position::new(0, 0)); + code_actions(text, &tree, &point, &lsp_diagnostics(text), &uri) + .into_iter() + .filter_map(|action| match action { + CodeActionOrCommand::CodeAction(action) => { + let edits = action.edit?.changes?.remove(&uri)?; + Some((action.title, edits)) + } + _ => None, + }) + .collect() + } - // Determine active parameter by counting commas before cursor inside - // brackets. When the cursor hasn't entered the argument list yet, - // highlight the first parameter. - let active_param = if inside_args { - let bracket_start = before.rfind('[').unwrap_or(0); - let inside = &before[bracket_start..]; - inside.matches(',').count() as u32 - } else { - 0 - }; + #[test] + fn quick_fixes_are_keyed_on_codes() { + let found = fixes("@el\n @paragrap Hi\n"); + let (title, edits) = found + .iter() + .find(|(title, _)| title.starts_with("Replace")) + .expect("replace fix"); + assert_eq!(title, "Replace with '@paragraph'"); + assert_eq!(edits[0].range.start, Position::new(1, 2)); + + let found = fixes("@el [paddin 4]\n"); + assert!( + found.iter().any(|(t, _)| t == "Replace with 'padding'"), + "{:?}", + found + ); + + let found = fixes("@let card $x\n @el $x\n\n @el more\n@el\n"); + let (title, edits) = found + .iter() + .find(|(t, _)| t.starts_with("Remove")) + .expect("remove fix"); + assert_eq!(title, "Remove unused function '@card'"); + assert_eq!( + edits[0].range, + Range::new(Position::new(0, 0), Position::new(4, 0)) + ); + + let found = fixes("@image cat.png\n"); + let (_, edits) = found + .iter() + .find(|(t, _)| t == "Add alt attribute") + .expect("alt fix"); + assert_eq!(edits[0].new_text, " [alt]"); + assert_eq!(edits[0].range.start, Position::new(0, 6)); + } - return Some(SignatureHelp { - signatures: vec![SignatureInformation { - label: sig_label, - documentation: Some(Documentation::String(format!( - "Defined at line {}", - line_idx + 1 - ))), - parameters: Some(param_labels), - active_parameter: Some(active_param), - }], - active_signature: Some(0), - active_parameter: Some(active_param), - }); + #[test] + fn folding_follows_the_tree() { + let text = "-- a\n-- b\n@el\n @text x\n\n @text y\n@style\n .a {\n }\n@text z\n"; + let ranges = folding_ranges(&syntax::parse(text)); + let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.start_line, r.end_line)).collect(); + assert!(spans.contains(&(0, 1)), "{:?}", spans); + assert!(spans.contains(&(2, 5)), "{:?}", spans); + assert!(spans.contains(&(6, 8)), "{:?}", spans); + assert_eq!(spans.len(), 3, "{:?}", spans); + } + + #[test] + fn semantic_tokens_skip_verbatim_bodies() { + let text = "@let gap 4\n@style\n @media (x) { a { b: $c } }\n@el [padding $gap]\n@card\n"; + let tokens = semantic_tokens(text, &syntax::parse(text), &htmlang::parser::parse(text)); + let mut line = 0; + let mut lines = Vec::new(); + for t in &tokens { + line += t.delta_line; + lines.push(line); } + assert!(!lines.contains(&2), "{:?}", tokens); + // `@let`, `gap`, `@style`, `@el`, `$gap`, `@card` (a call) + assert_eq!(tokens.len(), 6, "{:?}", tokens); + assert_eq!(tokens[5].token_type, TOKEN_FUNCTION); + } + + #[test] + fn signature_help_and_inlay_hints_use_definitions() { + let text = + "@let pad [padding 4]\n@let card $title $tone=info\n @el [$pad] $title\n@card [\n"; + let help = get_signature_help(text, Position::new(3, 7)).expect("signature"); + assert_eq!(help.signatures[0].label, "@card $title $tone=info"); + let hints = inlay_hints(text, &syntax::parse(text)); + assert_eq!(hints.len(), 1); + assert_eq!(hints[0].position, Position::new(2, 11)); } - None } diff --git a/src/bin/htmlang_lsp/completion.rs b/src/bin/htmlang_lsp/completion.rs index ae430d3..fe80c89 100644 --- a/src/bin/htmlang_lsp/completion.rs +++ b/src/bin/htmlang_lsp/completion.rs @@ -1,4 +1,5 @@ use htmlang::ast::ElementKind; +use htmlang::syntax::DefinitionKind; use htmlang::vocab; use tower_lsp::lsp_types::*; @@ -137,10 +138,11 @@ fn element_completions(range: Range) -> Vec { .collect() } -/// Directives, from the parser's directive list. +/// Directives, from the compiler's directive table. fn directive_completions(range: Range) -> Vec { - htmlang::parser::known_directives() + htmlang::ast::DIRECTIVES .iter() + .map(|spec| spec.name) .map(|name| { let label = format!("@{}", name); let detail = docs::directive(name).map_or("Directive", |d| d.summary); @@ -849,31 +851,30 @@ fn variable_completions(text: &str, range: Range) -> Vec { )); } - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("@let ") - && let Some((name, value)) = rest.trim().split_once(' ') - { - let value = value.trim(); - if value.starts_with('[') { - // Attribute bundle + for def in crate::tree::definitions(text) { + let label = format!("${}", def.name); + match def.kind { + DefinitionKind::Bundle => { items.push(item( - &format!("${}", name), + &label, CompletionItemKind::CONSTANT, "Attribute bundle", - &format!("${}", name), + &label, range, )); - } else if !value.starts_with('$') { - // Scalar variable (skip function params like $param) + } + DefinitionKind::Value => { + let value = def.value.unwrap_or_default(); + let detail = format!("= {}", value.trim_start_matches("= ")); items.push(item( - &format!("${}", name), + &label, CompletionItemKind::VARIABLE, - &format!("= {}", value), - &format!("${}", name), + &detail, + &label, range, )); } + DefinitionKind::Function => {} } } @@ -882,62 +883,53 @@ fn variable_completions(text: &str, range: Range) -> Vec { fn function_completions(text: &str, range: Range) -> Vec { let mut items = Vec::new(); - let all_lines: Vec<&str> = text.lines().collect(); - - for (idx, line) in all_lines.iter().enumerate() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("@let ") { - let parts: Vec<&str> = rest.split_whitespace().collect(); - if let Some(name) = parts.first() { - // Only suggest as function if it has params or an indented body - let has_params = parts.len() > 1 && parts[1..].iter().any(|p| p.starts_with('$')); - let has_body = all_lines - .get(idx + 1) - .map(|l| l.starts_with(" ") || l.starts_with('\t')) - .unwrap_or(false); - if !has_params && !has_body { - continue; - } - let params: Vec<&str> = parts[1..] - .iter() - .map(|p| p.strip_prefix('$').unwrap_or(p)) - .collect(); - let detail = if params.is_empty() { - "Function".to_string() - } else { - format!("Function({})", params.join(", ")) - }; - // Generate snippet with tab stops for parameters - let insert_text = if params.is_empty() { - format!("@{}", name) - } else { - let param_snippets: Vec = params - .iter() - .enumerate() - .map(|(i, p)| { - let p_name = p.split('=').next().unwrap_or(p); - let default = p.split('=').nth(1).unwrap_or(p_name); - format!("{} ${{{}:{}}}", p_name, i + 1, default) - }) - .collect(); - format!("@{} [{}]", name, param_snippets.join(", ")) - }; - let mut ci = CompletionItem { - label: format!("@{}", name), - kind: Some(CompletionItemKind::FUNCTION), - detail: Some(detail), - text_edit: Some(CompletionTextEdit::Edit(TextEdit { - range, - new_text: insert_text, - })), - ..Default::default() - }; - if !params.is_empty() { - ci.insert_text_format = Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET); - } - items.push(ci); - } + for def in crate::tree::definitions(text) { + if def.kind != DefinitionKind::Function { + continue; + } + let name = &def.name; + let params: Vec = def + .params + .iter() + .map(|p| match &p.default { + Some(default) => format!("{}={}", p.name, default), + None => p.name.clone(), + }) + .collect(); + let detail = if params.is_empty() { + "Function".to_string() + } else { + format!("Function({})", params.join(", ")) + }; + // A snippet with a tab stop for each parameter + let insert_text = if params.is_empty() { + format!("@{}", name) + } else { + let param_snippets: Vec = def + .params + .iter() + .enumerate() + .map(|(i, p)| { + let default = p.default.as_deref().unwrap_or(&p.name); + format!("{} ${{{}:{}}}", p.name, i + 1, default) + }) + .collect(); + format!("@{} [{}]", name, param_snippets.join(", ")) + }; + let mut ci = CompletionItem { + label: format!("@{}", name), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(detail), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range, + new_text: insert_text, + })), + ..Default::default() + }; + if !params.is_empty() { + ci.insert_text_format = Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET); } + items.push(ci); } items @@ -997,8 +989,12 @@ mod tests { } assert!(elements.iter().any(|i| i.label == "@spacer")); let directives = directive_completions(range); - for name in htmlang::parser::known_directives() { - assert!(directives.iter().any(|i| i.label == format!("@{}", name))); + for spec in htmlang::ast::DIRECTIVES { + assert!( + directives + .iter() + .any(|i| i.label == format!("@{}", spec.name)) + ); } } diff --git a/src/bin/htmlang_lsp/docs.rs b/src/bin/htmlang_lsp/docs.rs index d24f865..3d6156f 100644 --- a/src/bin/htmlang_lsp/docs.rs +++ b/src/bin/htmlang_lsp/docs.rs @@ -213,7 +213,7 @@ pub(crate) fn hover(word: &str) -> Option { if let Some(doc) = directive(name) { return Some(markdown(word, doc.summary, doc.usage)); } - if htmlang::parser::known_directives().contains(&name) { + if htmlang::ast::directive(name).is_some() { return Some(markdown(word, "Directive.", "")); } let summary = element_summary(name)?; @@ -354,6 +354,27 @@ mod tests { } } + #[test] + fn every_directive_is_documented_once() { + let documented: Vec<&str> = DIRECTIVES.iter().map(|d| d.name).collect(); + let compiled: Vec<&str> = htmlang::ast::DIRECTIVES.iter().map(|d| d.name).collect(); + let mut a = documented.clone(); + let mut b = compiled.clone(); + a.sort_unstable(); + b.sort_unstable(); + assert_eq!( + a, b, + "the hover docs and the compiler's directive table differ" + ); + for name in compiled { + assert!( + ElementKind::from_name(name).is_none(), + "@{} is both a directive and an element", + name + ); + } + } + #[test] fn hovers_for_css_html_and_prefixes() { assert!(hover("opacity").unwrap().contains("developer.mozilla.org")); diff --git a/src/bin/htmlang_lsp/hover.rs b/src/bin/htmlang_lsp/hover.rs index 26f9a83..82bd5ff 100644 --- a/src/bin/htmlang_lsp/hover.rs +++ b/src/bin/htmlang_lsp/hover.rs @@ -1,6 +1,8 @@ use tower_lsp::lsp_types::*; -use crate::docs; +use htmlang::syntax::DefinitionKind; + +use crate::{docs, tree}; pub(crate) fn hover_at(text: &str, position: Position) -> Option { let lines: Vec<&str> = text.lines().collect(); @@ -46,104 +48,69 @@ pub(crate) fn is_word_byte(c: u8) -> bool { } fn hover_variable(text: &str, name: &str) -> Option { - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("@let ") - && let Some((n, v)) = rest.trim().split_once(' ') - && n == name - { - return Some(format!("**${}** = `{}`", name, v.trim())); - } - } - - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("@let ") { - let rest = rest.trim(); - // Attribute bundle: @let name [...] - if let Some(bracket) = rest.find('[') { - let def_name = rest[..bracket].trim(); - if def_name == name { - return Some(format!( - "**${}** \u{2014} Attribute bundle\n\n`{}`", - name, trimmed - )); - } + let defs = tree::definitions(text); + if let Some(def) = defs.iter().find(|d| d.name == name) { + match def.kind { + DefinitionKind::Value => { + let value = def.value.as_deref().unwrap_or(""); + return Some(format!("**${}** = `{}`", name, value)); } - // Function parameter: @let fn-name $param (with body) - let parts: Vec<&str> = rest.split_whitespace().collect(); - if let Some(fn_name) = parts.first() { - for param in &parts[1..] { - let p = param.strip_prefix('$').unwrap_or(param); - if p == name { - return Some(format!( - "**${}** \u{2014} Parameter of `@{}`", - name, fn_name - )); - } - } + DefinitionKind::Bundle => { + return Some(format!( + "**${}** \u{2014} Attribute bundle\n\n`[{}]`", + name, + def.value.as_deref().unwrap_or("") + )); } + DefinitionKind::Function => {} } } - - None + // A function's parameter + defs.iter() + .find(|d| d.params.iter().any(|p| p.name == name)) + .map(|d| format!("**${}** \u{2014} Parameter of `@{}`", name, d.name)) } fn hover_user_fn(text: &str, name: &str) -> Option { - let lines: Vec<&str> = text.lines().collect(); - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("@let ") { - let parts: Vec<&str> = rest.split_whitespace().collect(); - if parts.first() == Some(&name) { - let params = &parts[1..]; - - // Collect doc-comment lines above the definition (lines starting with --) - let mut doc_lines: Vec<&str> = Vec::new(); - let mut j = i; - while j > 0 { - j -= 1; - let prev = lines[j].trim(); - if let Some(comment) = prev.strip_prefix("-- ") { - doc_lines.push(comment); - } else if let Some(comment) = prev.strip_prefix("--") { - doc_lines.push(comment); - } else { - break; - } - } - doc_lines.reverse(); - - let doc_str = if doc_lines.is_empty() { - String::new() - } else { - format!("\n\n{}", doc_lines.join("\n")) - }; + let def = tree::definitions(text) + .into_iter() + .find(|d| d.kind == DefinitionKind::Function && d.name == name)?; - // Format params showing defaults - let params_str = if params.is_empty() { - String::new() - } else { - let formatted: Vec = params - .iter() - .map(|p| { - if p.contains('=') { - let (name, default) = p.split_once('=').unwrap(); - format!("{} (default: {})", name, default) - } else { - p.to_string() - } - }) - .collect(); - format!("\n\nParameters: {}", formatted.join(", ")) - }; - - return Some(format!( - "**@{}** \u{2014} User function{}{}", - name, params_str, doc_str - )); - } + // Comment lines right above the definition are its documentation + let lines: Vec<&str> = text.lines().collect(); + let mut doc_lines: Vec<&str> = Vec::new(); + let mut j = def.line as usize; + while j > 0 { + j -= 1; + let prev = lines[j].trim(); + match prev.strip_prefix("--") { + Some(comment) => doc_lines.push(comment.strip_prefix(' ').unwrap_or(comment)), + None => break, } } - None + doc_lines.reverse(); + let doc_str = if doc_lines.is_empty() { + String::new() + } else { + format!("\n\n{}", doc_lines.join("\n")) + }; + + let params_str = if def.params.is_empty() { + String::new() + } else { + let formatted: Vec = def + .params + .iter() + .map(|p| match &p.default { + Some(default) => format!("${} (default: {})", p.name, default), + None => format!("${}", p.name), + }) + .collect(); + format!("\n\nParameters: {}", formatted.join(", ")) + }; + + Some(format!( + "**@{}** \u{2014} User function{}{}", + name, params_str, doc_str + )) } diff --git a/src/bin/htmlang_lsp/main.rs b/src/bin/htmlang_lsp/main.rs index e666fd0..581166f 100644 --- a/src/bin/htmlang_lsp/main.rs +++ b/src/bin/htmlang_lsp/main.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use htmlang::syntax::DefinitionKind; use serde_json::json; use tokio::sync::{Mutex, RwLock}; use tokio::task::JoinHandle; @@ -16,6 +17,7 @@ mod docs; mod hover; mod navigation; mod state; +mod tree; use analysis::{ code_actions, find_colors, folding_ranges, get_signature_help, inlay_hints, semantic_tokens, @@ -55,7 +57,8 @@ impl Backend { /// Replace the stored document with a new entry, scheduling a debounced /// diagnostic publish and refreshing the workspace symbol index. async fn set_doc(&self, uri: Url, text: String, version: i32) { - let entry = Arc::new(DocumentEntry::new(text, version)); + let base = state::base_dir(&uri); + let entry = Arc::new(DocumentEntry::new(text, version, base)); self.documents .write() .await @@ -137,8 +140,11 @@ fn build_diagnostics(text: &str, result: &htmlang::parser::ParseResult) -> Vec = text.lines().collect(); - if let Some(line) = lines.get(pos.line as usize) { - let trimmed = line.trim_start(); - if trimmed.starts_with("@include ") { - let items = path_completions(uri, pos); - if !items.is_empty() { - return Ok(Some(CompletionResponse::Array(items))); - } + // File names after `@include` + if tree::node_at(&doc.tree(), pos.line).is_some_and(|node| node.is_directive("include")) { + let items = path_completions(uri, pos); + if !items.is_empty() { + return Ok(Some(CompletionResponse::Array(items))); } } @@ -475,7 +478,13 @@ impl LanguageServer for Backend { let Some(doc) = self.doc(&uri).await else { return Ok(None); }; - let actions = code_actions(&doc.text, ¶ms.range, ¶ms.context.diagnostics, &uri); + let actions = code_actions( + &doc.text, + &doc.tree(), + ¶ms.range, + ¶ms.context.diagnostics, + &uri, + ); Ok(if actions.is_empty() { None } else { @@ -534,7 +543,7 @@ impl LanguageServer for Backend { let Some(doc) = self.doc(uri).await else { return Ok(None); }; - let ranges = folding_ranges(&doc.text); + let ranges = folding_ranges(&doc.tree()); Ok(if ranges.is_empty() { None } else { @@ -551,7 +560,7 @@ impl LanguageServer for Backend { return Ok(None); }; let parse = doc.parse(); - let tokens = semantic_tokens(&doc.text, &parse); + let tokens = semantic_tokens(&doc.text, &doc.tree(), &parse); Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { result_id: None, data: tokens, @@ -563,7 +572,7 @@ impl LanguageServer for Backend { let Some(doc) = self.doc(uri).await else { return Ok(None); }; - let hints = inlay_hints(&doc.text); + let hints = inlay_hints(&doc.text, &doc.tree()); Ok(if hints.is_empty() { None } else { Some(hints) }) } @@ -686,7 +695,6 @@ impl LanguageServer for Backend { let Some(doc) = self.doc(&uri).await else { return Ok(None); }; - let text = &doc.text; let Ok(this_path) = uri.to_file_path() else { return Ok(None); @@ -695,54 +703,26 @@ impl LanguageServer for Backend { return Ok(None); }; + // The files `@include`, `@markdown` and `@data` name let mut links = Vec::new(); - for (i, raw_line) in text.lines().enumerate() { - let trimmed = raw_line.trim_start(); - let indent = raw_line.len() - trimmed.len(); - let (prefix, filename) = if let Some(rest) = trimmed.strip_prefix("@include ") { - ("@include ", rest) - } else { - continue; + doc.tree().walk(&mut |node| { + let Some((name, range)) = tree::file_argument(node) else { + return; }; - - let name_token: &str = filename - .trim_start_matches('"') - .split(|c: char| c.is_whitespace() || c == ',') - .next() - .unwrap_or("") - .trim_end_matches('"'); - if name_token.is_empty() { - continue; - } - if name_token.contains('*') || name_token.contains('?') { - continue; - } - - let target = dir.join(name_token); + let target = dir.join(&name); if !target.exists() { - continue; + return; } let Ok(target_uri) = Url::from_file_path(&target) else { - continue; + return; }; - - let scan_from = indent + prefix.len(); - let Some(rel_start) = raw_line[scan_from..].find(name_token) else { - continue; - }; - let start_col = (scan_from + rel_start) as u32; - let end_col = start_col + name_token.len() as u32; - links.push(DocumentLink { - range: Range::new( - Position::new(i as u32, start_col), - Position::new(i as u32, end_col), - ), + range, target: Some(target_uri), - tooltip: Some(format!("Open {}", name_token)), + tooltip: Some(format!("Open {}", name)), data: None, }); - } + }); Ok(if links.is_empty() { None } else { Some(links) }) } @@ -755,65 +735,17 @@ impl LanguageServer for Backend { let text = &doc.text; let lines: Vec<&str> = text.lines().collect(); - #[derive(Clone)] - struct Def { - line: u32, - col: u32, - name: String, - kind: &'static str, - } - let mut defs: Vec = Vec::new(); - - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim_start(); - if let Some(rest) = trimmed.strip_prefix("@let ") { - // `rest` is a suffix of `line`, so the name's column is - // however much of the line precedes it. - let col = (line.len() - rest.trim_start().len()) as u32; - let rest = rest.trim(); - if let Some(name) = rest.split_whitespace().next() { - let has_body = lines - .get(i + 1) - .map(|l| l.starts_with(" ") || l.starts_with('\t')) - .unwrap_or(false); - let value_after_name = rest[name.len()..].trim_start(); - if has_body - && (value_after_name.is_empty() || value_after_name.starts_with('$')) - { - defs.push(Def { - line: i as u32, - col, - name: name.to_string(), - kind: "fn", - }); - } else if value_after_name.starts_with('[') { - defs.push(Def { - line: i as u32, - col, - name: name.to_string(), - kind: "define", - }); - } else { - defs.push(Def { - line: i as u32, - col, - name: name.to_string(), - kind: "let", - }); - } - } - } - } - + let defs = tree::definitions(text); + let verbatim = tree::verbatim_lines(&doc.tree()); let mut lenses = Vec::with_capacity(defs.len()); for def in &defs { let mut locations: Vec = Vec::new(); let needle = match def.kind { - "fn" => format!("@{}", def.name), + DefinitionKind::Function => format!("@{}", def.name), _ => format!("${}", def.name), }; for (i, line) in lines.iter().enumerate() { - if i as u32 == def.line { + if i as u32 == def.line || verbatim.contains(&(i as u32)) { continue; } let mut from = 0; @@ -848,7 +780,7 @@ impl LanguageServer for Backend { // The locations are sent precomputed because a reference query at // the definition site would resolve the `@let` keyword instead. let line_pos = Position::new(def.line, 0); - let name_pos = Position::new(def.line, def.col); + let name_pos = def.name_range.start; lenses.push(CodeLens { range: Range::new(line_pos, line_pos), command: Some(Command { diff --git a/src/bin/htmlang_lsp/navigation.rs b/src/bin/htmlang_lsp/navigation.rs index 873fd98..801720f 100644 --- a/src/bin/htmlang_lsp/navigation.rs +++ b/src/bin/htmlang_lsp/navigation.rs @@ -2,8 +2,11 @@ use std::collections::HashMap; use tower_lsp::lsp_types::*; +use htmlang::syntax::DefinitionKind; + use crate::hover::{is_word_byte, word_at}; use crate::state::WorkspaceIndex; +use crate::tree; // --------------------------------------------------------------------------- // Go to definition @@ -17,101 +20,56 @@ pub(crate) fn definition_at( let lines: Vec<&str> = text.lines().collect(); let line = lines.get(position.line as usize)?; - // Check for @include file path navigation - let trimmed = line.trim(); - if let Some(filename) = trimmed.strip_prefix("@include ") { - let filename = filename.trim(); - if !filename.is_empty() { - let file_path = uri.to_file_path().ok()?; - let dir = file_path.parent()?; - let target = dir.join(filename); - if target.exists() { - let target_uri = Url::from_file_path(&target).ok()?; - return Some(GotoDefinitionResponse::Scalar(Location { - uri: target_uri, - range: Range::new(Position::new(0, 0), Position::new(0, 0)), - })); - } + // The file named by `@include`, `@markdown` or `@data` + let tree = htmlang::syntax::parse(text); + if let Some(node) = tree::node_at(&tree, position.line) + && let Some((filename, _)) = tree::file_argument(node) + { + let file_path = uri.to_file_path().ok()?; + let target = file_path.parent()?.join(filename); + if target.exists() { + let target_uri = Url::from_file_path(&target).ok()?; + return Some(GotoDefinitionResponse::Scalar(Location { + uri: target_uri, + range: Range::new(Position::new(0, 0), Position::new(0, 0)), + })); } } let col = (position.character as usize).min(line.len()); let word = word_at(line, col)?; - - // Find definition location - let (def_line, def_col, def_len) = if let Some(name) = word.strip_prefix('$') { + let range = if let Some(name) = word.strip_prefix('$') { find_definition(text, name)? } else { find_fn_definition(text, word.strip_prefix('@')?)? }; - Some(GotoDefinitionResponse::Scalar(Location { uri: uri.clone(), - range: Range::new( - Position::new(def_line, def_col), - Position::new(def_line, def_col + def_len), - ), + range, })) } -/// Find @let definition for a $name reference (variable, attribute bundle, or function param). -pub(crate) fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> { - for (i, line) in text.lines().enumerate() { - let trimmed = line.trim(); - let offset = (line.len() - trimmed.len()) as u32; - - if let Some(rest) = trimmed.strip_prefix("@let ") { - let rest_trimmed = rest.trim(); - // Match variable or attribute bundle: @let name value / @let name [...] - if let Some((n, _)) = rest_trimmed.split_once(' ') { - if n == name { - let col = offset + "@let ".len() as u32; - return Some((i as u32, col, n.len() as u32)); - } - // Check function parameters - let parts: Vec<&str> = rest_trimmed.split_whitespace().collect(); - for param in &parts[1..] { - let p = param.strip_prefix('$').unwrap_or(param); - let p = p.split('=').next().unwrap_or(p); - if p == name - && let Some(pos) = line.find(param) - { - return Some((i as u32, pos as u32, param.len() as u32)); - } - } - } else if rest_trimmed == name { - // @let name (no value, function with no params) - let col = offset + "@let ".len() as u32; - return Some((i as u32, col, name.len() as u32)); - } - } - } - None +/// Where the `$name` a reference uses is defined: a value, an attribute +/// bundle, or a function's parameter. +pub(crate) fn find_definition(text: &str, name: &str) -> Option { + let defs = tree::definitions(text); + defs.iter() + .find(|d| d.kind != DefinitionKind::Function && d.name == name) + .map(|d| d.name_range) + .or_else(|| { + defs.iter() + .flat_map(|d| &d.params) + .find(|p| p.name == name) + .map(|p| p.name_range) + }) } -/// Find @let function definition for an @name function call. -pub(crate) fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> { - let lines: Vec<&str> = text.lines().collect(); - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - let offset = (line.len() - trimmed.len()) as u32; - - if let Some(rest) = trimmed.strip_prefix("@let ") { - let parts: Vec<&str> = rest.split_whitespace().collect(); - if parts.first() == Some(&name) { - // Verify it's a function (has body or params) - let has_body = lines - .get(i + 1) - .map(|l| l.starts_with(" ") || l.starts_with('\t')) - .unwrap_or(false); - if has_body { - let col = offset + "@let ".len() as u32; - return Some((i as u32, col, name.len() as u32)); - } - } - } - } - None +/// Where the function an `@name` call uses is defined. +pub(crate) fn find_fn_definition(text: &str, name: &str) -> Option { + tree::definitions(text) + .into_iter() + .find(|d| d.kind == DefinitionKind::Function && d.name == name) + .map(|d| d.name_range) } // --------------------------------------------------------------------------- @@ -176,135 +134,52 @@ pub(crate) fn rename_at( .unwrap_or(new_name); let mut edits = Vec::new(); + let edit = |range: Range, new_text: String| TextEdit { range, new_text }; - for (i, line) in text.lines().enumerate() { - let line_num = i as u32; - let trimmed = line.trim(); - + // The definitions, where the name is written without its sigil + for def in tree::definitions(text) { if is_var { - // Rename @let definition - if let Some(rest) = trimmed.strip_prefix("@let ") - && let Some((n, _)) = rest.trim().split_once(' ') - && n == name - && let Some(pos) = line.find(n) - { - edits.push(TextEdit { - range: Range::new( - Position::new(line_num, pos as u32), - Position::new(line_num, (pos + n.len()) as u32), - ), - new_text: new_base.to_string(), - }); + if def.kind != DefinitionKind::Function && def.name == name { + edits.push(edit(def.name_range, new_base.to_string())); } - - // Rename @let attribute bundle or parameter definitions - if let Some(rest) = trimmed.strip_prefix("@let ") { - let rest_trimmed = rest.trim(); - // Attribute bundle: @let name [...] - if let Some(bracket) = rest_trimmed.find('[') { - let n = rest_trimmed[..bracket].trim(); - if n == name - && let Some(pos) = line.find(n) - { - edits.push(TextEdit { - range: Range::new( - Position::new(line_num, pos as u32), - Position::new(line_num, (pos + n.len()) as u32), - ), - new_text: new_base.to_string(), - }); - continue; // Don't also match $ references on this line - } - } - // Function parameter definitions - let parts: Vec<&str> = rest_trimmed.split_whitespace().collect(); - for param in &parts[1..] { - let p = param.strip_prefix('$').unwrap_or(param); - let p = p.split('=').next().unwrap_or(p); - if p == name - && let Some(pos) = line.find(param) - { - let prefix = if param.starts_with('$') { "$" } else { "" }; - edits.push(TextEdit { - range: Range::new( - Position::new(line_num, pos as u32), - Position::new(line_num, (pos + param.len()) as u32), - ), - new_text: format!("{}{}", prefix, new_base), - }); - } - } - } - - // Rename all $name references - let search = format!("${}", name); - let replace = format!("${}", new_base); - let mut offset = 0; - while let Some(pos) = line[offset..].find(&search) { - let abs_pos = offset + pos; - // Check it's not part of a longer identifier - let after = abs_pos + search.len(); - let is_end = after >= line.len() - || !line.as_bytes()[after].is_ascii_alphanumeric() - && line.as_bytes()[after] != b'-' - && line.as_bytes()[after] != b'_'; - if is_end { - edits.push(TextEdit { - range: Range::new( - Position::new(line_num, abs_pos as u32), - Position::new(line_num, (abs_pos + search.len()) as u32), - ), - new_text: replace.clone(), - }); - } - offset = abs_pos + search.len(); - } - } else { - // Rename @let function definition - if let Some(rest) = trimmed.strip_prefix("@let ") { - let parts: Vec<&str> = rest.split_whitespace().collect(); - if parts.first() == Some(&name) - && let Some(pos) = line.find(&format!("@let {}", name)) - { - let start = pos + 5; // skip "@let " - edits.push(TextEdit { - range: Range::new( - Position::new(line_num, start as u32), - Position::new(line_num, (start + name.len()) as u32), - ), - new_text: new_base.to_string(), - }); - } + for param in def.params.iter().filter(|p| p.name == name) { + edits.push(edit(param.name_range, new_base.to_string())); } + } else if def.kind == DefinitionKind::Function && def.name == name { + edits.push(edit(def.name_range, new_base.to_string())); + } + } - // Rename @name function calls - let search = format!("@{}", name); - let replace = format!("@{}", new_base); - let mut offset = 0; - while let Some(pos) = line[offset..].find(&search) { - let abs_pos = offset + pos; - // Don't match @let definition (handled above) - if trimmed.starts_with("@let ") { - offset = abs_pos + search.len(); - continue; - } - // Check it's not part of a longer identifier - let after = abs_pos + search.len(); - let is_end = after >= line.len() - || !line.as_bytes()[after].is_ascii_alphanumeric() - && line.as_bytes()[after] != b'-' - && line.as_bytes()[after] != b'_'; - if is_end { - edits.push(TextEdit { - range: Range::new( - Position::new(line_num, abs_pos as u32), - Position::new(line_num, (abs_pos + search.len()) as u32), - ), - new_text: replace.clone(), - }); + // The references: `$name` or `@name`, outside verbatim bodies + let verbatim = tree::verbatim_lines(&htmlang::syntax::parse(text)); + let sigil = if is_var { '$' } else { '@' }; + let search = format!("{}{}", sigil, name); + let replace = format!("{}{}", sigil, new_base); + for (i, line) in text.lines().enumerate() { + let line_num = i as u32; + if verbatim.contains(&line_num) { + continue; + } + let mut offset = 0; + while let Some(pos) = line[offset..].find(&search) { + let abs_pos = offset + pos; + let after = abs_pos + search.len(); + // Not part of a longer name + let is_end = line[after..] + .chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || c == '-' || c == '_')); + if is_end { + let range = Range::new( + Position::new(line_num, abs_pos as u32), + Position::new(line_num, after as u32), + ); + // A parameter's `$` belongs to the edit above + if !edits.iter().any(|e| overlaps(&e.range, &range)) { + edits.push(edit(range, replace.clone())); } - offset = abs_pos + search.len(); } + offset = after; } } @@ -321,6 +196,12 @@ pub(crate) fn rename_at( }) } +fn overlaps(a: &Range, b: &Range) -> bool { + a.start.line == b.start.line + && a.start.character < b.end.character + && b.start.character < a.end.character +} + // --------------------------------------------------------------------------- // Linked editing ranges // --------------------------------------------------------------------------- @@ -558,3 +439,34 @@ pub(crate) fn find_references(text: &str, position: Position, uri: &Url) -> Vec< locations } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rename_edits_definitions_and_references_once() { + let text = "@let card $title $tone=info\n @el [color $tone] $title\n@style\n .x { content: \"$title\" }\n@card [title Hi]\n"; + let uri = Url::parse("file:///tmp/none/page.hl").unwrap(); + let edit = rename_at(text, Position::new(1, 22), "heading", &uri).expect("rename"); + let edits = edit.changes.unwrap().remove(&uri).unwrap(); + let mut at: Vec<(u32, u32, &str)> = edits + .iter() + .map(|e| { + ( + e.range.start.line, + e.range.start.character, + e.new_text.as_str(), + ) + }) + .collect(); + at.sort_unstable(); + // The parameter (name only) and the reference; not the CSS body + assert_eq!(at, [(0, 11, "heading"), (1, 20, "$heading")]); + + let found = find_fn_definition(text, "card").expect("definition"); + assert_eq!(found.start, Position::new(0, 5)); + let found = find_definition(text, "tone").expect("parameter"); + assert_eq!(found.start, Position::new(0, 18)); + } +} diff --git a/src/bin/htmlang_lsp/state.rs b/src/bin/htmlang_lsp/state.rs index 0ceb381..c2831cd 100644 --- a/src/bin/htmlang_lsp/state.rs +++ b/src/bin/htmlang_lsp/state.rs @@ -15,23 +15,41 @@ use crate::analysis::document_symbols; pub struct DocumentEntry { pub text: String, pub version: i32, + /// The directory of the file, so relative `@include` and `@data` paths + /// resolve as they do on the command line. `None` for unsaved buffers. + pub base: Option, parse: OnceLock>, + tree: OnceLock>, symbols: OnceLock>>, } impl DocumentEntry { - pub fn new(text: String, version: i32) -> Self { + pub fn new(text: String, version: i32, base: Option) -> Self { Self { text, version, + base, parse: OnceLock::new(), + tree: OnceLock::new(), symbols: OnceLock::new(), } } pub fn parse(&self) -> Arc { self.parse - .get_or_init(|| Arc::new(htmlang::parser::parse(&self.text))) + .get_or_init(|| { + Arc::new(htmlang::parser::parse_with_base( + &self.text, + self.base.as_deref(), + )) + }) + .clone() + } + + /// The syntax tree the compiler evaluates, shared by every feature. + pub fn tree(&self) -> Arc { + self.tree + .get_or_init(|| Arc::new(htmlang::syntax::parse(&self.text))) .clone() } @@ -42,6 +60,13 @@ impl DocumentEntry { } } +/// The directory a document's relative paths resolve against. +pub fn base_dir(uri: &Url) -> Option { + uri.to_file_path() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) +} + /// Apply a single content change event to a string. Used when the LSP client /// negotiates incremental sync. `utf8` selects how `Position::character` is /// interpreted: UTF-8 bytes when the client agreed to the `utf-8` position diff --git a/src/bin/htmlang_lsp/tree.rs b/src/bin/htmlang_lsp/tree.rs new file mode 100644 index 0000000..a99f786 --- /dev/null +++ b/src/bin/htmlang_lsp/tree.rs @@ -0,0 +1,145 @@ +//! The compiler's syntax tree in the editor's terms: definitions, lines and +//! ranges. Every feature that needs to know what a line *is* (a `@let`, an +//! `@include`, a verbatim body) asks here instead of matching raw text. + +use htmlang::syntax::{self, DefinitionKind, Node, NodeKind, Span}; +use tower_lsp::lsp_types::{Position, Range}; + +/// A `@let`, with LSP positions (0-based lines, byte columns). +#[derive(Clone, Debug)] +pub(crate) struct Def { + pub name: String, + pub kind: DefinitionKind, + pub name_range: Range, + /// The `@let` line. + pub line: u32, + /// The last line of the definition, its body included. + pub end_line: u32, + pub params: Vec, + /// The value as written (a value or a bundle). + pub value: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct Param { + pub name: String, + pub default: Option, + /// Just its name. + pub name_range: Range, +} + +/// A single-line span as an LSP range. +pub(crate) fn range(span: Span) -> Range { + let line = span.line.saturating_sub(1) as u32; + Range::new( + Position::new(line, span.column as u32), + Position::new( + line, + (span.column + span.end.saturating_sub(span.start)) as u32, + ), + ) +} + +/// Every `@let` in `text`, in source order. +pub(crate) fn definitions(text: &str) -> Vec { + let tree = syntax::parse(text); + tree.definitions() + .into_iter() + .map(|def| Def { + name: def.name.to_string(), + kind: def.kind, + name_range: range(def.name_span), + line: def.node.span.line.saturating_sub(1) as u32, + end_line: def.node.end_line().saturating_sub(1) as u32, + params: def + .params + .iter() + .map(|p| { + let written = range(p.span); + let skip = u32::from(text[p.span.start..p.span.end].starts_with('$')); + let start = Position::new(written.start.line, written.start.character + skip); + Param { + name: p.name.clone(), + default: p.default.clone(), + name_range: Range::new( + start, + Position::new(start.line, start.character + p.name.len() as u32), + ), + } + }) + .collect(), + value: def.value, + }) + .collect() +} + +/// The 0-based lines of verbatim bodies (CSS, JavaScript, HTML, Markdown), +/// in which nothing is htmlang. +pub(crate) fn verbatim_lines(tree: &syntax::Tree) -> std::collections::HashSet { + let mut lines = std::collections::HashSet::new(); + tree.walk(&mut |node| { + if matches!(node.kind, NodeKind::Verbatim(_)) { + let first = node.span.line.saturating_sub(1) as u32; + for line in first..first + node.line_count as u32 { + lines.insert(line); + } + } + }); + lines +} + +/// The node whose own lines include the 0-based `line`. +pub(crate) fn node_at(tree: &syntax::Tree, line: u32) -> Option<&Node> { + tree.node_at_line(line as usize + 1) +} + +/// The file argument of a directive that names one (`@include`, +/// `@markdown`, `@data`), with its range: not inline JSON, an environment +/// variable or a glob. +pub(crate) fn file_argument(node: &Node) -> Option<(String, Range)> { + use syntax::DirectiveArgs; + let arg = match &node.directive()?.args { + DirectiveArgs::Text(Some(arg)) if node.is_directive("include") => arg, + DirectiveArgs::Text(Some(arg)) if node.is_directive("markdown") => arg, + DirectiveArgs::Data { source, .. } => source, + _ => return None, + }; + let path = arg.raw.trim_matches('"'); + let is_file = !path.is_empty() + && !path.starts_with(['[', '{']) + && !path.starts_with("env:") + && !path.contains(['*', '?', '$']); + is_file.then(|| { + let offset = arg.raw.find(path).unwrap_or(0); + let mut r = range(arg.span); + r.start.character += offset as u32; + r.end.character = r.start.character + path.len() as u32; + (path.to_string(), r) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn definitions_come_from_the_tree() { + let text = "@let a 1\n@let card $title $tone=x\n @el $title\n@let b [padding 4]\n@let c = 1 + 1\n@el\n @let d 1\n"; + let defs = definitions(text); + let names: Vec<_> = defs.iter().map(|d| (d.name.as_str(), d.kind)).collect(); + assert_eq!( + names, + [ + ("a", DefinitionKind::Value), + ("card", DefinitionKind::Function), + ("b", DefinitionKind::Bundle), + ("c", DefinitionKind::Value), + ("d", DefinitionKind::Value), + ] + ); + let card = &defs[1]; + assert_eq!((card.line, card.end_line), (1, 2)); + assert_eq!(card.params[1].name_range.start, Position::new(1, 18)); + assert_eq!(card.name_range.start, Position::new(1, 5)); + } +} diff --git a/src/fmt.rs b/src/fmt.rs index 08b5f1a..2679389 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -1,428 +1,248 @@ +//! The formatter: prints the syntax tree back with normalized indentation +//! (two spaces per level) and attribute lists (`[a, b]`, wrapped one per +//! line when long). Comments, blank lines, text and verbatim bodies are +//! kept as written. + +use crate::syntax::{self, AttrList, DirectiveArgs, LetForm, Node, NodeKind}; + const MAX_LINE_WIDTH: usize = 100; /// Once a line has already been wrapped over multiple attribute lines, we keep it /// wrapped even after formatting as long as the attribute count stays >1. This /// prevents format→format→format oscillation around the wrap threshold. const WRAP_MIN_WIDTH: usize = 80; -/// Split an attribute list on top-level commas: commas inside `(...)`, -/// `[...]`, `{...}` or `"..."` belong to the value (e.g. `rgba(0,0,0,0.1)`). -fn split_attrs(attrs_str: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut depth = 0i32; - let mut in_str = false; - let mut start = 0; - for (i, c) in attrs_str.char_indices() { - match c { - '"' => in_str = !in_str, - '(' | '[' | '{' if !in_str => depth += 1, - ')' | ']' | '}' if !in_str => depth -= 1, - ',' if !in_str && depth <= 0 => { - parts.push(&attrs_str[start..i]); - start = i + 1; - } - _ => {} - } +/// Format an htmlang source file. +pub fn format(input: &str) -> String { + let tree = syntax::parse(input); + let lines: Vec<&str> = input.lines().collect(); + let mut out = String::new(); + for node in &tree.nodes { + print_node(node, 0, &lines, &mut out); } - parts.push(&attrs_str[start..]); - parts - .into_iter() - .map(|p| p.trim()) - .filter(|p| !p.is_empty()) - .collect() + out } -/// Normalize spacing in a comma-separated attribute string. Attribute order -/// is preserved: later attributes can override earlier ones (e.g. -/// `center-x, margin 0`), so reordering could change the output. -fn normalize_attrs(attrs_str: &str) -> String { - split_attrs(attrs_str).join(", ") +/// A formatted header line, and where its first attribute list is (for +/// wrapping). +struct Header { + text: String, + /// Byte range of the first `[...]` in `text`, and its attributes. + list: Option<(usize, usize, Vec)>, } -/// Bracket depth left open by the attribute lists on a (possibly joined) -/// line. Only brackets that start an attribute list count: one at the start -/// of the line, one right after the `@name` (or `@let name`) head, or one -/// after a chained `> @name`. Brackets in trailing text are ignored, so -/// `@text Use [ to open` does not start a multi-line block. -fn open_attr_depth(line: &str) -> i32 { - let mut depth = 0i32; - let mut region_seen = false; - for (i, b) in line.bytes().enumerate() { - if depth > 0 { - match b { - b'[' => depth += 1, - b']' => depth -= 1, - _ => {} - } - continue; +impl Header { + fn new() -> Self { + Header { + text: String::new(), + list: None, } - if b != b'[' { - continue; + } + + fn push(&mut self, s: &str) { + self.text.push_str(s); + } + + fn push_list(&mut self, list: &AttrList) { + let attrs: Vec = list.attrs.iter().map(|a| a.raw.clone()).collect(); + let start = self.text.len(); + self.text.push('['); + self.text.push_str(&attrs.join(", ")); + self.text.push(']'); + if self.list.is_none() { + self.list = Some((start, self.text.len(), attrs)); } - let before = line[..i].trim_end(); - let starts_region = if !region_seen { - // Only `@name` or `@name word` may precede the first list. - let mut tokens = before.split_whitespace(); - match (tokens.next(), tokens.next(), tokens.next()) { - (None, _, _) => true, - (Some(t), None, _) | (Some(t), Some(_), None) => t.starts_with('@'), - _ => false, + } +} + +fn print_node(node: &Node, level: usize, lines: &[&str], out: &mut String) { + let indent = " ".repeat(level); + // Verbatim children keep their offset from the header. + let delta = (level * 2) as i32 - node.indent as i32; + match &node.kind { + NodeKind::Blank => out.push('\n'), + NodeKind::Comment => { + out.push_str(&indent); + out.push_str(&node.source); + out.push('\n'); + } + NodeKind::Verbatim(_) => print_raw(node, lines, delta, false, out), + _ => match header(node) { + // An attribute list that never closed: keep the lines unchanged + _ if is_open(node) => { + for line in own_lines(node, lines) { + out.push_str(line); + out.push('\n'); + } } - } else { - // A later list belongs to a chained element: `... > @link [`. - let mut tokens = before.split_whitespace().rev(); - matches!((tokens.next(), tokens.next()), (Some(t), Some(">")) if t.starts_with('@')) - }; - if starts_region { - region_seen = true; - depth = 1; - } else if !region_seen { - // Text precedes the first bracket; nothing on this line opens an - // attribute list. - return 0; + // A multi-line header with comments in it (or inline JSON): + // re-indented, but otherwise as written + None => print_raw(node, lines, delta, true, out), + Some(_) if node.line_count > 1 && has_comment(node, lines) => { + print_raw(node, lines, delta, true, out) + } + Some(header) => print_header(header, node, level, out), + }, + } + for child in &node.children { + match child.kind { + NodeKind::Verbatim(_) => print_raw(child, lines, delta, false, out), + _ => print_node(child, level + 1, lines, out), } } - depth } -/// Directives whose indented body is foreign content (CSS, JS, Markdown, -/// JSON, raw HTML) that must not be reformatted. -fn is_raw_body_header(code: &str) -> bool { - ["@style", "@script", "@markdown", "@head", "@raw"] +/// The node's own source lines. +fn own_lines<'a>(node: &Node, lines: &[&'a str]) -> Vec<&'a str> { + let first = node.span.line.saturating_sub(1); + let last = (first + node.line_count).min(lines.len()); + lines[first.min(last)..last].to_vec() +} + +fn has_comment(node: &Node, lines: &[&str]) -> bool { + own_lines(node, lines) .iter() - .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' '))) + .skip(1) + .any(|l| l.trim_start().starts_with("--")) } -/// Re-indent a verbatim line by `delta` bytes, keeping its relative -/// indentation. Removes at most the existing leading whitespace. -fn shift_line(line: &str, delta: i32) -> String { - if delta >= 0 { - format!("{}{}", " ".repeat(delta as usize), line) - } else { - let lead = line.len() - line.trim_start().len(); - let remove = (delta.unsigned_abs() as usize).min(lead); - line[remove..].to_string() +/// Whether an attribute list on the line never closed. +fn is_open(node: &Node) -> bool { + let open = |list: &Option| list.as_ref().is_some_and(|l| !l.closed); + match &node.kind { + NodeKind::Element(line) => line.chain.iter().any(|h| open(&h.attrs)), + NodeKind::Directive(d) => match &d.args { + DirectiveArgs::Page { attrs, .. } => open(attrs), + DirectiveArgs::Let(def) => matches!(&def.form, LetForm::Bundle(l) if !l.closed), + _ => false, + }, + _ => false, } } -/// Split a line into (code, trailing_comment). The comment starts at the first `--` -/// that lies outside quoted strings and outside `[...]` attribute lists. Returns -/// (whole_line, None) if there is no trailing comment. -fn split_trailing_comment(line: &str) -> (&str, Option<&str>) { - let bytes = line.as_bytes(); - let mut in_str: Option = None; - let mut bracket_depth = 0i32; - let mut i = 0; - while i + 1 < bytes.len() { - let c = bytes[i]; - match in_str { - Some(q) => { - if c == b'\\' { - i += 2; - continue; - } else if c == q { - in_str = None; - } - } - None => match c { - b'"' | b'\'' => in_str = Some(c), - b'[' => bracket_depth += 1, - b']' => bracket_depth -= 1, - b'-' if bytes[i + 1] == b'-' - && bracket_depth == 0 - // Require a space (or start-of-line) before the `--` so we - // don't split inside identifiers or CSS values like `a--b`. - && (i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') => - { - let (before, after) = line.split_at(i); - return (before.trim_end(), Some(after)); - } - _ => {} - }, +/// Print a node's own lines, shifted by `delta` columns. With +/// `first_at_level`, the first line is re-indented instead. +fn print_raw(node: &Node, lines: &[&str], delta: i32, first_at_level: bool, out: &mut String) { + for (i, line) in own_lines(node, lines).into_iter().enumerate() { + if line.trim().is_empty() { + out.push('\n'); + continue; + } + if i == 0 && first_at_level { + let level_indent = (node.indent as i32 + delta).max(0) as usize; + out.push_str(&" ".repeat(level_indent)); + out.push_str(line.trim()); + } else { + out.push_str(&shift_line(line, delta)); } - i += 1; + out.push('\n'); } - (line, None) } -/// Format an htmlang source file with normalized indentation (2 spaces per level) -/// and cleaned-up whitespace. Bodies of raw-content directives (`@style`, -/// `@script`, ...) are kept verbatim apart from a uniform shift. -pub fn format(input: &str) -> String { - let mut output = String::new(); - let mut indent_stack: Vec = vec![-1]; // sentinel - let mut bracket_depth: i32 = 0; - let mut bracket_base_level: usize = 0; - let mut bracket_content = String::new(); - // Original lines of the pending multi-line bracket block, emitted as-is - // (re-indented) if the block holds comments, or verbatim if it never closes. - let mut bracket_raw: Vec<&str> = Vec::new(); - let mut bracket_has_comment = false; - let mut bracket_delta: i32 = 0; - let mut pending_comment: Option = None; - // Inside a raw-content body: (header raw indent, indent delta). - let mut raw_body: Option<(i32, i32)> = None; - - for line in input.lines() { - let trimmed = line.trim(); - - if let Some((header_indent, delta)) = raw_body { - if trimmed.is_empty() { - output.push('\n'); - continue; +/// The canonical text of a line, or `None` to keep it as written +/// (multi-line inline JSON). +fn header(node: &Node) -> Option
{ + let mut h = Header::new(); + match &node.kind { + NodeKind::Text(_) => { + if node.line_count > 1 { + return None; } - let raw_indent = (line.len() - line.trim_start().len()) as i32; - if raw_indent > header_indent { - output.push_str(&shift_line(line, delta)); - output.push('\n'); - continue; - } - raw_body = None; + h.push(&node.source); } - - // Inside multi-line bracket continuation — collect content. Blank and - // comment lines are skipped by the parser here, so they must not end - // up in the attribute list. - if bracket_depth > 0 { - bracket_raw.push(line); - if trimmed.is_empty() { - continue; - } - if trimmed.starts_with("--") { - bracket_has_comment = true; - continue; - } - bracket_content.push(' '); - bracket_content.push_str(trimmed); - bracket_depth = open_attr_depth(&bracket_content); - if bracket_depth <= 0 { - let level = bracket_base_level; - let raw_lines = std::mem::take(&mut bracket_raw); - let full = std::mem::take(&mut bracket_content); - if bracket_has_comment { - // Keep the block as written (re-indented) so comments stay - // where the author put them. - bracket_has_comment = false; - pending_comment = None; - for (i, raw) in raw_lines.iter().enumerate() { - if i == 0 { - output.push_str(&" ".repeat(level)); - output.push_str(raw.trim()); - } else if !raw.trim().is_empty() { - output.push_str(&shift_line(raw, bracket_delta)); - } - output.push('\n'); - } - continue; - } - let formatted = format_line_with_brackets(&full); - let attrs_count = count_attrs(&formatted); - let indented_len = level * 2 + formatted.len(); - // The original was wrapped, so stay wrapped above WRAP_MIN_WIDTH (hysteresis). - let should_wrap = (indented_len > MAX_LINE_WIDTH) - || (attrs_count > 1 && indented_len > WRAP_MIN_WIDTH); - if should_wrap && let Some(mut wrapped) = wrap_attrs(&formatted, level) { - if let Some(cmt) = pending_comment.take() { - if wrapped.ends_with('\n') { - wrapped.pop(); - } - wrapped.push(' '); - wrapped.push_str(&cmt); - wrapped.push('\n'); - } - output.push_str(&wrapped); - continue; + NodeKind::Element(line) => { + for (i, head) in line.chain.iter().enumerate() { + if i > 0 { + h.push(" > "); } - output.push_str(&" ".repeat(level)); - output.push_str(&formatted); - if let Some(cmt) = pending_comment.take() { - output.push(' '); - output.push_str(&cmt); + h.push("@"); + h.push(&head.name); + if let Some(list) = &head.attrs { + h.push(" "); + h.push_list(list); } - output.push('\n'); } - continue; - } - - // Preserve blank lines - if trimmed.is_empty() { - output.push('\n'); - continue; - } - - // Preserve comments at current indent level - if trimmed.starts_with("--") { - let raw_indent = (line.len() - line.trim_start().len()) as i32; - while indent_stack.len() > 1 && *indent_stack.last().unwrap() >= raw_indent { - indent_stack.pop(); + if let Some(text) = &line.text { + // Text continued over lines (an inline element's list): as + // written + if text.span.line != node.span.line + node.line_count - 1 { + return None; + } + h.push(" "); + h.push(&text.raw); } - let level = indent_stack.len() - 1; - output.push_str(&" ".repeat(level)); - output.push_str(trimmed); - output.push('\n'); - indent_stack.push(raw_indent); - continue; - } - - let raw_indent = (line.len() - line.trim_start().len()) as i32; - - // Pop stack to find parent - while indent_stack.len() > 1 && *indent_stack.last().unwrap() >= raw_indent { - indent_stack.pop(); - } - - let level = indent_stack.len() - 1; - let is_code = trimmed.starts_with('@'); - - // Split off a trailing `-- comment` before doing bracket math. - let (code, trailing) = split_trailing_comment(trimmed); - let code = code.trim_end(); - let trailing = trailing.map(|s| s.trim().to_string()); - - bracket_depth = if is_code { open_attr_depth(code) } else { 0 }; - if bracket_depth > 0 { - // Start of multi-line bracket - bracket_base_level = level; - bracket_content = code.to_string(); - bracket_raw = vec![line]; - bracket_has_comment = false; - bracket_delta = (level * 2) as i32 - raw_indent; - pending_comment = trailing; - indent_stack.push(raw_indent); - continue; } - - // Only element/attribute lines have attribute lists; text lines are - // left untouched so bracketed prose isn't rewritten. - let formatted = if is_code { - format_line_with_brackets(code) - } else { - code.to_string() - }; - - if is_raw_body_header(code) { - raw_body = Some((raw_indent, (level * 2) as i32 - raw_indent)); - } - - let indented_len = level * 2 + formatted.len(); - // Single-line emission — only wrap when we strictly exceed the ceiling. - if is_code - && indented_len > MAX_LINE_WIDTH - && formatted.contains('[') - && let Some(mut wrapped) = wrap_attrs(&formatted, level) - { - if let Some(cmt) = &trailing { - // Reattach comment on the final `]` line. - if wrapped.ends_with('\n') { - wrapped.pop(); + NodeKind::Directive(d) => match &d.args { + DirectiveArgs::Page { attrs, title } => { + h.push("@page"); + if let Some(list) = attrs { + h.push(" "); + h.push_list(list); + } + if let Some(title) = title { + h.push(" "); + h.push(&title.raw); } - wrapped.push(' '); - wrapped.push_str(cmt); - wrapped.push('\n'); } - output.push_str(&wrapped); - indent_stack.push(raw_indent); - continue; - } - - output.push_str(&" ".repeat(level)); - output.push_str(&formatted); - if let Some(cmt) = trailing { - output.push(' '); - output.push_str(&cmt); - } - output.push('\n'); - - indent_stack.push(raw_indent); - } - - // An attribute list that never closed: emit what we collected unchanged - // rather than dropping it. - for raw in bracket_raw { - output.push_str(raw); - output.push('\n'); + DirectiveArgs::Let(def) if matches!(def.form, LetForm::Bundle(_)) => { + let LetForm::Bundle(list) = &def.form else { + return None; + }; + h.push("@let "); + h.push(&def.name); + h.push(" "); + h.push_list(list); + } + _ if node.line_count > 1 => return None, + _ => h.push(&node.source), + }, + _ => return None, } - - output + Some(h) } -/// Find the byte range of the first top-level `[...]` in `line`, ignoring -/// brackets inside double-quoted strings. -fn find_attr_brackets(line: &str) -> Option<(usize, usize)> { - let bracket_start = line.find('[')?; - let mut depth = 0; - let mut in_str = false; - let mut prev = '\0'; - for (i, c) in line.char_indices().skip_while(|&(i, _)| i < bracket_start) { - if in_str { - if prev != '\\' && c == '"' { - in_str = false; - } - } else { - match c { - '"' => in_str = true, - '[' => depth += 1, - ']' => { - depth -= 1; - if depth == 0 { - return Some((bracket_start, i)); - } +fn print_header(header: Header, node: &Node, level: usize, out: &mut String) { + let indent = " ".repeat(level); + let len = level * 2 + header.text.len(); + let was_wrapped = node.line_count > 1; + if let Some((start, end, attrs)) = &header.list { + // Wrap when too long; a list that was already wrapped stays wrapped + // above the lower threshold (hysteresis). + let wrap = len > MAX_LINE_WIDTH || (was_wrapped && len > WRAP_MIN_WIDTH); + if wrap && attrs.len() > 1 { + out.push_str(&indent); + out.push_str(&header.text[..*start]); + out.push_str("[\n"); + for (i, attr) in attrs.iter().enumerate() { + out.push_str(&indent); + out.push_str(" "); + out.push_str(attr); + if i + 1 < attrs.len() { + out.push(','); } - _ => {} + out.push('\n'); } + out.push_str(&indent); + out.push(']'); + out.push_str(&header.text[*end..]); + out.push('\n'); + return; } - prev = c; - } - None -} - -/// Format a single line, normalizing the attribute list inside [...] brackets. -fn format_line_with_brackets(line: &str) -> String { - let Some((bracket_start, bracket_end)) = find_attr_brackets(line) else { - return line.to_string(); - }; - let before = &line[..bracket_start]; - let attrs_inner = &line[bracket_start + 1..bracket_end]; - let after = &line[bracket_end + 1..]; - format!("{}[{}]{}", before, normalize_attrs(attrs_inner), after) -} - -fn count_attrs(line: &str) -> usize { - match find_attr_brackets(line) { - Some((start, end)) => split_attrs(&line[start + 1..end]).len(), - None => 0, } + out.push_str(&indent); + out.push_str(&header.text); + out.push('\n'); } -fn wrap_attrs(line: &str, indent_level: usize) -> Option { - let (bracket_start, bracket_end) = find_attr_brackets(line)?; - let before = &line[..bracket_start]; - let attrs_inner = &line[bracket_start + 1..bracket_end]; - let after = &line[bracket_end + 1..]; - - let parts = split_attrs(attrs_inner); - if parts.len() <= 1 { - return None; - } - - let base_indent = " ".repeat(indent_level); - let attr_indent = " ".repeat(indent_level + 1); - let mut result = String::new(); - result.push_str(&base_indent); - result.push_str(before); - result.push_str("[\n"); - for (i, part) in parts.iter().enumerate() { - result.push_str(&attr_indent); - result.push_str(part); - if i < parts.len() - 1 { - result.push(','); - } - result.push('\n'); +/// Re-indent a verbatim line by `delta` bytes, keeping its relative +/// indentation. Removes at most the existing leading whitespace. +fn shift_line(line: &str, delta: i32) -> String { + if delta >= 0 { + format!("{}{}", " ".repeat(delta as usize), line) + } else { + let lead = line.len() - line.trim_start().len(); + let remove = (delta.unsigned_abs() as usize).min(lead); + line[remove..].to_string() } - result.push_str(&base_indent); - result.push(']'); - result.push_str(after); - result.push('\n'); - Some(result) } #[cfg(test)] @@ -442,6 +262,7 @@ mod tests { let a = format(src); let b = format(&a); assert_eq!(a, b, "formatter must be idempotent across runs"); + assert!(a.starts_with("@row [\n padding 20,\n"), "{a}"); } #[test] @@ -485,6 +306,14 @@ mod tests { fn bracket_in_text_does_not_join_lines() { let src = "@el\n Use [ to open a list\n @text [font-weight bold] Second\n"; assert_eq!(format(src), src); + let src = "@text Use [ to open\n@text next\n"; + assert_eq!(format(src), src); + } + + #[test] + fn urls_with_brackets_are_kept() { + let src = "@link /a[x,y] T\n@image [alt=a] /img[1].png\n"; + assert_eq!(format(src), src); } #[test] @@ -521,4 +350,28 @@ mod tests { assert_eq!(once, src); assert_eq!(format(&once), once); } + + #[test] + fn indentation_is_normalized_and_trivia_kept() { + let src = "-- header\n@el\n @text a\n\n -- note\n @row\n @text b\n"; + assert_eq!( + format(src), + "-- header\n@el\n @text a\n\n -- note\n @row\n @text b\n" + ); + } + + #[test] + fn multi_line_json_is_kept() { + let src = "@data $team [\n {\"name\": \"Ada\", \"role\": \"Engineering, research\"},\n {\"name\": \"Grace\"}\n]\n@text x\n"; + assert_eq!(format(src), src); + } + + #[test] + fn heads_are_normalized() { + let src = "@el[padding 4] > @link /x Go\n@let card [padding 4,color red]\n@page [lang en] Home\n"; + assert_eq!( + format(src), + "@el [padding 4] > @link /x Go\n@let card [padding 4, color red]\n@page [lang en] Home\n" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index ee89e6d..1b1b292 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ pub use htmlang_core::ast; pub use htmlang_core::codegen; +pub use htmlang_core::diagnostic; pub use htmlang_core::parser; +pub use htmlang_core::syntax; pub use htmlang_core::vocab; pub mod fmt; pub mod serve; diff --git a/src/main.rs b/src/main.rs index 6ba11d6..2459826 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,17 @@ use std::sync::Mutex; struct DiagnosticJson { file: String, line: usize, + column: Option, + code: &'static str, severity: String, message: String, } +/// `error[unknown-element]`: the severity and the diagnostic's stable code. +fn diagnostic_label(d: &htmlang::parser::Diagnostic) -> String { + format!("{}[{}]", severity_label(d.severity), d.code) +} + fn severity_label(s: htmlang::parser::Severity) -> &'static str { match s { htmlang::parser::Severity::Error => "error", @@ -46,6 +53,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec) { collector.lock().unwrap().push(DiagnosticJson { file: input_path.to_string(), line: 0, + column: None, + code: htmlang::diagnostic::code::UNREADABLE_FILE, severity: "error".to_string(), message: format!("{}", e), }); @@ -67,6 +76,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec) { collected.push(DiagnosticJson { file: input_path.to_string(), line: d.line, + column: d.column.map(|c| c + 1), + code: d.code, severity: severity_label(d.severity).to_string(), message: d.message.clone(), }); @@ -74,7 +85,7 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec) { } } else { for d in &result.diagnostics { - let prefix = severity_label(d.severity); + let prefix = diagnostic_label(d); if let Some(col) = d.column { eprintln!("{}: line {}:{}: {}", prefix, d.line, col + 1, d.message); } else { @@ -174,6 +185,12 @@ fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) { json_object(&[ ("file", json_escape_string(&d.file)), ("line", d.line.to_string()), + ( + "column", + d.column + .map_or_else(|| "null".to_string(), |c| c.to_string()), + ), + ("code", json_escape_string(d.code)), ("severity", json_escape_string(&d.severity)), ("message", json_escape_string(&d.message)), ]) @@ -348,15 +365,7 @@ fn lint_file(path: &str) -> Vec { .diagnostics .iter() .chain(&lint) - .map(|d| { - format!( - "{}:{}:{}: {}", - path, - d.line, - severity_label(d.severity), - d.message - ) - }) + .map(|d| format!("{}:{}:{}: {}", path, d.line, diagnostic_label(d), d.message)) .collect() } diff --git a/tests/docs.rs b/tests/docs.rs index 92890ab..3fedeb2 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -6,6 +6,7 @@ use std::path::Path; +use htmlang::diagnostic::code; use htmlang::parser::{self, Severity}; fn code_blocks(markdown: &str) -> Vec { @@ -48,12 +49,19 @@ fn problems(result: &parser::ParseResult) -> Vec { .iter() .filter(|d| { d.severity == Severity::Error - || d.message.contains("unknown") - || d.message.contains("undefined variable") - || d.message.contains("is an HTML attribute") - || d.message.contains("no single root") + || [ + code::UNKNOWN_ELEMENT, + code::UNKNOWN_ATTRIBUTE, + code::UNKNOWN_PAGE_ATTRIBUTE, + code::UNKNOWN_COLOR, + code::INVALID_VALUE, + code::UNDEFINED_VARIABLE, + code::HTML_ATTRIBUTE_FORM, + code::NO_SINGLE_ROOT, + ] + .contains(&d.code) }) - .map(|d| format!("line {}: {}", d.line, d.message)) + .map(|d| format!("line {}: {} [{}]", d.line, d.message, d.code)) .collect() } diff --git a/tests/editor.rs b/tests/editor.rs new file mode 100644 index 0000000..c698b4a --- /dev/null +++ b/tests/editor.rs @@ -0,0 +1,61 @@ +//! The editor grammar's word lists come from the compiler's tables: this +//! test fails when a directive, an element or an attribute is added, +//! renamed or removed in one place and not the other. + +use std::collections::BTreeSet; + +use htmlang::ast::{DIRECTIVES, ElementKind}; + +fn grammar() -> serde_json::Value { + let text = std::fs::read_to_string("editors/vscode/syntaxes/htmlang.tmLanguage.json") + .expect("read the TextMate grammar"); + serde_json::from_str(&text).expect("the TextMate grammar is JSON") +} + +/// The alternatives of the first `(?:a|b|c)` group in a pattern. +fn alternatives(pattern: &str) -> BTreeSet { + let start = pattern.find("(?:").expect("an alternation group") + 3; + let end = start + pattern[start..].find(')').expect("a closed group"); + pattern[start..end].split('|').map(str::to_string).collect() +} + +fn pattern(grammar: &serde_json::Value, rule: &str) -> String { + grammar["repository"][rule]["match"] + .as_str() + .unwrap_or_else(|| panic!("the grammar has no `{}` rule", rule)) + .to_string() +} + +#[test] +fn grammar_directives_are_the_compilers() { + let grammar = grammar(); + let listed = alternatives(&pattern(&grammar, "directive")); + let compiled: BTreeSet = DIRECTIVES.iter().map(|d| d.name.to_string()).collect(); + assert_eq!( + listed, compiled, + "update the `directive` rule of the TextMate grammar" + ); +} + +#[test] +fn grammar_elements_are_the_compilers() { + let grammar = grammar(); + let listed = alternatives(&pattern(&grammar, "element")); + let compiled: BTreeSet = ElementKind::all_names().map(str::to_string).collect(); + assert_eq!( + listed, compiled, + "update the `element` rule of the TextMate grammar" + ); +} + +#[test] +fn grammar_attribute_names_are_style_attributes() { + let grammar = grammar(); + for name in alternatives(&pattern(&grammar, "attribute-name")) { + assert!( + htmlang::vocab::is_style_attribute(&name), + "the grammar highlights `{}`, which isn't an attribute", + name + ); + } +} diff --git a/tests/fmt.rs b/tests/fmt.rs new file mode 100644 index 0000000..4bfdcf6 --- /dev/null +++ b/tests/fmt.rs @@ -0,0 +1,55 @@ +//! The formatter over every htmlang file in the repository: formatting is +//! idempotent, keeps every comment, and never changes what a file compiles +//! to. + +use std::path::{Path, PathBuf}; + +use htmlang::{codegen, fmt, parser}; + +fn sources() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + for dir in ["examples", "tests/snapshots"] { + for entry in std::fs::read_dir(root.join(dir)).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "hl") { + files.push(path); + } + } + } + files.sort(); + files +} + +fn compile(source: &str, dir: &Path) -> String { + codegen::generate(&parser::parse_with_base(source, Some(dir)).document) +} + +#[test] +fn formatting_is_idempotent_and_keeps_the_output() { + for path in sources() { + let source = std::fs::read_to_string(&path).unwrap(); + let dir = path.parent().unwrap(); + let once = fmt::format(&source); + assert_eq!( + fmt::format(&once), + once, + "not idempotent: {}", + path.display() + ); + assert_eq!( + compile(&once, dir), + compile(&source, dir), + "formatting changed the output of {}", + path.display() + ); + let comments = |s: &str| { + s.lines() + .filter(|l| l.trim_start().starts_with("--")) + .map(str::trim) + .map(String::from) + .collect::>() + }; + assert_eq!(comments(&once), comments(&source), "{}", path.display()); + } +} diff --git a/tests/regressions.rs b/tests/regressions.rs index a497751..7c2193b 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -514,3 +514,163 @@ fn page_takes_lang_and_favicon_only() { result.diagnostics ); } + +// --- One grammar: the syntax tree and the checks built on it --- + +fn codes(input: &str) -> Vec<(usize, &'static str, Severity)> { + parser::parse(input) + .diagnostics + .iter() + .map(|d| (d.line, d.code, d.severity)) + .collect() +} + +fn has_code(input: &str, line: usize, code: &str) -> bool { + codes(input) + .iter() + .any(|(l, c, _)| *l == line && *c == code) +} + +#[test] +fn untaken_branches_are_checked() { + let src = "@let on true\n@if $on\n @text ok\n@else\n @nosuch [paddin 4]\n @el [paddin 4]\n"; + let result = parser::parse(src); + let nosuch = result + .diagnostics + .iter() + .find(|d| d.message.contains("unknown element @nosuch")) + .expect("unknown element in an untaken @else"); + assert_eq!((nosuch.line, nosuch.severity), (5, Severity::Error)); + assert_eq!(nosuch.code, "unknown-element"); + assert!(has_code(src, 6, "unknown-attribute"), "{:?}", codes(src)); +} + +#[test] +fn uncalled_functions_and_empty_loops_are_checked() { + // A function body can call a function defined later in the file + let src = "@let a\n @b\n @nosuch\n@let b\n @text hi\n@b\n"; + let found = codes(src); + assert!(has_code(src, 3, "unknown-element"), "{:?}", found); + assert!(!found.iter().any(|(l, _, _)| *l == 2), "{:?}", found); + // A loop over an empty list, and its `@else` when the list isn't empty + let src = "@data $e []\n@each $x in $e\n @bogus\n@each $y in 1..2\n $y\n@else\n @nope\n"; + assert!(has_code(src, 3, "unknown-element"), "{:?}", codes(src)); + assert!(has_code(src, 7, "unknown-element"), "{:?}", codes(src)); + // Inline elements in code that doesn't run warn, as they do when it runs + let src = "@if false\n Text with {@nosuch x}\n"; + assert!( + codes(src).contains(&(2, "unknown-element", Severity::Warning)), + "{:?}", + codes(src) + ); +} + +#[test] +fn names_used_only_in_code_that_does_not_run_are_used() { + let src = "@let color red\n@let card\n @el hi\n@if false\n @card [color $color]\n"; + let found = codes(src); + assert!( + !found + .iter() + .any(|(_, c, _)| *c == "unused-variable" || *c == "unused-function"), + "{:?}", + found + ); +} + +#[test] +fn unevaluated_names_include_functions_from_included_files() { + let dir = scratch_dir("static_include"); + std::fs::write(dir.join("lib.hl"), "@let helper\n @el\n @children\n").unwrap(); + std::fs::write(dir.join("bad.hl"), "@if false\n @oops\n").unwrap(); + let result = parser::parse_with_base( + "@let page-body\n @helper x\n@include lib.hl\n@include bad.hl\n", + Some(&dir), + ); + let unknown: Vec<_> = result + .diagnostics + .iter() + .filter(|d| d.code == "unknown-element") + .collect(); + assert_eq!(unknown.len(), 1, "{:?}", result.diagnostics); + assert!(unknown[0].message.contains("@oops"), "{:?}", unknown); + assert!(unknown[0].message.contains("in bad.hl"), "{:?}", unknown); +} + +#[test] +fn a_bracket_in_text_does_not_open_a_list() { + let out = compile("@text Use [ to open\n@text [color red] next\n"); + assert!(out.contains("Use [ to open"), "{}", out); + assert!(out.contains(">next<"), "{}", out); + let out = compile("@link /a[x,y] T\n"); + assert!(out.contains("href=\"/a[x,y]\""), "{}", out); +} + +#[test] +fn a_chain_is_only_between_element_heads() { + let out = compile("@text Ask me > @support\n"); + assert!( + out.contains("Ask me > @support") || out.contains("Ask me > @support"), + "{}", + out + ); +} + +#[test] +fn indented_lines_under_a_directive_without_a_body_are_errors() { + for src in [ + "@page Home\n @text x\n", + "@meta description A site\n @text x\n", + "@include x.hl\n @text x\n", + "@data $d [1]\n @text x\n", + "@let x = 1\n @text x\n", + "@let b [padding 4]\n @text x\n", + ] { + assert!( + has_code(src, 2, "unexpected-body"), + "{}: {:?}", + src, + codes(src) + ); + } + // Comments and blank lines may be indented anywhere + assert!(codes("@page Home\n -- note\n\n@text x\n").is_empty()); +} + +#[test] +fn verbatim_directives_take_a_line_or_a_body() { + let src = "@raw
\n
\n"; + assert!(has_code(src, 1, "unexpected-body"), "{:?}", codes(src)); + let src = "@style .a { color: red; }\n"; + assert!(has_code(src, 1, "unexpected-argument"), "{:?}", codes(src)); +} + +#[test] +fn multi_line_inline_json_object() { + let out = compile( + "@data $site {\n \"name\": \"Demo\",\n \"tags\": [\"a\", \"b\"]\n}\n@text $site.name\n", + ); + assert!(out.contains(">Demo<"), "{}", out); +} + +#[test] +fn script_attribute_list_may_span_lines() { + let out = compile("@script [\n type=module,\n defer\n]\n const a = [1, 2];\n"); + assert!(out.contains(""# + ), + "{}", + html + ); +} + +#[test] +fn page_attributes_and_attributes_with_no_root_are_errors() { + let d = parse_diagnostics("@page [lang en, colour red] T\n"); + assert_eq!(errors(&d).len(), 1, "{:?}", d); + assert_eq!(errors(&d)[0].code, "unknown-page-attribute"); + + let d = parse_diagnostics("@let @two\n @text a\n @text b\n@two [padding 4]\n"); + let found = coded(&d, "no-single-root"); + assert!( + found.len() == 1 && found[0].severity == htmlang::parser::Severity::Error, + "{:?}", + d + ); +} + +#[test] +fn a_bundle_is_checked_where_it_is_used() { + // Its `title` passes a parameter to a call... + let d = parse_diagnostics( + "@let t [title Hi, padding 4]\n@let @card [title]\n @el $title\n@card [$t]\n", + ); + assert!(d.is_empty(), "{:?}", d); + // ...and on an element it is an HTML attribute written like a style + let d = parse_diagnostics("@let t [title Hi, padding 4]\n@el [$t] x\n"); + let found = coded(&d, "html-attribute-form"); + assert_eq!(found.len(), 1, "{:?}", d); + assert_eq!(found[0].line, 2); + // A known style is checked where the bundle is defined + let d = parse_diagnostics("@let t [padding 4;]\n@el [$t] x\n"); + assert_eq!(coded(&d, "invalid-value").len(), 1, "{:?}", d); + assert_eq!(coded(&d, "invalid-value")[0].line, 1); +} + +#[test] +fn a_style_and_an_html_attribute_of_one_name_are_not_duplicates() { + let d = parse_diagnostics("@image [width=800, width 200, alt=A] a.png\n"); + assert!(d.is_empty(), "{:?}", d); + let d = parse_diagnostics("@el [padding 4, padding 8]\n x\n"); + let found = coded(&d, "duplicate-attribute"); + assert!( + found.len() == 1 && found[0].message.contains("the later one wins"), + "{:?}", + d + ); +} + +#[test] +fn a_warning_in_a_loop_or_a_function_is_reported_once() { + let d = parse_diagnostics( + "@let @box\n @el [colr red, color #12]\n @children\n@each $i in 1..5\n @box $i\n @el [bakground red] $i\n", + ); + assert_eq!(coded(&d, "unknown-attribute").len(), 2, "{:?}", d); + assert_eq!(coded(&d, "invalid-color").len(), 1, "{:?}", d); +} + +#[test] +fn hidden_is_a_boolean_attribute() { + let result = htmlang::parser::parse("@el [hidden] x\n"); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + assert!(htmlang::codegen::generate(&result.document).contains("