From 8ba01633cdbf91e9a3a1010f840ad3ac2b992cf9 Mon Sep 17 00:00:00 2001 From: Alex Walls Date: Mon, 27 Jul 2026 18:29:43 +0100 Subject: [PATCH] fix: recognize generic HTML-attribute processors (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `th:content`, `th:for`, `th:open` and ~160 other valid Standard Dialect attributes were flagged as "Unknown Thymeleaf attribute" because every consumer treated absence from the 42-entry `ATTRS` catalog as invalid. Recognition is now two-tier. `ATTRS` keeps the curated, hand-documented processors; `SPECIFIC_ATTRS` / `BOOLEAN_ATTRS` hold the generic `th:` names transcribed from the Thymeleaf tutorial (§5.2 "Setting value to specific attributes" and §5.5 "Fixed-value boolean attributes"). `classify` resolves the curated tier first, so hand-written docs always win, and also accepts `th:data-*` / `th:aria-*`, which Thymeleaf 3 routes through StandardDefaultAttributesTagProcessor. That same default processor means *any* `th:*` name is technically valid, so "not in the catalog" was never a sound basis for a warning. The diagnostic is re-aimed at typos: it fires only when the name is unrecognized *and* within `MAX_SUGGESTION_DISTANCE` of a real one, and names the intended attribute in the message. `closest_known` and `levenshtein` move from `code_actions` into `thymeleaf` so both features share one search — every warning now has a matching quick fix. The threshold drops from 3 to 2: with the candidate pool grown from 42 to ~200 names, distance 3 matches almost any short word by chance (`th:bogus` would "correct" to `th:cols`). Two edits still covers dropped letters, transpositions and doubled characters. Hover, semantic tokens and completion follow the same classifier, so generic attributes get documentation, highlighting and suggestions. Completion offers both tiers with `sort_text` ranking the curated ones first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014sSr14H6f4sgB7HAbyugMm --- CLAUDE.md | 6 +- README.md | 6 +- index.html | 9 + src/code_actions.rs | 115 ++++----- src/completion.rs | 110 +++++++-- src/diagnostics.rs | 99 ++++++-- src/hover.rs | 60 ++++- src/main.rs | 19 +- src/semantic_tokens.rs | 14 +- src/thymeleaf.rs | 549 ++++++++++++++++++++++++++++++++++++++++- 10 files changed, 856 insertions(+), 131 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f8e86a6..d083046 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,18 +32,18 @@ Feature modules follow a common shape: a small set of `pub fn`s taking `&Vault`/ Foundation: - **`vault`** — `Vault`: `HashMap` document store plus the workspace root; `upsert`/`apply_change`/`remove`/`get`/`uris`, a fragment index (`all_fragment_defs`, `find_fragment_definitions`), and `scan_workspace_html`. - **`document`** — `Document`: text + tree-sitter parse tree; `new`/`update`/`apply_change`; position<->byte-offset conversion (UTF-16 `character` at the LSP boundary, byte offsets internally for tree-sitter), `context_at` returning a `CursorContext` (`AttrName`/`AttrValue`/`TagName`/`Text`/`Other`), `node_at`, and `attributes()` -> `Vec` (name/value + byte ranges). -- **`thymeleaf`** — static Standard Dialect catalog (`ThymeleafAttr`) + expression-syntax/utility-object reference; `lookup` normalizes both `th:x` and `data-th-x` spellings. +- **`thymeleaf`** — static Standard Dialect catalog + expression-syntax/utility-object reference. Recognition is **two-tier**: `ATTRS` holds the curated, hand-documented processors, while `SPECIFIC_ATTRS`/`BOOLEAN_ATTRS` hold the generic `th:` names transcribed from the Thymeleaf tutorial (§5.2 / §5.5). `classify` -> `AttrKind` (`Catalog`/`Generic`/`Unknown`) is the entry point every consumer uses, with `is_known` and `describe` (owned `AttrInfo`, synthesizing docs for the generic tier) on top; `th:data-*`/`th:aria-*` also count as `Generic` because Thymeleaf 3's default attribute processor accepts any name. `lookup`/`normalize` still handle both `th:x` and `data-th-x` spellings. `closest_known` + `levenshtein` live here too (`MAX_SUGGESTION_DISTANCE = 2`) so diagnostics and code actions share one suggestion search — change matching rules here only. Features: - **`completion`** — attribute-name completions (filtered by partial) in `AttrName` context; expression/utility completions inside known `th:*` values. - **`hover`** — attribute docs on the name; contextual expression-syntax help inside `th:*` values. -- **`diagnostics`** — WARNING for unknown `th:*`/`data-th-*` attributes; ERROR for clearly unbalanced brackets in known-attribute values. +- **`diagnostics`** — WARNING for `th:*`/`data-th-*` names that look *misspelled*, i.e. unknown to `classify` **and** within `MAX_SUGGESTION_DISTANCE` of a real name (so every warning has a matching quick fix, and deliberate default-processor attributes stay silent); ERROR for clearly unbalanced brackets in any Thymeleaf attribute value. - **`navigation`** — fragment `goto` (reference -> definition) and `references` (all reference sites + definitions), parsing `~{tpl :: frag}` reference forms and `th:fragment="name(args)"` definitions. - **`symbols`** — `document_symbols` (fragments + `id` elements) and `workspace_symbols` (fragment search across the vault). - **`highlight`** — `document_highlight`: all occurrences of the fragment (def=WRITE/ref=READ) or attribute name under the cursor. - **`folding`** — `folding_ranges` from tree-sitter element/script/style/comment nodes spanning multiple lines. - **`links`** — `document_links` for `@{...}` link expressions (depth-aware brace/paren matching for path vars like `@{/o/{id}(...)}`) and concrete `href`/`src` URLs. -- **`code_actions`** — quick fixes for unknown `th:*` attributes: nearest known name by Levenshtein (distance <= 3) and "remove attribute". +- **`code_actions`** — quick fixes for unrecognized `th:*` attributes: nearest known name via `thymeleaf::closest_known` and "remove attribute". - **`rename`** — `prepare_rename` + `rename`: workspace-wide fragment rename, rewriting only the name token (preserving `(args)` and `~{tpl :: }` wrappers). - **`semantic_tokens`** — `legend()` (PROPERTY/MACRO/STRING/VARIABLE) + `semantic_tokens_full`: delta-encoded tokens for `th:*` names and expression markers. `data` is `Vec` (structs), not a flat u32 array. - **`fragmentref`** — shared fragment-attribute parsing used by `navigation`, `rename`, and `highlight`: `is_fragment_attr`/`is_reference_attr`, `definition_name_range`/`reference_name_range` (name-token byte range within a value), and the `definition_name`/`reference_name` slice helpers. Change fragment parsing rules here only — the consumers all delegate to this module. diff --git a/README.md b/README.md index 747aa7b..e948011 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ A [Language Server Protocol](https://microsoft.github.io/language-server-protoco | Feature | What you get | | --- | --- | -| **Completion** | `th:*` attribute names in tags (filtered as you type) and expression syntaxes inside `th:*` values. Triggers on `:`. | +| **Completion** | The full Standard Dialect attribute surface in tags — the structural processors first, then the generic `th:` ones — filtered as you type, plus expression syntaxes inside `th:*` values. Triggers on `:`. | | **Hover** | Documentation for Thymeleaf attributes and expression syntaxes. | -| **Diagnostics** | Warnings for unknown `th:*` attributes; errors for unbalanced expression brackets. | +| **Diagnostics** | Warnings for misspelled `th:*` attributes (with the intended name); errors for unbalanced expression brackets. | | **Goto-definition / References** | Jump between `th:fragment` definitions and `th:insert`/`th:replace`/`th:include` usages, across the whole workspace. | | **Rename** | Rename a fragment everywhere it is defined and referenced. | -| **Code actions** | Quick-fix an unknown `th:*` attribute (nearest-match suggestion) or remove it. | +| **Code actions** | Quick-fix a misspelled `th:*` attribute (nearest-match suggestion) or remove it. | | **Document / workspace symbols** | Outline of fragments and `id` elements; workspace-wide fragment search. | | **Document highlight** | Highlight every occurrence of the fragment/attribute under the cursor. | | **Semantic tokens** | Highlight `th:*` names and expression markers. | diff --git a/index.html b/index.html index 9767442..4bcd0df 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,8 @@ Static title + + @@ -28,6 +30,13 @@

Heading

Profile + + + +
+ Details +

details

+
diff --git a/src/code_actions.rs b/src/code_actions.rs index aac2967..7da8c5b 100644 --- a/src/code_actions.rs +++ b/src/code_actions.rs @@ -1,11 +1,14 @@ //! Quick-fix code actions for Thymeleaf attributes. //! //! Operates purely on a parsed [`Document`]. For each `th:*` / `data-th-*` -//! attribute whose name overlaps the requested range and is *not* part of the -//! known Standard Dialect catalog, this module offers: +//! attribute whose name overlaps the requested range and is *not* a documented +//! Standard Dialect processor, this module offers: //! * a "Change to `th:xxx`" quick fix that rewrites the name to the closest -//! known attribute (by Levenshtein distance, only when distance <= 3), and +//! known attribute ([`thymeleaf::closest_known`]), and //! * a "Remove `name`" quick fix that deletes the whole attribute. +//! +//! Recognition and the suggestion search both live in [`crate::thymeleaf`], so +//! these actions stay in lockstep with the diagnostics that surface them. use std::collections::HashMap; @@ -15,9 +18,6 @@ use tower_lsp::lsp_types::{ use crate::{document::Document, thymeleaf}; -/// Maximum Levenshtein distance at which a "change to" suggestion is offered. -const MAX_SUGGESTION_DISTANCE: usize = 3; - /// Computes the code actions available for `range` in `doc`. /// /// Returns quick fixes for the unknown Thymeleaf attributes whose name range @@ -33,8 +33,8 @@ pub fn code_actions(doc: &Document, uri: &Url, range: Range) -> Vec Vec bool { lower.starts_with("th:") || lower.starts_with("data-th-") } -/// Returns the canonical name of the closest known attribute to `name`, if it -/// is within [`MAX_SUGGESTION_DISTANCE`] edits. -fn closest_known(name: &str) -> Option<&'static str> { - let target = canonical(name); - thymeleaf::all_attrs() - .iter() - .map(|a| (a.name, levenshtein(&target, a.name))) - .filter(|(_, dist)| *dist <= MAX_SUGGESTION_DISTANCE) - .min_by_key(|(_, dist)| *dist) - .map(|(candidate, _)| candidate) -} - -/// Normalizes an attribute name to canonical `th:xxx` form for comparison. -/// Falls back to the lowercased verbatim name if it is not in a recognizable -/// Thymeleaf form. -fn canonical(name: &str) -> String { - let lower = name.trim().to_ascii_lowercase(); - if let Some(rest) = lower.strip_prefix("data-th-") { - format!("th:{}", rest) - } else if let Some(rest) = lower.strip_prefix("th:") { - format!("th:{}", rest) - } else { - lower - } -} - /// Builds a single-edit [`WorkspaceEdit`] replacing `range` in `uri` with /// `new_text`. fn workspace_edit(uri: &Url, range: Range, new_text: String) -> WorkspaceEdit { @@ -129,37 +103,6 @@ fn workspace_edit(uri: &Url, range: Range, new_text: String) -> WorkspaceEdit { } } -/// Classic dynamic-programming Levenshtein (edit) distance between two strings, -/// counting insertions, deletions and substitutions. Operates over Unicode -/// scalar values. -fn levenshtein(a: &str, b: &str) -> usize { - let a: Vec = a.chars().collect(); - let b: Vec = b.chars().collect(); - if a.is_empty() { - return b.len(); - } - if b.is_empty() { - return a.len(); - } - - // Single-row rolling buffer of previous distances. - let mut prev: Vec = (0..=b.len()).collect(); - let mut cur = vec![0usize; b.len() + 1]; - - for (i, &ca) in a.iter().enumerate() { - cur[0] = i + 1; - for (j, &cb) in b.iter().enumerate() { - let cost = if ca == cb { 0 } else { 1 }; - cur[j + 1] = (prev[j + 1] + 1) // deletion - .min(cur[j] + 1) // insertion - .min(prev[j] + cost); // substitution - } - std::mem::swap(&mut prev, &mut cur); - } - - prev[b.len()] -} - #[cfg(test)] mod tests { use super::*; @@ -237,6 +180,39 @@ mod tests { ); } + /// Generic Standard Dialect processors are valid, so they get no quick fix + /// (regression for issue #1). + #[test] + fn generic_html_attr_yields_no_actions() { + for src in [ + "", + "", + "
s
", + ] { + let d = doc(src); + let actions = code_actions(&d, &uri(), whole(&d)); + assert!( + actions.is_empty(), + "unexpected actions for {}: {:?}", + src, + titles(&actions) + ); + } + } + + /// Typos of generic-tier names are suggested too, not just catalog ones. + #[test] + fn generic_html_attr_typo_is_suggested() { + let d = doc(""); + let actions = code_actions(&d, &uri(), whole(&d)); + let ts = titles(&actions); + assert!( + ts.iter().any(|t| t == "Change to `th:content`"), + "expected a suggestion to th:content, got {:?}", + ts + ); + } + #[test] fn far_off_name_has_no_suggestion_but_still_removable() { // Distance from "th:completelywrong" to any known name exceeds 3. @@ -263,11 +239,4 @@ mod tests { titles(&actions) ); } - - #[test] - fn levenshtein_basic() { - assert_eq!(levenshtein("th:txt", "th:text"), 1); - assert_eq!(levenshtein("", "abc"), 3); - assert_eq!(levenshtein("abc", "abc"), 0); - } } diff --git a/src/completion.rs b/src/completion.rs index 54f2ce6..67edd3c 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -23,7 +23,7 @@ pub fn completion(vault: &Vault, uri: &Url, position: Position) -> Vec { // Only offer expression helpers inside recognized Thymeleaf // attributes. - if thymeleaf::lookup(&attr).is_some() { + if thymeleaf::is_known(&attr) { expression_completions() } else { Vec::new() @@ -35,24 +35,54 @@ pub fn completion(vault: &Vault, uri: &Url, position: Position) -> Vec` processors. `sort_text` carries the tier so clients +/// rank the documented, structural attributes above the long generic tail +/// regardless of how they order equal-scoring items. fn attribute_completions(partial: &str) -> Vec { let filter = normalize_partial(partial); + let matches = |name: &str| filter.is_empty() || name.starts_with(&filter); - thymeleaf::all_attrs() + let curated = thymeleaf::all_attrs() .iter() - .filter(|attr| filter.is_empty() || attr.name.starts_with(&filter)) - .map(|attr| CompletionItem { - label: attr.name.to_string(), - kind: Some(CompletionItemKind::PROPERTY), - detail: Some(attr.summary.to_string()), - documentation: Some(Documentation::MarkupContent(MarkupContent { - kind: MarkupKind::Markdown, - value: attr.doc.to_string(), - })), - insert_text: Some(attr.name.to_string()), - ..Default::default() - }) - .collect() + .filter(|attr| matches(attr.name)) + .map(|attr| item(attr.name, attr.summary, attr.doc, Tier::Curated)); + + let generic = thymeleaf::generic_attrs() + .map(|local| format!("th:{}", local)) + // Names with a curated entry are already offered above. + .filter(|name| matches(name) && thymeleaf::lookup(name).is_none()) + .filter_map(|name| thymeleaf::describe(&name)) + .map(|info| item(&info.name, &info.summary, &info.doc, Tier::Generic)); + + curated.chain(generic).collect() +} + +/// Which recognition tier an attribute came from, which decides its rank. +enum Tier { + Curated, + Generic, +} + +/// Builds a single attribute-name completion item. +fn item(name: &str, summary: &str, doc: &str, tier: Tier) -> CompletionItem { + let rank = match tier { + Tier::Curated => '0', + Tier::Generic => '1', + }; + CompletionItem { + label: name.to_string(), + kind: Some(CompletionItemKind::PROPERTY), + detail: Some(summary.to_string()), + documentation: Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: doc.to_string(), + })), + insert_text: Some(name.to_string()), + sort_text: Some(format!("{}{}", rank, name)), + ..Default::default() + } } /// Normalizes the partial attribute text into a canonical `th:xxx`-style prefix @@ -183,6 +213,56 @@ mod tests { assert!(!items.iter().any(|i| i.label == "th:if")); } + /// Both recognition tiers are offered, curated names ranking first. + #[test] + fn attr_name_context_offers_generic_attrs_after_curated() { + let src = "
"; + let (vault, uri) = vault_with(src); + let off = src.find("th:c").unwrap() + 4; + let items = completion(&vault, &uri, pos(off)); + + let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect(); + assert!(labels.contains(&"th:class"), "curated: {:?}", labels); + assert!(labels.contains(&"th:content"), "generic: {:?}", labels); + + let sort_of = |label: &str| { + items + .iter() + .find(|i| i.label == label) + .and_then(|i| i.sort_text.clone()) + .unwrap_or_else(|| panic!("{} should be offered", label)) + }; + assert!( + sort_of("th:class") < sort_of("th:content"), + "curated entries must sort before generic ones" + ); + } + + /// Names appearing in both tiers (e.g. `class`) must be offered once. + #[test] + fn attr_name_completions_have_no_duplicates() { + let src = "
"; + let (vault, uri) = vault_with(src); + let off = src.find(" ").unwrap() + 1; + let items = completion(&vault, &uri, pos(off)); + + let mut seen = std::collections::HashSet::new(); + for item in &items { + assert!(seen.insert(item.label.clone()), "duplicate {}", item.label); + } + } + + /// Expression helpers fire inside generic `th:*` values too, not just + /// inside curated ones. + #[test] + fn generic_attr_value_context_offers_expressions() { + let src = ""; + let (vault, uri) = vault_with(src); + let off = src.find("=\"").unwrap() + 2; + let items = completion(&vault, &uri, pos(off)); + assert!(items.iter().any(|i| i.label == "${...}"), "{:?}", items); + } + #[test] fn text_context_returns_no_completions() { let src = "

