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); + } }