Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@ Feature modules follow a common shape: a small set of `pub fn`s taking `&Vault`/
Foundation:
- **`vault`** — `Vault`: `HashMap<Url, Document>` 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<AttrOccurrence>` (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:<html-attr>` 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<SemanticToken>` (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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<html-attr>` 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. |
Expand Down
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<head>
<title th:text="${page.title}">Static title</title>
<meta charset="UTF-8">
<!-- Generic th:<html-attr> processors: valid, and not flagged. -->
<meta name="description" th:content="${page.description}">
<link th:href="@{/css/style.css}" rel="stylesheet">
</head>
<body>
Expand All @@ -28,6 +30,13 @@ <h1 th:txt="${page.heading}">Heading</h1>
</ul>

<a th:href="@{/users/{id}(id=${user.id})}">Profile</a>

<!-- More generic processors: a dynamic `for` and a boolean `open`. -->
<label th:for="${searchFieldId}">Search</label>
<details th:open="${expandByDefault}">
<summary>Details</summary>
<p th:text="${page.details}">details</p>
</details>
</main>

<!-- Fragment definition referenced by th:replace above. -->
Expand Down
115 changes: 42 additions & 73 deletions src/code_actions.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
Expand All @@ -33,8 +33,8 @@ pub fn code_actions(doc: &Document, uri: &Url, range: Range) -> Vec<CodeActionOr
if !is_thymeleaf_name(&attr.name) {
continue;
}
// Only unknown attributes are actionable.
if thymeleaf::lookup(&attr.name).is_some() {
// Only unrecognized attributes are actionable.
if thymeleaf::is_known(&attr.name) {
continue;
}
// The attribute's name range must overlap the requested range. Two
Expand All @@ -47,7 +47,7 @@ pub fn code_actions(doc: &Document, uri: &Url, range: Range) -> Vec<CodeActionOr
}

// a) "Change to" the closest known attribute name.
if let Some(suggestion) = closest_known(&attr.name) {
if let Some(suggestion) = thymeleaf::closest_known(&attr.name) {
let name_lsp = Range {
start: doc.position_at(n_start),
end: doc.position_at(n_end),
Expand Down Expand Up @@ -91,32 +91,6 @@ fn is_thymeleaf_name(name: &str) -> 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 {
Expand All @@ -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<char> = a.chars().collect();
let b: Vec<char> = 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<usize> = (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::*;
Expand Down Expand Up @@ -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 [
"<meta th:content=\"${d}\">",
"<label th:for=\"${id}\">L</label>",
"<details th:open=\"${x}\"><summary>s</summary></details>",
] {
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("<meta th:contnt=\"${x}\">");
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.
Expand All @@ -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);
}
}
110 changes: 95 additions & 15 deletions src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub fn completion(vault: &Vault, uri: &Url, position: Position) -> Vec<Completio
CursorContext::AttrValue { attr, .. } => {
// Only offer expression helpers inside recognized Thymeleaf
// attributes.
if thymeleaf::lookup(&attr).is_some() {
if thymeleaf::is_known(&attr) {
expression_completions()
} else {
Vec::new()
Expand All @@ -35,24 +35,54 @@ pub fn completion(vault: &Vault, uri: &Url, position: Position) -> Vec<Completio

/// Builds completion items for all Thymeleaf attributes whose canonical name
/// matches `partial`.
///
/// Both recognition tiers are offered: the curated catalog first, then the
/// generic `th:<html-attr>` 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<CompletionItem> {
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
Expand Down Expand Up @@ -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 = "<div th:c></div>";
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 = "<div ></div>";
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 = "<meta th:content=\"\">";
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 = "<p>Z</p>";
Expand Down
Loading
Loading