Z

"; diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 4cc095a..ae39b8d 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -1,10 +1,17 @@ //! Diagnostics for Thymeleaf attributes. //! //! Operates purely on a parsed [`Document`]: -//! * flags `th:*` / `data-th-*` attributes that are not part of the known -//! Standard Dialect catalog (WARNING), and -//! * runs a lightweight, low-false-positive bracket/paren balance check on the -//! values of *known* Thymeleaf attributes (ERROR). +//! * flags `th:*` / `data-th-*` names that look like *misspellings* of a +//! documented processor (WARNING), and +//! * runs a lightweight, low-false-positive bracket/paren balance check on +//! every Thymeleaf attribute value (ERROR). +//! +//! The name check is deliberately narrower than "not in the catalog". Thymeleaf +//! 3's default attribute processor accepts *any* `th:*` name, so an unfamiliar +//! name is not by itself an error — only a name that is one or two edits away +//! from a real one is worth reporting. The gate is +//! [`thymeleaf::closest_known`], which is also what drives the "change to" +//! quick fix, so every warning raised here has a matching code action. use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range}; @@ -21,34 +28,35 @@ pub fn diagnostics(document: &Document) -> Vec { continue; } - match thymeleaf::lookup(&attr.name) { - None => { - // Unknown Thymeleaf attribute -> warning at the name range. + // a) Name check: only likely typos, never merely-unfamiliar names. + if !thymeleaf::is_known(&attr.name) { + if let Some(suggestion) = thymeleaf::closest_known(&attr.name) { let range = to_range(document, attr.name_range.start, attr.name_range.end); out.push(Diagnostic { range, severity: Some(DiagnosticSeverity::WARNING), source: Some(SOURCE.to_string()), message: format!( - "Unknown Thymeleaf attribute '{}'", - canonical_display(&attr.name) + "Unknown Thymeleaf attribute '{}'. Did you mean '{}'?", + canonical_display(&attr.name), + suggestion ), ..Default::default() }); } - Some(_) => { - // Known attribute: sanity-check the expression value. - if let Some(msg) = unbalanced_message(&attr.value) { - let range = to_range(document, attr.value_range.start, attr.value_range.end); - out.push(Diagnostic { - range, - severity: Some(DiagnosticSeverity::ERROR), - source: Some(SOURCE.to_string()), - message: msg, - ..Default::default() - }); - } - } + } + + // b) Value check: the value of any `th:*` attribute is a Thymeleaf + // expression, so this applies regardless of how the name resolved. + if let Some(msg) = unbalanced_message(&attr.value) { + let range = to_range(document, attr.value_range.start, attr.value_range.end); + out.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::ERROR), + source: Some(SOURCE.to_string()), + message: msg, + ..Default::default() + }); } } @@ -174,12 +182,44 @@ mod tests { } #[test] - fn unknown_thymeleaf_attr_is_flagged() { - let d = doc("

y

"); + fn misspelled_thymeleaf_attr_is_flagged_with_a_suggestion() { + let d = doc("

y

"); let diags = diagnostics(&d); assert_eq!(diags.len(), 1); assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING)); - assert!(diags[0].message.contains("th:bogus")); + assert!(diags[0].message.contains("th:txt")); + assert!( + diags[0].message.contains("th:text"), + "the warning should name its fix, got {:?}", + diags[0].message + ); + } + + /// Regression for issue #1: generic HTML-attribute processors are valid + /// Standard Dialect and must not be flagged. + #[test] + fn generic_html_attr_processors_are_not_flagged() { + let d = doc(concat!( + "", + "", + "
s
", + )); + let diags = diagnostics(&d); + assert!(diags.is_empty(), "unexpected diagnostics: {:?}", diags); + } + + /// Any `th:*` name is technically valid via Thymeleaf's default attribute + /// processor, so a name that is nowhere near a documented one stays silent. + #[test] + fn distant_unknown_attr_is_not_flagged() { + let d = doc("

y

"); + assert!(diagnostics(&d).is_empty()); + } + + #[test] + fn custom_data_attr_is_not_flagged() { + let d = doc("
x
"); + assert!(diagnostics(&d).is_empty()); } #[test] @@ -195,4 +235,13 @@ mod tests { assert_eq!(diags.len(), 1); assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR)); } + + /// The bracket check is not tied to the curated catalog. + #[test] + fn unbalanced_expression_in_generic_attr_is_flagged() { + let d = doc(""); + let diags = diagnostics(&d); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR)); + } } diff --git a/src/hover.rs b/src/hover.rs index 6a5011d..97ab3cf 100644 --- a/src/hover.rs +++ b/src/hover.rs @@ -30,7 +30,7 @@ pub fn hover(vault: &Vault, uri: &Url, position: Position) -> Option { .iter() .find(|a| offset >= a.name_range.start && offset <= a.name_range.end) { - if let Some(meta) = thymeleaf::lookup(&attr.name) { + if let Some(meta) = thymeleaf::describe(&attr.name) { let markdown = format!("## `{}`\n\n{}\n\n{}", meta.name, meta.summary, meta.doc); let range = Range { start: doc.position_at(attr.name_range.start), @@ -157,3 +157,61 @@ fn consider( fn is_utility_md(md: &str) -> bool { !md.starts_with("**") } + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a single-document vault and returns it with the doc's URL. + fn vault_with(src: &str) -> (Vault, Url) { + let uri = Url::parse("file:///test.html").unwrap(); + let mut vault = Vault::new(None); + vault.upsert(uri.clone(), src.to_string()); + (vault, uri) + } + + /// Position at a byte offset on the (single) first line. + fn pos(offset: usize) -> Position { + Position { + line: 0, + character: offset as u32, + } + } + + /// Returns the markdown of a hover, panicking on any other content shape. + fn markdown(h: Hover) -> String { + match h.contents { + HoverContents::Markup(m) => m.value, + other => panic!("expected markup contents, got {:?}", other), + } + } + + #[test] + fn hovers_curated_attribute_name() { + let src = "

x

"; + let (vault, uri) = vault_with(src); + let off = src.find("th:text").unwrap() + 2; + let md = markdown(hover(&vault, &uri, pos(off)).expect("th:text hovers")); + assert!(md.contains("th:text"), "{}", md); + } + + /// Generic Standard Dialect processors get synthesized documentation + /// (issue #1) rather than nothing. + #[test] + fn hovers_generic_attribute_name() { + let src = ""; + let (vault, uri) = vault_with(src); + let off = src.find("th:content").unwrap() + 2; + let md = markdown(hover(&vault, &uri, pos(off)).expect("th:content hovers")); + assert!(md.contains("th:content"), "{}", md); + assert!(md.contains("content"), "{}", md); + } + + #[test] + fn no_hover_on_plain_html_attribute() { + let src = "
x
"; + let (vault, uri) = vault_with(src); + let off = src.find("class").unwrap() + 1; + assert!(hover(&vault, &uri, pos(off)).is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index af6764d..5c03033 100644 --- a/src/main.rs +++ b/src/main.rs @@ -398,21 +398,32 @@ mod integration_tests { } #[test] - fn diagnostics_flag_unknown_attr_only() { + fn diagnostics_flag_misspelled_attr_only() { let uri = url("d.html"); let mut vault = Vault::new(None); - vault.upsert(uri.clone(), "

hi

".to_string()); + vault.upsert(uri.clone(), "

hi

".to_string()); let diags = diagnostics::diagnostics(vault.get(&uri).unwrap()); - assert_eq!(diags.len(), 1, "unknown th:* should produce one diagnostic"); + assert_eq!( + diags.len(), + 1, + "a typo'd th:* should produce one diagnostic" + ); assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING)); - assert!(diags[0].message.contains("th:bogus")); + assert!(diags[0].message.contains("th:txt")); vault.upsert(uri.clone(), "

hi

".to_string()); assert!( diagnostics::diagnostics(vault.get(&uri).unwrap()).is_empty(), "valid th:text must not be flagged" ); + + // A documented generic processor (issue #1) is equally valid. + vault.upsert(uri.clone(), "".to_string()); + assert!( + diagnostics::diagnostics(vault.get(&uri).unwrap()).is_empty(), + "generic th:content must not be flagged" + ); } #[test] diff --git a/src/semantic_tokens.rs b/src/semantic_tokens.rs index d019655..3a7425b 100644 --- a/src/semantic_tokens.rs +++ b/src/semantic_tokens.rs @@ -58,7 +58,7 @@ pub fn semantic_tokens_full(doc: &Document) -> SemanticTokens { for attr in doc.attributes() { // Only recognized Thymeleaf attributes contribute tokens. - if thymeleaf::lookup(&attr.name).is_none() { + if !thymeleaf::is_known(&attr.name) { continue; } @@ -262,6 +262,18 @@ mod tests { assert_eq!(toks.data[0].delta_line, 0); } + /// Generic Standard Dialect processors are highlighted like curated ones + /// (issue #1). + #[test] + fn generic_attrs_are_tokenized() { + let d = doc(""); + let toks = semantic_tokens_full(&d); + + let types: Vec = toks.data.iter().map(|t| t.token_type).collect(); + assert!(types.contains(&TYPE_PROPERTY), "th:content name token"); + assert!(types.contains(&TYPE_MACRO), "${{...}} marker token"); + } + #[test] fn ignores_unrecognized_attributes() { let d = doc("

x

"); diff --git a/src/thymeleaf.rs b/src/thymeleaf.rs index d657922..0cc5e47 100644 --- a/src/thymeleaf.rs +++ b/src/thymeleaf.rs @@ -1,15 +1,32 @@ //! Static catalog of the Thymeleaf Standard Dialect. //! //! This module is pure data + lookup helpers. It is consumed by the feature -//! modules (completion / hover) to provide attribute names, human-readable -//! summaries and markdown documentation, plus a small reference of the -//! Thymeleaf expression syntaxes and utility objects. +//! modules (completion / hover / diagnostics / code actions) to provide +//! attribute names, human-readable summaries and markdown documentation, plus a +//! small reference of the Thymeleaf expression syntaxes and utility objects. +//! +//! Recognition is deliberately **two-tier**, because the Standard Dialect is +//! much wider than the set of attributes worth documenting by hand: +//! +//! 1. [`ATTRS`] — a curated catalog of the structural / frequently used +//! processors, each with a hand-written summary and markdown example. +//! 2. [`SPECIFIC_ATTRS`] + [`BOOLEAN_ATTRS`] — the generic `th:` +//! processors listed verbatim in the Thymeleaf tutorial (§5.2 "Setting value +//! to specific attributes" and §5.5 "Fixed-value boolean attributes"). +//! These get a synthesized description rather than a hand-written one. +//! +//! On top of both, Thymeleaf 3's `StandardDefaultAttributesTagProcessor` +//! handles *any* remaining `th:*` name generically, which is how `th:data-*` +//! custom attributes work. [`classify`] therefore treats `th:data-*` / +//! `th:aria-*` as known too, and callers should not assume that a name outside +//! all of these lists is actually invalid — see [`closest_known`], which is how +//! the diagnostics decide whether a name is a plausible *typo*. /// A single Thymeleaf standard-dialect attribute. /// /// `name` is always stored in the canonical `th:xxx` form. Lookups accept both /// the `th:xxx` and the HTML5-valid `data-th-xxx` forms (see [`lookup`]). -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ThymeleafAttr { /// Canonical attribute name, e.g. `"th:text"`. pub name: &'static str, @@ -19,7 +36,7 @@ pub struct ThymeleafAttr { pub doc: &'static str, } -/// The full catalog of standard-dialect attributes. +/// The curated catalog of standard-dialect attributes. static ATTRS: &[ThymeleafAttr] = &[ ThymeleafAttr { name: "th:text", @@ -233,11 +250,408 @@ static ATTRS: &[ThymeleafAttr] = &[ }, ]; -/// Returns the full catalog of standard-dialect attributes. +/// Generic `th:` processors for specific HTML attributes, as listed in the +/// Thymeleaf tutorial §5.2 "Setting value to specific attributes". Stored as the +/// bare local name (without the `th:` prefix), lowercase. +/// +/// A handful of these (`class`, `href`, `fragment`, `inline`, …) also have a +/// curated [`ATTRS`] entry; [`classify`] resolves [`ATTRS`] first, so the +/// hand-written documentation always wins. +static SPECIFIC_ATTRS: &[&str] = &[ + "abbr", + "accept", + "accept-charset", + "accesskey", + "action", + "align", + "alt", + "archive", + "audio", + "autocomplete", + "axis", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "challenge", + "charset", + "cite", + "class", + "classid", + "codebase", + "codetype", + "cols", + "colspan", + "compact", + "content", + "contenteditable", + "contextmenu", + "data", + "datetime", + "dir", + "draggable", + "dropzone", + "enctype", + "for", + "form", + "formaction", + "formenctype", + "formmethod", + "formtarget", + "fragment", + "frame", + "frameborder", + "headers", + "height", + "high", + "href", + "hreflang", + "hspace", + "http-equiv", + "icon", + "id", + "inline", + "keytype", + "kind", + "label", + "lang", + "list", + "longdesc", + "low", + "manifest", + "marginheight", + "marginwidth", + "max", + "maxlength", + "media", + "method", + "min", + "name", + "onabort", + "onafterprint", + "onbeforeprint", + "onbeforeunload", + "onblur", + "oncanplay", + "oncanplaythrough", + "onchange", + "onclick", + "oncontextmenu", + "ondblclick", + "ondrag", + "ondragend", + "ondragenter", + "ondragleave", + "ondragover", + "ondragstart", + "ondrop", + "ondurationchange", + "onemptied", + "onended", + "onerror", + "onfocus", + "onformchange", + "onforminput", + "onhashchange", + "oninput", + "oninvalid", + "onkeydown", + "onkeypress", + "onkeyup", + "onload", + "onloadeddata", + "onloadedmetadata", + "onloadstart", + "onmessage", + "onmousedown", + "onmousemove", + "onmouseout", + "onmouseover", + "onmouseup", + "onmousewheel", + "onoffline", + "ononline", + "onpause", + "onplay", + "onplaying", + "onpopstate", + "onprogress", + "onratechange", + "onreadystatechange", + "onredo", + "onreset", + "onresize", + "onscroll", + "onseeked", + "onseeking", + "onselect", + "onshow", + "onstalled", + "onstorage", + "onsubmit", + "onsuspend", + "ontimeupdate", + "onundo", + "onunload", + "onvolumechange", + "onwaiting", + "optimum", + "pattern", + "placeholder", + "poster", + "preload", + "radiogroup", + "rel", + "rev", + "rows", + "rowspan", + "rules", + "sandbox", + "scheme", + "scope", + "scrolling", + "size", + "sizes", + "span", + "spellcheck", + "src", + "srclang", + "standby", + "start", + "step", + "style", + "summary", + "tabindex", + "target", + "title", + "type", + "usemap", + "value", + "valuetype", + "vspace", + "width", + "wrap", + "xmlbase", + "xmllang", + "xmlspace", +]; + +/// Fixed-value boolean attributes, as listed in the Thymeleaf tutorial §5.5 +/// "Fixed-value boolean attributes". Setting one of these to a truthy +/// expression emits the bare HTML attribute; a falsy one omits it entirely. +static BOOLEAN_ATTRS: &[&str] = &[ + "async", + "autofocus", + "autoplay", + "checked", + "controls", + "declare", + "default", + "defer", + "disabled", + "formnovalidate", + "hidden", + "ismap", + "loop", + "multiple", + "novalidate", + "nowrap", + "open", + "pubdate", + "readonly", + "required", + "reversed", + "scoped", + "seamless", + "selected", +]; + +/// Maximum Levenshtein distance at which [`closest_known`] offers a suggestion. +/// +/// Kept deliberately tight. With ~200 candidate names a distance of 3 matches +/// almost any short word by chance (`th:bogus` would "correct" to `th:cols`), +/// which would both produce nonsense quick fixes and defeat the point of gating +/// the diagnostic on a suggestion existing. Two edits still covers the +/// realistic typos — dropped letters, transpositions, doubled characters. +pub const MAX_SUGGESTION_DISTANCE: usize = 2; + +/// How a `th:*` / `data-th-*` attribute name resolves against the dialect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttrKind { + /// A curated [`ATTRS`] entry, with hand-written documentation. + Catalog(&'static ThymeleafAttr), + /// A generic processor: a §5.2 / §5.5 attribute, or a `th:data-*` / + /// `th:aria-*` custom attribute handled by Thymeleaf 3's + /// `StandardDefaultAttributesTagProcessor`. Carries the bare local name + /// (e.g. `"content"` for `th:content`). + Generic { local: String }, + /// Not recognized as any documented processor. Note this does *not* mean + /// the attribute is invalid — the default attribute processor accepts any + /// name — only that we have nothing to say about it. + Unknown, +} + +/// Classifies an attribute name, accepting both `th:xxx` and `data-th-xxx` +/// spellings, case-insensitively. +pub fn classify(name: &str) -> AttrKind { + let Some(canonical) = normalize(name) else { + return AttrKind::Unknown; + }; + let local = &canonical["th:".len()..]; + if local.is_empty() { + return AttrKind::Unknown; + } + + // The curated catalog always wins, so hand-written docs are preferred over + // the synthesized ones. + if let Some(attr) = ATTRS.iter().find(|a| a.name == canonical) { + return AttrKind::Catalog(attr); + } + if is_generic_local(local) { + return AttrKind::Generic { + local: local.to_string(), + }; + } + AttrKind::Unknown +} + +/// Whether `name` resolves to any documented processor (curated or generic). +pub fn is_known(name: &str) -> bool { + !matches!(classify(name), AttrKind::Unknown) +} + +/// Whether a bare local name is handled by a generic processor. +fn is_generic_local(local: &str) -> bool { + SPECIFIC_ATTRS.contains(&local) + || BOOLEAN_ATTRS.contains(&local) + // Custom data / ARIA attributes go through the default attribute + // processor, which accepts any suffix. + || local.starts_with("data-") + || local.starts_with("aria-") +} + +/// Resolved documentation for an attribute name. Owned because the generic +/// tier's text is synthesized per-name rather than stored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttrInfo { + /// Canonical attribute name, e.g. `"th:content"`. + pub name: String, + /// One-line summary suitable for a completion detail line. + pub summary: String, + /// Markdown documentation, including a short usage example. + pub doc: String, +} + +/// Returns documentation for `name`, or `None` if it is not a documented +/// processor. Curated entries return their hand-written text; generic ones get +/// a synthesized description. +pub fn describe(name: &str) -> Option { + match classify(name) { + AttrKind::Catalog(attr) => Some(AttrInfo { + name: attr.name.to_string(), + summary: attr.summary.to_string(), + doc: attr.doc.to_string(), + }), + AttrKind::Generic { local } => Some(generic_info(&local)), + AttrKind::Unknown => None, + } +} + +/// Synthesizes documentation for a generic-tier attribute. +fn generic_info(local: &str) -> AttrInfo { + let name = format!("th:{}", local); + if BOOLEAN_ATTRS.contains(&local) { + AttrInfo { + summary: format!("Fixed-value boolean attribute: {}.", local), + doc: format!( + "Sets or removes the boolean `{local}` attribute depending on whether the\nexpression evaluates to true.\n\n```html\n\n```\n\n*Standard Dialect fixed-value boolean attribute.*" + ), + name, + } + } else if SPECIFIC_ATTRS.contains(&local) { + AttrInfo { + summary: format!("Sets the {} attribute from an expression.", local), + doc: format!( + "Evaluates the expression and sets the result as the `{local}` attribute of\nthe host tag.\n\n```html\n...\n```\n\n*Standard Dialect attribute-value processor.*" + ), + name, + } + } else { + AttrInfo { + summary: format!("Sets the custom {} attribute from an expression.", local), + doc: format!( + "Evaluates the expression and sets the result as the `{local}` attribute,\nvia Thymeleaf's default attribute processor.\n\n```html\n
...
\n```" + ), + name, + } + } +} + +/// Returns the full curated catalog of standard-dialect attributes. pub fn all_attrs() -> &'static [ThymeleafAttr] { ATTRS } +/// Returns every generic-tier local name (§5.2 followed by §5.5), without the +/// `th:` prefix. Used by completion to offer the wider dialect surface. +pub fn generic_attrs() -> impl Iterator { + SPECIFIC_ATTRS.iter().chain(BOOLEAN_ATTRS).copied() +} + +/// Returns the canonical name of the documented attribute closest to `name`, if +/// one lies within [`MAX_SUGGESTION_DISTANCE`] edits. +/// +/// This is what separates a *typo* from a deliberate use of the default +/// attribute processor: since any `th:*` name is technically valid, both the +/// "unknown attribute" diagnostic and the "change to" quick fix are gated on a +/// suggestion existing, which keeps the two in lockstep. +pub fn closest_known(name: &str) -> Option { + let target = normalize(name)?; + ATTRS + .iter() + .map(|a| a.name.to_string()) + .chain(generic_attrs().map(|local| format!("th:{}", local))) + .map(|candidate| { + let dist = levenshtein(&target, &candidate); + (candidate, dist) + }) + .filter(|(_, dist)| *dist <= MAX_SUGGESTION_DISTANCE) + .min_by_key(|(_, dist)| *dist) + .map(|(candidate, _)| candidate) +} + +/// Classic dynamic-programming Levenshtein (edit) distance between two strings, +/// counting insertions, deletions and substitutions. Operates over Unicode +/// scalar values. +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + + // Single-row rolling buffer of previous distances. + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0usize; b.len() + 1]; + + for (i, &ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = if ca == cb { 0 } else { 1 }; + cur[j + 1] = (prev[j + 1] + 1) // deletion + .min(cur[j] + 1) // insertion + .min(prev[j] + cost); // substitution + } + std::mem::swap(&mut prev, &mut cur); + } + + prev[b.len()] +} + /// Normalizes an attribute name to the canonical `th:xxx` form. /// /// Accepts both `th:text` and the HTML5-valid `data-th-text` spellings. @@ -373,4 +787,127 @@ mod tests { // Every catalog entry is stored in canonical `th:` form. assert!(all_attrs().iter().all(|a| a.name.starts_with("th:"))); } + + // === two-tier recognition ============================================ + + /// The three attributes reported in issue #1 are documented §5.2/§5.5 + /// processors and must resolve, even though they have no curated entry. + #[test] + fn generic_html_attrs_are_known() { + for name in ["th:content", "th:for", "th:open"] { + assert!( + matches!(classify(name), AttrKind::Generic { .. }), + "{} should classify as generic, got {:?}", + name, + classify(name) + ); + } + } + + #[test] + fn curated_entries_win_over_generic_list() { + // `class` appears in SPECIFIC_ATTRS *and* in ATTRS; the hand-written + // documentation must be the one that resolves. + assert!(SPECIFIC_ATTRS.contains(&"class")); + match classify("th:class") { + AttrKind::Catalog(attr) => assert_eq!(attr.name, "th:class"), + other => panic!("th:class should be a catalog entry, got {:?}", other), + } + } + + #[test] + fn generic_classification_normalizes_spellings() { + assert!(matches!( + classify("data-th-content"), + AttrKind::Generic { .. } + )); + assert!(matches!(classify("TH:CONTENT"), AttrKind::Generic { .. })); + } + + #[test] + fn custom_data_and_aria_attrs_are_known() { + for name in ["th:data-toggle", "th:aria-label", "data-th-data-id"] { + assert!(is_known(name), "{} should be known", name); + } + } + + #[test] + fn undocumented_names_are_unknown() { + assert_eq!(classify("th:zzzzzzzzzz"), AttrKind::Unknown); + // Not a Thymeleaf attribute at all, and a bare prefix with no local. + assert_eq!(classify("class"), AttrKind::Unknown); + assert_eq!(classify("th:"), AttrKind::Unknown); + } + + #[test] + fn describe_covers_both_tiers() { + let curated = describe("th:text").expect("curated entry describes"); + assert_eq!(curated.name, "th:text"); + assert!(curated.doc.contains("th:text")); + + let specific = describe("th:content").expect("generic entry describes"); + assert_eq!(specific.name, "th:content"); + assert!(specific.summary.contains("content")); + assert!(specific.doc.contains("th:content")); + + let boolean = describe("th:open").expect("boolean entry describes"); + assert!( + boolean.summary.contains("boolean"), + "boolean attrs get boolean wording, got {:?}", + boolean.summary + ); + + assert!(describe("th:zzzzzzzzzz").is_none()); + } + + #[test] + fn generic_lists_are_lowercase_and_unique() { + let mut seen = std::collections::HashSet::new(); + for local in generic_attrs() { + assert!(!local.is_empty(), "empty local name in the generic lists"); + assert_eq!( + local, + local.to_ascii_lowercase(), + "{} should be stored lowercase", + local + ); + assert!( + seen.insert(local), + "{} appears twice across the generic lists", + local + ); + } + } + + // === suggestions ====================================================== + + #[test] + fn closest_known_finds_catalog_and_generic_typos() { + assert_eq!(closest_known("th:txt").as_deref(), Some("th:text")); + assert_eq!(closest_known("th:contnt").as_deref(), Some("th:content")); + } + + #[test] + fn closest_known_handles_transpositions_and_dropped_letters() { + assert_eq!(closest_known("th:fragmnet").as_deref(), Some("th:fragment")); + assert_eq!(closest_known("th:opne").as_deref(), Some("th:open")); + } + + /// A name that is not a near-miss yields no suggestion, which is what keeps + /// deliberate default-processor attributes free of diagnostics. `th:bogus` + /// and `th:wibble` are the interesting cases: both sit at distance 3 from + /// an unrelated name, so they pin [`MAX_SUGGESTION_DISTANCE`] down. + #[test] + fn closest_known_gives_up_on_names_that_are_not_near_misses() { + for name in ["th:zzzzzzzzzz", "th:bogus", "th:wibble"] { + assert_eq!(closest_known(name), None, "{} should not suggest", name); + } + } + + #[test] + fn levenshtein_basic() { + assert_eq!(levenshtein("th:txt", "th:text"), 1); + assert_eq!(levenshtein("", "abc"), 3); + assert_eq!(levenshtein("abc", "abc"), 0); + } }