on master help sign in
Add parser cache, incremental sync, workspace indexing, and context-aware LSP features
commit50119b17e5a25ccaca424779e7dfed1f27c5a8c8 authoriko <mail@iko.soy> dateFri 1 May 2026, 08:40 (5 months ago) parent6ac9439
10 files changed+1410−270
Cargo.lock+1 Cargo.toml+1 editors/vscode/package.json+33−1 editors/vscode/src/extension.ts+108−4 src/bin/htmlang_lsp/analysis.rs+39−2 src/bin/htmlang_lsp/completion.rs+496−4 src/bin/htmlang_lsp/hover.rs+12−1 src/bin/htmlang_lsp/main.rs+376−258 src/bin/htmlang_lsp/navigation.rs+95 src/bin/htmlang_lsp/state.rs+249
Cargo.lock+1
@@ -280,6 +280,7 @@ dependencies = [
280280 "htmlang-core",
281281 "notify",
282282 "rustls-pemfile",
283+ "serde_json",
283284 "tokio",
284285 "tokio-rustls",
285286 "tower-lsp",
Cargo.toml+1
@@ -14,6 +14,7 @@ path = "src/bin/htmlang_lsp/main.rs"
1414htmlang-core = { path = "crates/htmlang-core" }
1515notify = "7"
1616rustls-pemfile = "2.2.0"
17+serde_json = "1"
1718tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
1819tokio-rustls = { version = "0.26.4", features = ["ring"] }
1920tower-lsp = "0.20"
editors/vscode/package.json+33−1
@@ -11,7 +11,10 @@
1111 "categories": [
1212 "Programming Languages"
1313 ],
14− "activationEvents": [],
14+ "activationEvents": [
15+ "onLanguage:htmlang",
16+ "workspaceContains:**/*.hl"
17+ ],
1518 "main": "./out/extension.js",
1619 "contributes": {
1720 "languages": [
@@ -39,6 +42,16 @@
3942 "path": "./snippets/htmlang.json"
4043 }
4144 ],
45+ "commands": [
46+ {
47+ "command": "htmlang.restartServer",
48+ "title": "htmlang: Restart Language Server"
49+ },
50+ {
51+ "command": "htmlang.showOutput",
52+ "title": "htmlang: Show Language Server Output"
53+ }
54+ ],
4255 "configuration": {
4356 "title": "htmlang",
4457 "properties": {
@@ -46,6 +59,25 @@
4659 "type": "string",
4760 "default": "htmlang-lsp",
4861 "description": "Path to the htmlang-lsp binary"
62+ },
63+ "htmlang.server.args": {
64+ "type": "array",
65+ "items": {
66+ "type": "string"
67+ },
68+ "default": [],
69+ "description": "Arguments passed to the htmlang-lsp binary"
70+ },
71+ "htmlang.trace.server": {
72+ "type": "string",
73+ "scope": "window",
74+ "enum": [
75+ "off",
76+ "messages",
77+ "verbose"
78+ ],
79+ "default": "off",
80+ "description": "Trace LSP protocol messages between VS Code and the htmlang language server."
4981 }
5082 }
5183 }
editors/vscode/src/extension.ts+108−4
@@ -1,20 +1,75 @@
1−import { ExtensionContext, workspace } from "vscode";
1+import {
2+ commands,
3+ ExtensionContext,
4+ Location,
5+ Position,
6+ StatusBarAlignment,
7+ StatusBarItem,
8+ ThemeColor,
9+ Uri,
10+ window,
11+ workspace,
12+} from "vscode";
213import {
314 LanguageClient,
415 LanguageClientOptions,
516 ServerOptions,
17+ State,
618} from "vscode-languageclient/node";
719
820let client: LanguageClient | undefined;
21+let statusBar: StatusBarItem | undefined;
22+let activeContext: ExtensionContext | undefined;
23+
24+export async function activate(context: ExtensionContext) {
25+ activeContext = context;
26+ statusBar = window.createStatusBarItem(StatusBarAlignment.Left, 100);
27+ statusBar.name = "htmlang";
28+ statusBar.command = "htmlang.showOutput";
29+ context.subscriptions.push(statusBar);
30+
31+ context.subscriptions.push(
32+ commands.registerCommand("htmlang.restartServer", restartServer),
33+ commands.registerCommand("htmlang.showOutput", () => {
34+ client?.outputChannel.show(true);
35+ })
36+ );
37+
38+ await startServer(context);
39+}
940
10−export function activate(context: ExtensionContext) {
41+async function startServer(context: ExtensionContext) {
1142 const config = workspace.getConfiguration("htmlang");
1243 const command = config.get<string>("server.path", "htmlang-lsp");
44+ const args = config.get<string[]>("server.args", []);
1345
14− const serverOptions: ServerOptions = { command };
46+ const serverOptions: ServerOptions = { command, args };
1547
1648 const clientOptions: LanguageClientOptions = {
1749 documentSelector: [{ scheme: "file", language: "htmlang" }],
50+ middleware: {
51+ executeCommand: async (command, args, next) => {
52+ // The server emits `htmlang.showReferences` from code lenses; route
53+ // it through the built-in references viewer.
54+ if (command === "htmlang.showReferences") {
55+ const [uri, position] = args as [string, Position];
56+ const target = Uri.parse(uri);
57+ const refs = await commands.executeCommand<Location[]>(
58+ "vscode.executeReferenceProvider",
59+ target,
60+ new Position(position.line, position.character)
61+ );
62+ await commands.executeCommand(
63+ "editor.action.showReferences",
64+ target,
65+ new Position(position.line, position.character),
66+ refs ?? []
67+ );
68+ return;
69+ }
70+ return next(command, args);
71+ },
72+ },
1873 };
1974
2075 client = new LanguageClient(
@@ -24,7 +79,56 @@ export function activate(context: ExtensionContext) {
2479 clientOptions
2580 );
2681
27− client.start();
82+ client.onDidChangeState((event) => updateStatus(event.newState));
83+ updateStatus(State.Starting);
84+
85+ try {
86+ await client.start();
87+ } catch (err) {
88+ window.showErrorMessage(`htmlang: failed to start language server: ${err}`);
89+ updateStatus(State.Stopped);
90+ }
91+}
92+
93+async function restartServer() {
94+ if (client) {
95+ try {
96+ await client.stop();
97+ } catch {
98+ // ignore — server might already be dead
99+ }
100+ client = undefined;
101+ }
102+ if (activeContext) {
103+ await startServer(activeContext);
104+ }
105+}
106+
107+function updateStatus(state: State) {
108+ if (!statusBar) return;
109+ switch (state) {
110+ case State.Starting:
111+ statusBar.text = "$(sync~spin) htmlang";
112+ statusBar.tooltip = "htmlang language server is starting…";
113+ statusBar.backgroundColor = undefined;
114+ statusBar.show();
115+ break;
116+ case State.Running:
117+ statusBar.text = "$(check) htmlang";
118+ statusBar.tooltip = "htmlang language server is running. Click for output.";
119+ statusBar.backgroundColor = undefined;
120+ statusBar.show();
121+ break;
122+ case State.Stopped:
123+ statusBar.text = "$(error) htmlang";
124+ statusBar.tooltip =
125+ "htmlang language server stopped. Click for output, or run 'htmlang: Restart Language Server'.";
126+ statusBar.backgroundColor = new ThemeColor(
127+ "statusBarItem.errorBackground"
128+ );
129+ statusBar.show();
130+ break;
131+ }
28132}
29133
30134export function deactivate(): Thenable<void> | undefined {
src/bin/htmlang_lsp/analysis.rs+39−2
@@ -1,5 +1,6 @@
11use std::collections::HashMap;
22
3+use htmlang::parser::ParseResult;
34use tower_lsp::lsp_types::*;
45
56use crate::completion::in_brackets;
@@ -759,6 +760,15 @@ pub(crate) fn find_colors(text: &str) -> Vec<ColorInformation> {
759760 colors
760761}
761762
763+/// Find a CSS named color whose RGB triple matches exactly. Used by the
764+/// color presentation handler to offer "red" alongside "#ff0000".
765+pub(crate) fn named_color_for(r: u8, g: u8, b: u8) -> Option<&'static str> {
766+ NAMED_CSS_COLORS
767+ .iter()
768+ .find(|&&(_, nr, ng, nb)| nr == r && ng == g && nb == b)
769+ .map(|&(name, _, _, _)| name)
770+}
771+
762772/// Named CSS colors: (name, r, g, b)
763773const NAMED_CSS_COLORS: &[(&str, u8, u8, u8)] = &[
764774 ("red", 255, 0, 0),
@@ -945,6 +955,34 @@ pub(crate) fn folding_ranges(text: &str) -> Vec<FoldingRange> {
945955 }
946956 i += 1;
947957 }
958+
959+ // Fold any multi-line `[...]` attribute list. We track bracket depth
960+ // across lines so nested brackets and brackets that close later in the
961+ // file are handled the same way.
962+ let mut stack: Vec<(u32, u32)> = Vec::new(); // (line, col) of each unmatched `[`
963+ for (line_idx, line) in lines.iter().enumerate() {
964+ for (col, ch) in line.char_indices() {
965+ match ch {
966+ '[' => stack.push((line_idx as u32, col as u32)),
967+ ']' => {
968+ if let Some((start_line, start_col)) = stack.pop()
969+ && start_line as usize != line_idx
970+ {
971+ ranges.push(FoldingRange {
972+ start_line,
973+ start_character: Some(start_col),
974+ end_line: line_idx as u32,
975+ end_character: Some(col as u32 + 1),
976+ kind: Some(FoldingRangeKind::Region),
977+ collapsed_text: None,
978+ });
979+ }
980+ }
981+ _ => {}
982+ }
983+ }
984+ }
985+
948986 ranges
949987}
950988
@@ -952,13 +990,12 @@ pub(crate) fn folding_ranges(text: &str) -> Vec<FoldingRange> {
952990// Semantic tokens
953991// ---------------------------------------------------------------------------
954992
955−pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
993+pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec<SemanticToken> {
956994 let mut tokens = Vec::new();
957995 let mut prev_line: u32 = 0;
958996 let mut prev_start: u32 = 0;
959997
960998 // Build set of unused variables by parsing diagnostics
961− let result = htmlang::parser::parse(text);
962999 let mut unused_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
9631000 for d in &result.diagnostics {
9641001 if d.message.contains("unused variable '$")
src/bin/htmlang_lsp/completion.rs+496−4
@@ -57,12 +57,18 @@ pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem>
5757 }
5858 }
5959
60− // Color value completions after color-related attributes
60+ // Attribute-value enums for `attr <value>` patterns (e.g. type, cursor).
61+ if let Some(values) = attr_value_completions(before, edit_range) {
62+ return values;
63+ }
64+
65+ // Color value completions after color-related attributes.
6166 if let Some(colors) = color_value_completions(before, edit_range) {
6267 return colors;
6368 }
6469
65− return attr_completions(edit_range);
70+ let element = owning_element(text, position);
71+ return attr_completions(edit_range, element.as_deref());
6672 }
6773
6874 // $ variable reference outside brackets
@@ -116,6 +122,45 @@ pub(crate) fn in_brackets(text: &str) -> bool {
116122 depth > 0
117123}
118124
125+/// Extract design tokens from any `@theme` blocks in the source.
126+///
127+/// `@theme` opens a block whose indented children are `name value` pairs.
128+/// Each pair becomes both a `$variable` reference and a CSS custom property
129+/// at codegen time. We surface them as completions and hover targets.
130+pub(crate) fn collect_theme_tokens(text: &str) -> Vec<(String, String)> {
131+ let mut out = Vec::new();
132+ let lines: Vec<&str> = text.lines().collect();
133+ let mut i = 0;
134+ while i < lines.len() {
135+ let line = lines[i];
136+ let trimmed = line.trim_start();
137+ if trimmed == "@theme" || trimmed.starts_with("@theme ") {
138+ let header_indent = line.len() - trimmed.len();
139+ let mut j = i + 1;
140+ while j < lines.len() {
141+ let l = lines[j];
142+ if l.trim().is_empty() {
143+ j += 1;
144+ continue;
145+ }
146+ let indent = l.len() - l.trim_start().len();
147+ if indent <= header_indent {
148+ break;
149+ }
150+ let body = l.trim();
151+ if let Some((name, value)) = body.split_once(char::is_whitespace) {
152+ out.push((name.trim().to_string(), value.trim().to_string()));
153+ }
154+ j += 1;
155+ }
156+ i = j;
157+ } else {
158+ i += 1;
159+ }
160+ }
161+ out
162+}
163+
119164fn item(
120165 label: &str,
121166 kind: CompletionItemKind,
@@ -637,7 +682,431 @@ pub(crate) fn use_symbol_completions(
637682 items
638683}
639684
640−fn attr_completions(range: Range) -> Vec<CompletionItem> {
685+#[cfg(test)]
686+mod tests {
687+ use super::*;
688+
689+ fn pos(line: u32, ch: u32) -> Position {
690+ Position::new(line, ch)
691+ }
692+
693+ #[test]
694+ fn owning_element_finds_element_on_same_line() {
695+ let text = "@input [type text, ";
696+ // Cursor at end of line — inside the unclosed `[`.
697+ assert_eq!(
698+ owning_element(text, pos(0, text.len() as u32)),
699+ Some("input".to_string())
700+ );
701+ }
702+
703+ #[test]
704+ fn owning_element_finds_element_across_lines() {
705+ let text = "@button [\n padding 10,\n ";
706+ assert_eq!(
707+ owning_element(text, pos(2, 2)),
708+ Some("button".to_string())
709+ );
710+ }
711+
712+ #[test]
713+ fn owning_element_returns_none_when_not_in_brackets() {
714+ let text = "@row\n";
715+ assert_eq!(owning_element(text, pos(0, 4)), None);
716+ }
717+
718+ #[test]
719+ fn owning_element_skips_nested_brackets() {
720+ let text = "@el [transform translate(10, [20, 30]), ";
721+ // Cursor sits inside the outermost bracket after the inner one closed.
722+ assert_eq!(
723+ owning_element(text, pos(0, text.len() as u32)),
724+ Some("el".to_string())
725+ );
726+ }
727+
728+ #[test]
729+ fn theme_tokens_extracted_from_block() {
730+ let text = "@theme\n primary #3b82f6\n spacing-md 16\n@row\n";
731+ let tokens = collect_theme_tokens(text);
732+ assert_eq!(
733+ tokens,
734+ vec![
735+ ("primary".into(), "#3b82f6".into()),
736+ ("spacing-md".into(), "16".into()),
737+ ]
738+ );
739+ }
740+}
741+
742+/// Walk back from `position` to find the element directive that opened the
743+/// nearest unmatched `[`. Returns the bare name without the leading `@`
744+/// (e.g. `"input"`).
745+pub(crate) fn owning_element(text: &str, position: Position) -> Option<String> {
746+ let lines: Vec<&str> = text.lines().collect();
747+ // First, locate the line that contains the unmatched `[`. We scan from
748+ // the cursor back, tracking depth.
749+ let cursor_line = position.line as usize;
750+ let cursor_col = (position.character as usize)
751+ .min(lines.get(cursor_line).map(|l| l.len()).unwrap_or(0));
752+ let mut depth: i32 = 0;
753+ let mut bracket_line: Option<usize> = None;
754+ let mut bracket_col: usize = 0;
755+ 'outer: for line_idx in (0..=cursor_line).rev() {
756+ let line = lines[line_idx];
757+ let last = if line_idx == cursor_line {
758+ cursor_col
759+ } else {
760+ line.len()
761+ };
762+ for (col, ch) in line[..last].char_indices().rev() {
763+ match ch {
764+ ']' => depth += 1,
765+ '[' => {
766+ if depth == 0 {
767+ bracket_line = Some(line_idx);
768+ bracket_col = col;
769+ break 'outer;
770+ }
771+ depth -= 1;
772+ }
773+ _ => {}
774+ }
775+ }
776+ }
777+ let line_idx = bracket_line?;
778+ let line = lines[line_idx];
779+ // The owning element should appear on the same line as the `[`. Look
780+ // backwards for an `@name` token before the bracket. Anything else
781+ // (e.g. `$bundle [...]`) doesn't bind to a builtin element.
782+ let prefix = &line[..bracket_col];
783+ let at_pos = prefix.rfind('@')?;
784+ let after_at = &prefix[at_pos + 1..];
785+ let name_end = after_at
786+ .find(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
787+ .unwrap_or(after_at.len());
788+ let name = &after_at[..name_end];
789+ if name.is_empty() {
790+ return None;
791+ }
792+ Some(name.to_string())
793+}
794+
795+/// Attributes the LSP knows are specifically meaningful for a given element.
796+/// Universal styling attributes (padding, color, etc.) aren't listed here —
797+/// they remain available to every element via `attr_completions`.
798+fn element_specific_attrs(element: &str) -> &'static [&'static str] {
799+ match element {
800+ "input" => &[
801+ "type",
802+ "name",
803+ "value",
804+ "placeholder",
805+ "required",
806+ "disabled",
807+ "checked",
808+ "readonly",
809+ "pattern",
810+ "min",
811+ "max",
812+ "step",
813+ "multiple",
814+ "maxlength",
815+ "minlength",
816+ "autofocus",
817+ "autocomplete",
818+ "inputmode",
819+ "spellcheck",
820+ "list",
821+ "accept",
822+ ],
823+ "button" | "btn" => &[
824+ "type",
825+ "disabled",
826+ "name",
827+ "value",
828+ "autofocus",
829+ "popovertarget",
830+ "popovertargetaction",
831+ ],
832+ "select" => &["name", "multiple", "required", "disabled", "size", "autofocus"],
833+ "textarea" => &[
834+ "name",
835+ "rows",
836+ "cols",
837+ "placeholder",
838+ "required",
839+ "disabled",
840+ "readonly",
841+ "maxlength",
842+ "minlength",
843+ "wrap",
844+ "autofocus",
845+ "spellcheck",
846+ ],
847+ "option" | "opt" => &["value", "selected", "disabled", "label"],
848+ "form" => &[
849+ "action",
850+ "method",
851+ "novalidate",
852+ "target",
853+ "autocomplete",
854+ "enctype",
855+ "name",
856+ ],
857+ "image" | "img" => &[
858+ "src",
859+ "alt",
860+ "width",
861+ "height",
862+ "loading",
863+ "decoding",
864+ "fetchpriority",
865+ "srcset",
866+ "sizes",
867+ ],
868+ "link" => &["href", "target", "rel", "download", "referrerpolicy", "type"],
869+ "video" => &[
870+ "src",
871+ "controls",
872+ "autoplay",
873+ "loop",
874+ "muted",
875+ "poster",
876+ "preload",
877+ "width",
878+ "height",
879+ "playsinline",
880+ ],
881+ "audio" => &["src", "controls", "autoplay", "loop", "muted", "preload"],
882+ "iframe" => &[
883+ "src",
884+ "width",
885+ "height",
886+ "sandbox",
887+ "allow",
888+ "allowfullscreen",
889+ "loading",
890+ "referrerpolicy",
891+ ],
892+ "td" | "th" => &["colspan", "rowspan", "scope"],
893+ "meter" => &["value", "min", "max", "low", "high", "optimum"],
894+ "progress" => &["value", "max"],
895+ "details" => &["open"],
896+ "dialog" => &["open"],
897+ "list" => &["ordered", "type", "start"],
898+ "time" => &["datetime"],
899+ "abbr" => &["title"],
900+ "label" => &["for"],
901+ "picture" | "source" => &["src", "srcset", "sizes", "media", "type"],
902+ "meta" => &["name", "content", "charset"],
903+ _ => &[],
904+ }
905+}
906+
907+/// Attributes whose value is a closed enum (e.g. `cursor`, `text-align`).
908+/// Returns the list of valid values when `before` ends with the attribute
909+/// name plus a single space and no value yet typed.
910+fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
911+ let bracket_content = before.rsplit('[').next()?;
912+ let segment = bracket_content.rsplit(',').next()?.trim_start();
913+ let attr_end = segment
914+ .find(|c: char| c.is_whitespace())
915+ .unwrap_or(segment.len());
916+ let attr = &segment[..attr_end];
917+ let after_attr = &segment[attr_end..];
918+ if !after_attr.starts_with(' ') {
919+ return None;
920+ }
921+ // Only fire when no value has been started yet.
922+ let typed = after_attr.trim_start();
923+ if !typed.is_empty() {
924+ return None;
925+ }
926+
927+ // Strip state prefix to find the base attribute.
928+ let base_attr = if let Some(pos) = attr.rfind(':') {
929+ &attr[pos + 1..]
930+ } else {
931+ attr
932+ };
933+
934+ let values: &[&str] = match base_attr {
935+ "type" => &[
936+ "text",
937+ "email",
938+ "password",
939+ "submit",
940+ "button",
941+ "reset",
942+ "checkbox",
943+ "radio",
944+ "file",
945+ "hidden",
946+ "number",
947+ "range",
948+ "search",
949+ "tel",
950+ "url",
951+ "date",
952+ "datetime-local",
953+ "month",
954+ "time",
955+ "week",
956+ "color",
957+ ],
958+ "cursor" => &[
959+ "auto",
960+ "default",
961+ "pointer",
962+ "text",
963+ "wait",
964+ "help",
965+ "not-allowed",
966+ "crosshair",
967+ "move",
968+ "grab",
969+ "grabbing",
970+ "zoom-in",
971+ "zoom-out",
972+ "ew-resize",
973+ "ns-resize",
974+ "nesw-resize",
975+ "nwse-resize",
976+ ],
977+ "text-align" => &["left", "center", "right", "justify", "start", "end"],
978+ "text-transform" => &["uppercase", "lowercase", "capitalize", "none"],
979+ "white-space" => &["normal", "nowrap", "pre", "pre-line", "pre-wrap", "break-spaces"],
980+ "overflow" | "overflow-x" | "overflow-y" => {
981+ &["visible", "hidden", "scroll", "auto", "clip"]
982+ }
983+ "position" => &["static", "relative", "absolute", "fixed", "sticky"],
984+ "display" => &[
985+ "block",
986+ "inline",
987+ "inline-block",
988+ "flex",
989+ "inline-flex",
990+ "grid",
991+ "inline-grid",
992+ "none",
993+ "contents",
994+ "list-item",
995+ "table",
996+ ],
997+ "visibility" => &["visible", "hidden", "collapse"],
998+ "justify-content" => &[
999+ "flex-start",
1000+ "center",
1001+ "flex-end",
1002+ "space-between",
1003+ "space-around",
1004+ "space-evenly",
1005+ "start",
1006+ "end",
1007+ ],
1008+ "align-items" => &[
1009+ "stretch",
1010+ "flex-start",
1011+ "center",
1012+ "flex-end",
1013+ "baseline",
1014+ "start",
1015+ "end",
1016+ ],
1017+ "align-self" => &[
1018+ "auto",
1019+ "stretch",
1020+ "flex-start",
1021+ "center",
1022+ "flex-end",
1023+ "baseline",
1024+ ],
1025+ "object-fit" => &["fill", "contain", "cover", "none", "scale-down"],
1026+ "loading" => &["lazy", "eager"],
1027+ "decoding" => &["async", "sync", "auto"],
1028+ "preload" => &["auto", "metadata", "none"],
1029+ "method" => &["get", "post", "dialog"],
1030+ "target" => &["_self", "_blank", "_parent", "_top"],
1031+ "scope" => &["row", "col", "rowgroup", "colgroup"],
1032+ "wrap" => &["soft", "hard", "off"],
1033+ "inputmode" => &[
1034+ "text",
1035+ "numeric",
1036+ "decimal",
1037+ "email",
1038+ "search",
1039+ "tel",
1040+ "url",
1041+ "none",
1042+ ],
1043+ "enterkeyhint" => &["enter", "done", "go", "next", "previous", "search", "send"],
1044+ "fetchpriority" => &["high", "low", "auto"],
1045+ "spellcheck" | "translate" => &["true", "false"],
1046+ "color-scheme" => &["light", "dark", "light dark", "normal"],
1047+ "appearance" => &["none", "auto"],
1048+ "autocomplete" => &["on", "off", "name", "email", "username", "current-password", "new-password"],
1049+ "scroll-behavior" => &["smooth", "auto"],
1050+ "resize" => &["none", "both", "horizontal", "vertical", "block", "inline"],
1051+ "writing-mode" => &["horizontal-tb", "vertical-rl", "vertical-lr"],
1052+ "direction" => &["ltr", "rtl"],
1053+ "list-style" => &["disc", "circle", "square", "decimal", "none"],
1054+ "border-collapse" => &["collapse", "separate"],
1055+ "text-decoration" => &["none", "underline", "overline", "line-through"],
1056+ "text-decoration-style" => &["solid", "double", "dotted", "dashed", "wavy"],
1057+ "text-wrap" => &["wrap", "nowrap", "balance", "pretty", "stable"],
1058+ "font-style" => &["normal", "italic", "oblique"],
1059+ "font-weight" => &[
1060+ "100", "200", "300", "400", "500", "600", "700", "800", "900", "normal", "bold",
1061+ "lighter", "bolder",
1062+ ],
1063+ "vertical-align" => &[
1064+ "baseline",
1065+ "top",
1066+ "middle",
1067+ "bottom",
1068+ "text-top",
1069+ "text-bottom",
1070+ "sub",
1071+ "super",
1072+ ],
1073+ "user-select" => &["none", "auto", "text", "all", "contain"],
1074+ "pointer-events" => &["none", "auto"],
1075+ "popovertargetaction" => &["toggle", "show", "hide"],
1076+ "popover" => &["auto", "manual"],
1077+ "hyphens" => &["none", "manual", "auto"],
1078+ "isolation" => &["auto", "isolate"],
1079+ "touch-action" => &[
1080+ "none",
1081+ "pan-x",
1082+ "pan-y",
1083+ "manipulation",
1084+ "auto",
1085+ "pinch-zoom",
1086+ ],
1087+ "contain" => &["none", "strict", "content", "size", "layout", "style", "paint"],
1088+ "content-visibility" => &["visible", "auto", "hidden"],
1089+ _ => return None,
1090+ };
1091+
1092+ Some(
1093+ values
1094+ .iter()
1095+ .map(|v| CompletionItem {
1096+ label: v.to_string(),
1097+ kind: Some(CompletionItemKind::ENUM_MEMBER),
1098+ detail: Some(format!("value for {}", base_attr)),
1099+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1100+ range,
1101+ new_text: v.to_string(),
1102+ })),
1103+ ..Default::default()
1104+ })
1105+ .collect(),
1106+ )
1107+}
1108+
1109+fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem> {
6411110 [
6421111 // Layout
6431112 ("spacing", "Gap between children (supports CSS units)", true),
@@ -1217,7 +1686,20 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
12171686 } else {
12181687 name.to_string()
12191688 };
1220− item(name, CompletionItemKind::PROPERTY, detail, &insert, range)
1689+ let mut completion = item(name, CompletionItemKind::PROPERTY, detail, &insert, range);
1690+ // Boost element-specific attributes to the top of the list when we
1691+ // know which element owns the brackets. The default sort prefix is
1692+ // "5_" so unboosted entries land below the prioritized ones, while
1693+ // staying above snippets (which use "zz_").
1694+ let boosted = element
1695+ .map(|e| element_specific_attrs(e).contains(name))
1696+ .unwrap_or(false);
1697+ completion.sort_text = Some(if boosted {
1698+ format!("0_{}", name)
1699+ } else {
1700+ format!("5_{}", name)
1701+ });
1702+ completion
12211703 })
12221704 .collect()
12231705}
@@ -1378,6 +1860,16 @@ fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionI
13781860fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
13791861 let mut items = Vec::new();
13801862
1863+ for (name, value) in collect_theme_tokens(text) {
1864+ items.push(item(
1865+ &format!("${}", name),
1866+ CompletionItemKind::COLOR,
1867+ &format!("@theme \u{2014} {}", value),
1868+ &format!("${}", name),
1869+ range,
1870+ ));
1871+ }
1872+
13811873 for line in text.lines() {
13821874 let trimmed = line.trim();
13831875 if let Some(rest) = trimmed.strip_prefix("@let ")
src/bin/htmlang_lsp/hover.rs+12−1
@@ -1,5 +1,7 @@
11use tower_lsp::lsp_types::*;
22
3+use crate::completion::collect_theme_tokens;
4+
35pub(crate) fn hover_at(text: &str, position: Position) -> Option<Hover> {
46 let lines: Vec<&str> = text.lines().collect();
57 let line = lines.get(position.line as usize)?;
@@ -7,7 +9,7 @@ pub(crate) fn hover_at(text: &str, position: Position) -> Option<Hover> {
79 let word = word_at(line, col)?;
810
911 let doc = if let Some(var_name) = word.strip_prefix('$') {
10− hover_variable(text, var_name)
12+ hover_theme_token(text, var_name).or_else(|| hover_variable(text, var_name))
1113 } else if let Some(fn_name) = word.strip_prefix('@') {
1214 hover_user_fn(text, fn_name).or_else(|| hover_builtin(&word))
1315 } else {
@@ -43,6 +45,15 @@ pub(crate) fn is_word_byte(c: u8) -> bool {
4345 c.is_ascii_alphanumeric() || c == b'@' || c == b'$' || c == b'-' || c == b'_' || c == b':'
4446}
4547
48+fn hover_theme_token(text: &str, name: &str) -> Option<String> {
49+ let tokens = collect_theme_tokens(text);
50+ let (token_name, value) = tokens.iter().find(|(n, _)| n == name)?;
51+ Some(format!(
52+ "**${}** \u{2014} `@theme` token\n\nValue: `{}`\n\nAlso available as the CSS custom property `--{}`.",
53+ token_name, value, token_name
54+ ))
55+}
56+
4657fn hover_variable(text: &str, name: &str) -> Option<String> {
4758 for line in text.lines() {
4859 let trimmed = line.trim();
src/bin/htmlang_lsp/main.rs+376−258
@@ -1,7 +1,10 @@
11use std::collections::HashMap;
22use std::sync::Arc;
3+use std::time::Duration;
34
4−use tokio::sync::RwLock;
5+use serde_json::json;
6+use tokio::sync::{Mutex, RwLock};
7+use tokio::task::JoinHandle;
58use tower_lsp::jsonrpc::Result;
69use tower_lsp::lsp_types::*;
710use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -10,16 +13,17 @@ mod analysis;
1013mod completion;
1114mod hover;
1215mod navigation;
16+mod state;
1317
1418use analysis::{
15− code_actions, document_symbols, find_colors, folding_ranges, get_signature_help, inlay_hints,
16− semantic_tokens,
19+ code_actions, find_colors, folding_ranges, get_signature_help, inlay_hints, semantic_tokens,
1720};
1821use completion::{completions, path_completions, use_symbol_completions};
1922use hover::hover_at;
2023use navigation::{
2124 definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
2225};
26+use state::{DocumentEntry, WorkspaceIndex, apply_change};
2327
2428// ---------------------------------------------------------------------------
2529// Backend
@@ -27,57 +31,132 @@ use navigation::{
2731
2832struct Backend {
2933 client: Client,
30− documents: Arc<RwLock<HashMap<Url, String>>>,
34+ documents: Arc<RwLock<HashMap<Url, Arc<DocumentEntry>>>>,
35+ index: Arc<RwLock<WorkspaceIndex>>,
36+ /// Per-URI debounce handles. The value is a join handle for an in-flight
37+ /// diagnostic publish; replacing the entry cancels the prior handle.
38+ pending_diags: Arc<Mutex<HashMap<Url, JoinHandle<()>>>>,
3139}
3240
41+const DIAG_DEBOUNCE: Duration = Duration::from_millis(150);
42+
3343impl Backend {
34− async fn on_change(&self, uri: Url, text: String) {
35− let result = htmlang::parser::parse(&text);
36− let diags: Vec<Diagnostic> = result
37− .diagnostics
38− .iter()
39− .map(|d| {
40− let severity = match d.severity {
41− htmlang::parser::Severity::Error => DiagnosticSeverity::ERROR,
42− htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
43− htmlang::parser::Severity::Info => DiagnosticSeverity::INFORMATION,
44− htmlang::parser::Severity::Help => DiagnosticSeverity::HINT,
45− };
46− let line = d.line.saturating_sub(1) as u32;
47− let col_start = d.column.unwrap_or(0) as u32;
48− let col_end = if d.column.is_some() {
49− // Highlight a reasonable span from the column
50− let lines_vec: Vec<&str> = text.lines().collect();
51− lines_vec
52− .get(line as usize)
53− .map(|l| l.len() as u32)
54− .unwrap_or(col_start + 1)
55− } else {
56− 1000 // Highlight entire line when no column info
57− };
58− Diagnostic {
59− range: Range::new(Position::new(line, col_start), Position::new(line, col_end)),
60− severity: Some(severity),
61− source: Some("htmlang".into()),
62− message: d.message.clone(),
63− ..Default::default()
64− }
65− })
66− .collect();
67− self.client
68− .publish_diagnostics(uri.clone(), diags, None)
69− .await;
70− self.documents.write().await.insert(uri, text);
44+ /// Look up the open document for `uri`. Returns the cached entry, which
45+ /// the caller can use to access text plus the lazy parse cache.
46+ async fn doc(&self, uri: &Url) -> Option<Arc<DocumentEntry>> {
47+ self.documents.read().await.get(uri).cloned()
7148 }
49+
50+ /// Replace the stored document with a new entry, scheduling a debounced
51+ /// diagnostic publish and refreshing the workspace symbol index.
52+ async fn set_doc(&self, uri: Url, text: String, version: i32) {
53+ let entry = Arc::new(DocumentEntry::new(text, version));
54+ self.documents
55+ .write()
56+ .await
57+ .insert(uri.clone(), entry.clone());
58+
59+ // Refresh the workspace symbol index for this file synchronously —
60+ // symbol extraction is cheap (single pass over text) and keeps
61+ // workspace-wide queries consistent without waiting on the debounce.
62+ if let Ok(path) = uri.to_file_path() {
63+ self.index
64+ .write()
65+ .await
66+ .update_from_text(&path, &entry.text);
67+ }
68+
69+ self.schedule_diagnostics(uri, entry).await;
70+ }
71+
72+ /// Schedule a diagnostic publish after a short debounce window. If another
73+ /// change comes in during the window, the previous publish is cancelled
74+ /// before it runs, so we only parse and publish once per quiet period.
75+ async fn schedule_diagnostics(&self, uri: Url, entry: Arc<DocumentEntry>) {
76+ let client = self.client.clone();
77+ let uri_for_task = uri.clone();
78+ let handle = tokio::spawn(async move {
79+ tokio::time::sleep(DIAG_DEBOUNCE).await;
80+ let parse = entry.parse();
81+ let diags = build_diagnostics(&entry.text, &parse);
82+ client
83+ .publish_diagnostics(uri_for_task, diags, Some(entry.version))
84+ .await;
85+ });
86+
87+ let mut pending = self.pending_diags.lock().await;
88+ if let Some(prev) = pending.insert(uri, handle) {
89+ prev.abort();
90+ }
91+ }
92+
93+ /// Walk the workspace once on first use and register a file watcher for
94+ /// `.hl` files. Idempotent — subsequent calls short-circuit.
95+ async fn ensure_index_ready(&self) {
96+ let needs_scan = {
97+ let idx = self.index.read().await;
98+ !idx.scanned && idx.root.is_some()
99+ };
100+ if needs_scan {
101+ self.index.write().await.scan();
102+ }
103+ }
104+}
105+
106+fn build_diagnostics(text: &str, result: &htmlang::parser::ParseResult) -> Vec<Diagnostic> {
107+ result
108+ .diagnostics
109+ .iter()
110+ .map(|d| {
111+ let severity = match d.severity {
112+ htmlang::parser::Severity::Error => DiagnosticSeverity::ERROR,
113+ htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
114+ htmlang::parser::Severity::Info => DiagnosticSeverity::INFORMATION,
115+ htmlang::parser::Severity::Help => DiagnosticSeverity::HINT,
116+ };
117+ let line = d.line.saturating_sub(1) as u32;
118+ let col_start = d.column.unwrap_or(0) as u32;
119+ let col_end = if d.column.is_some() {
120+ let lines_vec: Vec<&str> = text.lines().collect();
121+ lines_vec
122+ .get(line as usize)
123+ .map(|l| l.len() as u32)
124+ .unwrap_or(col_start + 1)
125+ } else {
126+ 1000
127+ };
128+ Diagnostic {
129+ range: Range::new(Position::new(line, col_start), Position::new(line, col_end)),
130+ severity: Some(severity),
131+ source: Some("htmlang".into()),
132+ message: d.message.clone(),
133+ ..Default::default()
134+ }
135+ })
136+ .collect()
72137}
73138
74139#[tower_lsp::async_trait]
75140impl LanguageServer for Backend {
76− async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
141+ async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
142+ // Capture the workspace root if the client provided one. Falls back
143+ // to the parent of the first opened file later, in `did_open`.
144+ #[allow(deprecated)]
145+ let root = params
146+ .workspace_folders
147+ .as_ref()
148+ .and_then(|folders| folders.first())
149+ .and_then(|f| f.uri.to_file_path().ok())
150+ .or_else(|| params.root_uri.as_ref().and_then(|u| u.to_file_path().ok()));
151+ if let Some(root) = root {
152+ self.index.write().await.set_root(root);
153+ }
154+
77155 Ok(InitializeResult {
78156 capabilities: ServerCapabilities {
157+ position_encoding: Some(PositionEncodingKind::UTF8),
79158 text_document_sync: Some(TextDocumentSyncCapability::Kind(
80− TextDocumentSyncKind::FULL,
159+ TextDocumentSyncKind::INCREMENTAL,
81160 )),
82161 completion_provider: Some(CompletionOptions {
83162 trigger_characters: Some(vec!["@".into(), "$".into(), "[".into(), ",".into()]),
@@ -113,9 +192,7 @@ impl LanguageServer for Backend {
113192 SemanticTokenType::COMMENT,
114193 SemanticTokenType::PROPERTY,
115194 ],
116− token_modifiers: vec![
117− SemanticTokenModifier::new("deprecated"), // bit 0 = 1 -> dimmed/strikethrough
118− ],
195+ token_modifiers: vec![SemanticTokenModifier::new("deprecated")],
119196 },
120197 full: Some(SemanticTokensFullOptions::Bool(true)),
121198 range: None,
@@ -143,27 +220,74 @@ impl LanguageServer for Backend {
143220 code_lens_provider: Some(CodeLensOptions {
144221 resolve_provider: Some(false),
145222 }),
223+ execute_command_provider: Some(ExecuteCommandOptions {
224+ commands: vec!["htmlang.showReferences".into()],
225+ ..Default::default()
226+ }),
227+ workspace: Some(WorkspaceServerCapabilities {
228+ workspace_folders: Some(WorkspaceFoldersServerCapabilities {
229+ supported: Some(true),
230+ change_notifications: Some(OneOf::Left(true)),
231+ }),
232+ ..Default::default()
233+ }),
146234 ..Default::default()
147235 },
148236 ..Default::default()
149237 })
150238 }
151239
152− async fn initialized(&self, _: InitializedParams) {}
240+ async fn initialized(&self, _: InitializedParams) {
241+ // Dynamically register a watcher for .hl files under the workspace.
242+ // Handled clientside (VS Code etc.) and dispatched back to us via
243+ // `did_change_watched_files`.
244+ let registration = Registration {
245+ id: "htmlang-watch-hl".into(),
246+ method: "workspace/didChangeWatchedFiles".into(),
247+ register_options: Some(json!({
248+ "watchers": [{ "globPattern": "**/*.hl" }]
249+ })),
250+ };
251+ let _ = self.client.register_capability(vec![registration]).await;
252+ self.ensure_index_ready().await;
253+ }
153254
154255 async fn shutdown(&self) -> Result<()> {
155256 Ok(())
156257 }
157258
158259 async fn did_open(&self, params: DidOpenTextDocumentParams) {
159− self.on_change(params.text_document.uri, params.text_document.text)
160− .await;
260+ // If we still don't have a workspace root, fall back to the parent of
261+ // the first opened file. Common case for `code path/to/file.hl`.
262+ if self.index.read().await.root.is_none() {
263+ if let Ok(path) = params.text_document.uri.to_file_path()
264+ && let Some(parent) = path.parent()
265+ {
266+ self.index.write().await.set_root(parent.to_path_buf());
267+ self.index.write().await.scan();
268+ }
269+ }
270+ self.set_doc(
271+ params.text_document.uri,
272+ params.text_document.text,
273+ params.text_document.version,
274+ )
275+ .await;
161276 }
162277
163278 async fn did_change(&self, params: DidChangeTextDocumentParams) {
164− if let Some(change) = params.content_changes.into_iter().last() {
165− self.on_change(params.text_document.uri, change.text).await;
279+ let uri = params.text_document.uri.clone();
280+ let version = params.text_document.version;
281+
282+ // Start from the current text and apply each change in order.
283+ let mut text = match self.doc(&uri).await {
284+ Some(d) => d.text.clone(),
285+ None => String::new(),
286+ };
287+ for change in &params.content_changes {
288+ apply_change(&mut text, change);
166289 }
290+ self.set_doc(uri, text, version).await;
167291 }
168292
169293 async fn did_close(&self, params: DidCloseTextDocumentParams) {
@@ -171,22 +295,45 @@ impl LanguageServer for Backend {
171295 .write()
172296 .await
173297 .remove(&params.text_document.uri);
298+ if let Some(handle) = self
299+ .pending_diags
300+ .lock()
301+ .await
302+ .remove(&params.text_document.uri)
303+ {
304+ handle.abort();
305+ }
174306 self.client
175307 .publish_diagnostics(params.text_document.uri, vec![], None)
176308 .await;
177309 }
178310
311+ async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
312+ let mut idx = self.index.write().await;
313+ for change in params.changes {
314+ let Ok(path) = change.uri.to_file_path() else {
315+ continue;
316+ };
317+ match change.typ {
318+ FileChangeType::CREATED | FileChangeType::CHANGED => {
319+ idx.update_from_disk(&path);
320+ }
321+ FileChangeType::DELETED => {
322+ idx.remove(&path);
323+ }
324+ _ => {}
325+ }
326+ }
327+ }
328+
179329 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
180330 let uri = &params.text_document_position.text_document.uri;
181331 let pos = params.text_document_position.position;
182− let docs = self.documents.read().await;
183− let text = match docs.get(uri) {
184− Some(t) => t.clone(),
185− None => return Ok(None),
332+ let Some(doc) = self.doc(uri).await else {
333+ return Ok(None);
186334 };
187− drop(docs);
335+ let text = &doc.text;
188336
189− // Check if we're on an @include/@import/@use/@extends line for path/symbol completions
190337 let lines: Vec<&str> = text.lines().collect();
191338 if let Some(line) = lines.get(pos.line as usize) {
192339 let trimmed = line.trim_start();
@@ -199,9 +346,7 @@ impl LanguageServer for Backend {
199346 return Ok(Some(CompletionResponse::Array(items)));
200347 }
201348 }
202− // @use "file.hl" fn1, fn2 -- after the filename, suggest exported names
203349 if let Some(after_use) = trimmed.strip_prefix("@use ") {
204− // If we already have a filename (quoted or unquoted), suggest symbols from that file
205350 let has_file = after_use.contains(".hl");
206351 if has_file {
207352 let items = use_symbol_completions(uri, trimmed, pos);
@@ -217,7 +362,7 @@ impl LanguageServer for Backend {
217362 }
218363 }
219364
220− let items = completions(&text, pos);
365+ let items = completions(text, pos);
221366 Ok(if items.is_empty() {
222367 None
223368 } else {
@@ -228,14 +373,10 @@ impl LanguageServer for Backend {
228373 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
229374 let uri = &params.text_document_position_params.text_document.uri;
230375 let pos = params.text_document_position_params.position;
231− let docs = self.documents.read().await;
232− let text = match docs.get(uri) {
233− Some(t) => t.clone(),
234− None => return Ok(None),
376+ let Some(doc) = self.doc(uri).await else {
377+ return Ok(None);
235378 };
236− drop(docs);
237−
238− Ok(hover_at(&text, pos))
379+ Ok(hover_at(&doc.text, pos))
239380 }
240381
241382 async fn goto_definition(
@@ -248,14 +389,19 @@ impl LanguageServer for Backend {
248389 .uri
249390 .clone();
250391 let pos = params.text_document_position_params.position;
251− let docs = self.documents.read().await;
252− let text = match docs.get(&uri) {
253− Some(t) => t.clone(),
254− None => return Ok(None),
392+ let Some(doc) = self.doc(&uri).await else {
393+ return Ok(None);
255394 };
256− drop(docs);
257395
258− Ok(definition_at(&text, pos, &uri))
396+ // Try the local file first — fast and matches common case.
397+ if let Some(local) = definition_at(&doc.text, pos, &uri) {
398+ return Ok(Some(local));
399+ }
400+
401+ // Fall back to the workspace index for cross-file lookups.
402+ let idx = self.index.read().await;
403+ let target = navigation::cross_file_definition(&doc.text, pos, &idx);
404+ Ok(target)
259405 }
260406
261407 async fn prepare_rename(
@@ -264,28 +410,20 @@ impl LanguageServer for Backend {
264410 ) -> Result<Option<PrepareRenameResponse>> {
265411 let uri = &params.text_document.uri;
266412 let pos = params.position;
267− let docs = self.documents.read().await;
268− let text = match docs.get(uri) {
269− Some(t) => t.clone(),
270− None => return Ok(None),
413+ let Some(doc) = self.doc(uri).await else {
414+ return Ok(None);
271415 };
272− drop(docs);
273−
274− Ok(prepare_rename_at(&text, pos))
416+ Ok(prepare_rename_at(&doc.text, pos))
275417 }
276418
277419 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
278420 let uri = params.text_document_position.text_document.uri.clone();
279421 let pos = params.text_document_position.position;
280422 let new_name = params.new_name;
281− let docs = self.documents.read().await;
282− let text = match docs.get(&uri) {
283− Some(t) => t.clone(),
284− None => return Ok(None),
423+ let Some(doc) = self.doc(&uri).await else {
424+ return Ok(None);
285425 };
286− drop(docs);
287−
288− Ok(rename_at(&text, pos, &new_name, &uri))
426+ Ok(rename_at(&doc.text, pos, &new_name, &uri))
289427 }
290428
291429 async fn document_symbol(
@@ -293,31 +431,29 @@ impl LanguageServer for Backend {
293431 params: DocumentSymbolParams,
294432 ) -> Result<Option<DocumentSymbolResponse>> {
295433 let uri = &params.text_document.uri;
296− let docs = self.documents.read().await;
297− let text = match docs.get(uri) {
298− Some(t) => t.clone(),
299− None => return Ok(None),
434+ let Some(doc) = self.doc(uri).await else {
435+ return Ok(None);
300436 };
301− drop(docs);
302−
303− let symbols = document_symbols(&text);
437+ let symbols = doc.symbols();
304438 Ok(if symbols.is_empty() {
305439 None
306440 } else {
307− Some(DocumentSymbolResponse::Flat(symbols))
441+ // Re-stamp URI so the cached entry's "file:///" placeholder is
442+ // replaced with the document's own URI.
443+ let mut out = (*symbols).clone();
444+ for s in &mut out {
445+ s.location.uri = uri.clone();
446+ }
447+ Some(DocumentSymbolResponse::Flat(out))
308448 })
309449 }
310450
311451 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
312452 let uri = params.text_document.uri.clone();
313− let docs = self.documents.read().await;
314− let text = match docs.get(&uri) {
315− Some(t) => t.clone(),
316− None => return Ok(None),
453+ let Some(doc) = self.doc(&uri).await else {
454+ return Ok(None);
317455 };
318− drop(docs);
319−
320− let actions = code_actions(&text, &params.range, &params.context.diagnostics, &uri);
456+ let actions = code_actions(&doc.text, &params.range, &params.context.diagnostics, &uri);
321457 Ok(if actions.is_empty() {
322458 None
323459 } else {
@@ -327,13 +463,10 @@ impl LanguageServer for Backend {
327463
328464 async fn document_color(&self, params: DocumentColorParams) -> Result<Vec<ColorInformation>> {
329465 let uri = &params.text_document.uri;
330− let docs = self.documents.read().await;
331− let text = match docs.get(uri) {
332− Some(t) => t.clone(),
333− None => return Ok(vec![]),
466+ let Some(doc) = self.doc(uri).await else {
467+ return Ok(vec![]);
334468 };
335− drop(docs);
336− Ok(find_colors(&text))
469+ Ok(find_colors(&doc.text))
337470 }
338471
339472 async fn color_presentation(
@@ -341,34 +474,45 @@ impl LanguageServer for Backend {
341474 params: ColorPresentationParams,
342475 ) -> Result<Vec<ColorPresentation>> {
343476 let c = params.color;
344− let r = (c.red * 255.0) as u8;
345− let g = (c.green * 255.0) as u8;
346− let b = (c.blue * 255.0) as u8;
477+ let r = (c.red * 255.0).round() as u8;
478+ let g = (c.green * 255.0).round() as u8;
479+ let b = (c.blue * 255.0).round() as u8;
480+ let a = (c.alpha * 255.0).round() as u8;
347481 let hex = if c.alpha < 1.0 {
348− let a = (c.alpha * 255.0) as u8;
349482 format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
350483 } else {
351484 format!("#{:02x}{:02x}{:02x}", r, g, b)
352485 };
353− Ok(vec![ColorPresentation {
486+ let mut presentations = vec![ColorPresentation {
354487 label: hex.clone(),
355488 text_edit: Some(TextEdit {
356489 range: params.range,
357− new_text: hex,
490+ new_text: hex.clone(),
358491 }),
359492 additional_text_edits: None,
360− }])
493+ }];
494+ // Offer a CSS named-color presentation when one matches exactly.
495+ if c.alpha >= 1.0
496+ && let Some(name) = analysis::named_color_for(r, g, b)
497+ {
498+ presentations.push(ColorPresentation {
499+ label: name.to_string(),
500+ text_edit: Some(TextEdit {
501+ range: params.range,
502+ new_text: name.to_string(),
503+ }),
504+ additional_text_edits: None,
505+ });
506+ }
507+ Ok(presentations)
361508 }
362509
363510 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
364511 let uri = &params.text_document.uri;
365− let docs = self.documents.read().await;
366− let text = match docs.get(uri) {
367− Some(t) => t.clone(),
368− None => return Ok(None),
512+ let Some(doc) = self.doc(uri).await else {
513+ return Ok(None);
369514 };
370− drop(docs);
371− let ranges = folding_ranges(&text);
515+ let ranges = folding_ranges(&doc.text);
372516 Ok(if ranges.is_empty() {
373517 None
374518 } else {
@@ -381,13 +525,11 @@ impl LanguageServer for Backend {
381525 params: SemanticTokensParams,
382526 ) -> Result<Option<SemanticTokensResult>> {
383527 let uri = &params.text_document.uri;
384− let docs = self.documents.read().await;
385− let text = match docs.get(uri) {
386− Some(t) => t.clone(),
387− None => return Ok(None),
528+ let Some(doc) = self.doc(uri).await else {
529+ return Ok(None);
388530 };
389− drop(docs);
390− let tokens = semantic_tokens(&text);
531+ let parse = doc.parse();
532+ let tokens = semantic_tokens(&doc.text, &parse);
391533 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
392534 result_id: None,
393535 data: tokens,
@@ -396,94 +538,49 @@ impl LanguageServer for Backend {
396538
397539 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
398540 let uri = &params.text_document.uri;
399− let docs = self.documents.read().await;
400− let text = match docs.get(uri) {
401− Some(t) => t.clone(),
402− None => return Ok(None),
541+ let Some(doc) = self.doc(uri).await else {
542+ return Ok(None);
403543 };
404− drop(docs);
405− let hints = inlay_hints(&text);
544+ let hints = inlay_hints(&doc.text);
406545 Ok(if hints.is_empty() { None } else { Some(hints) })
407546 }
408547
409− #[allow(deprecated)]
410548 async fn symbol(
411549 &self,
412550 params: WorkspaceSymbolParams,
413551 ) -> Result<Option<Vec<SymbolInformation>>> {
552+ self.ensure_index_ready().await;
553+
414554 let query = params.query.to_lowercase();
415− let docs = self.documents.read().await;
555+
556+ // Open buffers always win — they may be ahead of the on-disk version.
416557 let mut all_symbols: Vec<SymbolInformation> = Vec::new();
417558 let mut covered_files: std::collections::HashSet<std::path::PathBuf> =
418559 std::collections::HashSet::new();
419560
420− // Open documents first — their in-memory text may be newer than what
421− // is on disk.
422− for (uri, text) in docs.iter() {
561+ let docs = self.documents.read().await;
562+ for (uri, doc) in docs.iter() {
423563 if let Ok(path) = uri.to_file_path() {
424564 covered_files.insert(path);
425565 }
426− for mut sym in document_symbols(text) {
427− sym.location.uri = uri.clone();
566+ for sym in doc.symbols().iter() {
428567 if query.is_empty() || sym.name.to_lowercase().contains(&query) {
429− all_symbols.push(sym);
568+ let mut s = sym.clone();
569+ s.location.uri = uri.clone();
570+ all_symbols.push(s);
430571 }
431572 }
432573 }
433574 drop(docs);
434575
435− // Extend search to .hl files on disk. The workspace root is not given
436− // via a workspace folder, so derive it from the first open document's
437− // path (common case: editor opened a folder containing one open file).
438− let workspace_root: Option<std::path::PathBuf> = {
439− let docs = self.documents.read().await;
440− docs.keys()
441− .next()
442− .and_then(|u| u.to_file_path().ok())
443− .and_then(|p| p.parent().map(|p| p.to_path_buf()))
444− };
445− if let Some(root) = workspace_root {
446− let mut stack = vec![root];
447− let max_files = 500; // safety cap for huge workspaces
448− let mut scanned = 0usize;
449− while let Some(dir) = stack.pop() {
450− let Ok(entries) = std::fs::read_dir(&dir) else {
451− continue;
452− };
453− for entry in entries.flatten() {
454− if scanned >= max_files {
455− break;
456− }
457− let path = entry.path();
458− // Skip hidden / vendor / build directories.
459− if let Some(name) = path.file_name().and_then(|n| n.to_str())
460− && (name.starts_with('.')
461− || name == "target"
462− || name == "node_modules"
463− || name == "dist")
464− {
465− continue;
466− }
467− if path.is_dir() {
468− stack.push(path);
469− continue;
470− }
471− if path.extension().is_some_and(|e| e == "hl") && !covered_files.contains(&path)
472− {
473− scanned += 1;
474− let Ok(text) = std::fs::read_to_string(&path) else {
475− continue;
476− };
477− let Ok(file_uri) = Url::from_file_path(&path) else {
478− continue;
479− };
480− for mut sym in document_symbols(&text) {
481− sym.location.uri = file_uri.clone();
482− if query.is_empty() || sym.name.to_lowercase().contains(&query) {
483− all_symbols.push(sym);
484− }
485− }
486− }
576+ let idx = self.index.read().await;
577+ for (path, syms) in &idx.by_file {
578+ if covered_files.contains(path) {
579+ continue;
580+ }
581+ for sym in syms {
582+ if query.is_empty() || sym.name.to_lowercase().contains(&query) {
583+ all_symbols.push(sym.clone());
487584 }
488585 }
489586 }
@@ -501,29 +598,23 @@ impl LanguageServer for Backend {
501598 ) -> Result<Option<LinkedEditingRanges>> {
502599 let uri = &params.text_document_position_params.text_document.uri;
503600 let pos = params.text_document_position_params.position;
504− let docs = self.documents.read().await;
505− let text = match docs.get(uri) {
506− Some(t) => t.clone(),
507− None => return Ok(None),
601+ let Some(doc) = self.doc(uri).await else {
602+ return Ok(None);
508603 };
509− drop(docs);
510− Ok(linked_editing_ranges(&text, pos))
604+ Ok(linked_editing_ranges(&doc.text, pos))
511605 }
512606
513607 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
514608 let uri = &params.text_document.uri;
515− let docs = self.documents.read().await;
516− let text = match docs.get(uri) {
517− Some(t) => t.clone(),
518− None => return Ok(None),
609+ let Some(doc) = self.doc(uri).await else {
610+ return Ok(None);
519611 };
520− drop(docs);
521− let formatted = htmlang::fmt::format(&text);
522− if formatted == text {
612+ let formatted = htmlang::fmt::format(&doc.text);
613+ if formatted == doc.text {
523614 return Ok(None);
524615 }
525− let last_line = text.lines().count().saturating_sub(1) as u32;
526− let last_col = text.lines().last().map_or(0, |l| l.len()) as u32;
616+ let last_line = doc.text.lines().count().saturating_sub(1) as u32;
617+ let last_col = doc.text.lines().last().map_or(0, |l| l.len()) as u32;
527618 Ok(Some(vec![TextEdit {
528619 range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
529620 new_text: formatted,
@@ -536,16 +627,11 @@ impl LanguageServer for Backend {
536627 ) -> Result<Option<Vec<TextEdit>>> {
537628 let uri = &params.text_document.uri;
538629 let range = params.range;
539− let docs = self.documents.read().await;
540− let text = match docs.get(uri) {
541− Some(t) => t.clone(),
542− None => return Ok(None),
630+ let Some(doc) = self.doc(uri).await else {
631+ return Ok(None);
543632 };
544− drop(docs);
545633
546− // Extract the selection (snapping to whole lines — htmlang is
547− // indent-sensitive, so partial-line formatting is meaningless).
548− let lines: Vec<&str> = text.lines().collect();
634+ let lines: Vec<&str> = doc.text.lines().collect();
549635 let start_line = range.start.line as usize;
550636 let end_line = (range.end.line as usize).min(lines.len().saturating_sub(1));
551637 if start_line > end_line {
@@ -571,12 +657,10 @@ impl LanguageServer for Backend {
571657
572658 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
573659 let uri = params.text_document.uri.clone();
574− let docs = self.documents.read().await;
575− let text = match docs.get(&uri) {
576− Some(t) => t.clone(),
577− None => return Ok(None),
660+ let Some(doc) = self.doc(&uri).await else {
661+ return Ok(None);
578662 };
579− drop(docs);
663+ let text = &doc.text;
580664
581665 let Ok(this_path) = uri.to_file_path() else {
582666 return Ok(None);
@@ -594,7 +678,6 @@ impl LanguageServer for Backend {
594678 } else if let Some(rest) = trimmed.strip_prefix("@import ") {
595679 ("@import ", rest)
596680 } else if let Some(rest) = trimmed.strip_prefix("@use ") {
597− // @use "file.hl" fn1, fn2 — only the filename token is linkable.
598681 ("@use ", rest)
599682 } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
600683 ("@extends ", rest)
@@ -602,7 +685,6 @@ impl LanguageServer for Backend {
602685 continue;
603686 };
604687
605− // Pull the filename token (stop at whitespace or `,`).
606688 let name_token: &str = filename
607689 .trim_start_matches('"')
608690 .split(|c: char| c.is_whitespace() || c == ',')
@@ -612,7 +694,6 @@ impl LanguageServer for Backend {
612694 if name_token.is_empty() {
613695 continue;
614696 }
615− // Ignore glob patterns — they don't resolve to a single path.
616697 if name_token.contains('*') || name_token.contains('?') {
617698 continue;
618699 }
@@ -625,8 +706,6 @@ impl LanguageServer for Backend {
625706 continue;
626707 };
627708
628− // Locate the token in the original line so the link highlights the
629− // filename rather than the whole directive.
630709 let scan_from = indent + prefix.len();
631710 let Some(rel_start) = raw_line[scan_from..].find(name_token) else {
632711 continue;
@@ -650,15 +729,10 @@ impl LanguageServer for Backend {
650729
651730 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
652731 let uri = params.text_document.uri.clone();
653− let docs = self.documents.read().await;
654− let text = match docs.get(&uri) {
655− Some(t) => t.clone(),
656− None => return Ok(None),
732+ let Some(doc) = self.doc(&uri).await else {
733+ return Ok(None);
657734 };
658− drop(docs);
659−
660− // Build a simple usage counter by scanning the whole document. For each
661− // @let definition we emit a lens that reports how many call/ref sites exist.
735+ let text = &doc.text;
662736 let lines: Vec<&str> = text.lines().collect();
663737
664738 #[derive(Clone)]
@@ -673,9 +747,7 @@ impl LanguageServer for Backend {
673747 let trimmed = line.trim_start();
674748 if let Some(rest) = trimmed.strip_prefix("@let ") {
675749 let rest = rest.trim();
676− // Determine the kind based on the value
677750 if let Some(name) = rest.split_whitespace().next() {
678− // Check for indented body (function)
679751 let has_body = lines
680752 .get(i + 1)
681753 .map(|l| l.starts_with(" ") || l.starts_with('\t'))
@@ -708,8 +780,6 @@ impl LanguageServer for Backend {
708780
709781 let mut lenses = Vec::with_capacity(defs.len());
710782 for def in &defs {
711− // Count references: for functions, look for `@name` at start-of-token;
712− // for variables / attribute bundles, look for `$name`.
713783 let mut count: usize = 0;
714784 let needle = match def.kind {
715785 "fn" => format!("@{}", def.name),
@@ -722,8 +792,6 @@ impl LanguageServer for Backend {
722792 let mut from = 0;
723793 while let Some(idx) = line[from..].find(&needle) {
724794 let pos = from + idx;
725− // Guard: the char right after the match must not be a valid
726− // identifier continuation, so `$foo` doesn't match `$foobar`.
727795 let after = line.as_bytes().get(pos + needle.len()).copied();
728796 let ok = match after {
729797 None => true,
@@ -741,15 +809,16 @@ impl LanguageServer for Backend {
741809 } else {
742810 format!("{} references", count)
743811 };
812+ // A clickable lens that opens VS Code's references panel at this
813+ // definition. The command name matches what VS Code's built-in
814+ // `editor.action.showReferences` accepts: (uri, position, locations).
815+ let line_pos = Position::new(def.line, 0);
744816 lenses.push(CodeLens {
745− range: Range::new(Position::new(def.line, 0), Position::new(def.line, 0)),
746− // Non-executable lens: clients display the title without an
747− // associated command action. Leaving `command` as None yields an
748− // informational lens in most editors.
817+ range: Range::new(line_pos, line_pos),
749818 command: Some(Command {
750819 title,
751− command: String::new(),
752− arguments: None,
820+ command: "htmlang.showReferences".into(),
821+ arguments: Some(vec![json!(uri), json!(line_pos)]),
753822 }),
754823 data: None,
755824 });
@@ -762,29 +831,76 @@ impl LanguageServer for Backend {
762831 })
763832 }
764833
834+ async fn execute_command(
835+ &self,
836+ params: ExecuteCommandParams,
837+ ) -> Result<Option<serde_json::Value>> {
838+ if params.command == "htmlang.showReferences" {
839+ // The VS Code client converts this to `editor.action.showReferences`
840+ // through a registered middleware; on other clients it's a no-op.
841+ // We still acknowledge it so the lens click doesn't error.
842+ return Ok(None);
843+ }
844+ Ok(None)
845+ }
846+
765847 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
766848 let uri = params.text_document_position.text_document.uri.clone();
767849 let pos = params.text_document_position.position;
768− let docs = self.documents.read().await;
769− let text = match docs.get(&uri) {
770− Some(t) => t.clone(),
771− None => return Ok(None),
850+ let Some(doc) = self.doc(&uri).await else {
851+ return Ok(None);
772852 };
773− drop(docs);
774− let refs = find_references(&text, pos, &uri);
853+ let mut refs = find_references(&doc.text, pos, &uri);
854+
855+ // Workspace-wide reference search: scan every other indexed file for
856+ // textual occurrences of the same symbol.
857+ if let Some(symbol) = navigation::symbol_at(&doc.text, pos) {
858+ self.ensure_index_ready().await;
859+ let idx = self.index.read().await;
860+ for path in idx.iter_files() {
861+ let Ok(file_uri) = Url::from_file_path(path) else {
862+ continue;
863+ };
864+ if file_uri == uri {
865+ continue;
866+ }
867+ // Skip files that have an open buffer — we'd double-count
868+ // because their content might differ from disk.
869+ if self.documents.read().await.contains_key(&file_uri) {
870+ continue;
871+ }
872+ let Ok(text) = std::fs::read_to_string(path) else {
873+ continue;
874+ };
875+ refs.extend(navigation::find_references_for_symbol(
876+ &text, &symbol, &file_uri,
877+ ));
878+ }
879+
880+ // Also search other open buffers.
881+ let docs = self.documents.read().await;
882+ for (other_uri, other_doc) in docs.iter() {
883+ if other_uri == &uri {
884+ continue;
885+ }
886+ refs.extend(navigation::find_references_for_symbol(
887+ &other_doc.text,
888+ &symbol,
889+ other_uri,
890+ ));
891+ }
892+ }
893+
775894 Ok(if refs.is_empty() { None } else { Some(refs) })
776895 }
777896
778897 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
779898 let uri = &params.text_document_position_params.text_document.uri;
780899 let pos = params.text_document_position_params.position;
781− let docs = self.documents.read().await;
782− let text = match docs.get(uri) {
783− Some(t) => t.clone(),
784− None => return Ok(None),
900+ let Some(doc) = self.doc(uri).await else {
901+ return Ok(None);
785902 };
786− drop(docs);
787− Ok(get_signature_help(&text, pos))
903+ Ok(get_signature_help(&doc.text, pos))
788904 }
789905}
790906
@@ -799,6 +915,8 @@ async fn main() {
799915 let (service, socket) = LspService::new(|client| Backend {
800916 client,
801917 documents: Arc::new(RwLock::new(HashMap::new())),
918+ index: Arc::new(RwLock::new(WorkspaceIndex::new())),
919+ pending_diags: Arc::new(Mutex::new(HashMap::new())),
802920 });
803921 Server::new(stdin, stdout, socket).serve(service).await;
804922}
src/bin/htmlang_lsp/navigation.rs+95
@@ -3,6 +3,7 @@ use std::collections::HashMap;
33use tower_lsp::lsp_types::*;
44
55use crate::hover::{is_word_byte, word_at};
6+use crate::state::WorkspaceIndex;
67
78// ---------------------------------------------------------------------------
89// Go to definition
@@ -400,6 +401,100 @@ pub(crate) fn linked_editing_ranges(text: &str, position: Position) -> Option<Li
400401 })
401402}
402403
404+// ---------------------------------------------------------------------------
405+// Cross-file lookups (workspace-aware)
406+// ---------------------------------------------------------------------------
407+
408+/// Identify the symbol token (e.g. `@card` or `$primary`) under the cursor.
409+/// Returns `None` for plain words that aren't a directive or variable
410+/// reference.
411+pub(crate) fn symbol_at(text: &str, position: Position) -> Option<String> {
412+ let lines: Vec<&str> = text.lines().collect();
413+ let line = lines.get(position.line as usize)?;
414+ let col = (position.character as usize).min(line.len());
415+ let bytes = line.as_bytes();
416+ let mut start = col;
417+ while start > 0 && is_word_byte(bytes[start - 1]) {
418+ start -= 1;
419+ }
420+ let mut end = col;
421+ while end < bytes.len() && is_word_byte(bytes[end]) {
422+ end += 1;
423+ }
424+ if start == end {
425+ return None;
426+ }
427+ let word = &line[start..end];
428+ if word.starts_with('$') || word.starts_with('@') {
429+ return Some(word.to_string());
430+ }
431+ // Cursor on the bare name part (e.g. inside "card" of "@card") — synthesize
432+ // the prefix from the preceding byte if it's `@` or `$`.
433+ if start > 0 {
434+ let prev = bytes[start - 1];
435+ if prev == b'@' {
436+ return Some(format!("@{}", word));
437+ }
438+ if prev == b'$' {
439+ return Some(format!("${}", word));
440+ }
441+ }
442+ None
443+}
444+
445+/// Resolve a definition by consulting every other file in the workspace
446+/// index. Returns the first match (deterministic by HashMap iteration order
447+/// is not guaranteed, but typical projects only define a name once).
448+pub(crate) fn cross_file_definition(
449+ text: &str,
450+ position: Position,
451+ index: &WorkspaceIndex,
452+) -> Option<GotoDefinitionResponse> {
453+ let symbol = symbol_at(text, position)?;
454+ let mut hits = index.find_symbol(&symbol);
455+ if hits.is_empty() {
456+ return None;
457+ }
458+ if hits.len() == 1 {
459+ return Some(GotoDefinitionResponse::Scalar(hits.remove(0)));
460+ }
461+ Some(GotoDefinitionResponse::Array(hits))
462+}
463+
464+/// Scan `text` for occurrences of `symbol` (a `$name` or `@name` token) and
465+/// emit one `Location` per word-boundary match. Used to extend the local
466+/// `find_references` result across the workspace.
467+pub(crate) fn find_references_for_symbol(text: &str, symbol: &str, uri: &Url) -> Vec<Location> {
468+ let mut out = Vec::new();
469+ for (line_idx, line) in text.lines().enumerate() {
470+ let mut offset = 0;
471+ while let Some(pos) = line[offset..].find(symbol) {
472+ let abs_pos = offset + pos;
473+ let after = abs_pos + symbol.len();
474+ let bytes = line.as_bytes();
475+ let before_ok = abs_pos == 0 || {
476+ let c = bytes[abs_pos - 1];
477+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
478+ };
479+ let after_ok = after >= line.len() || {
480+ let c = bytes[after];
481+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
482+ };
483+ if before_ok && after_ok {
484+ out.push(Location {
485+ uri: uri.clone(),
486+ range: Range::new(
487+ Position::new(line_idx as u32, abs_pos as u32),
488+ Position::new(line_idx as u32, after as u32),
489+ ),
490+ });
491+ }
492+ offset = after;
493+ }
494+ }
495+ out
496+}
497+
403498// ---------------------------------------------------------------------------
404499// Find references
405500// ---------------------------------------------------------------------------
src/bin/htmlang_lsp/state.rsnew file+249
@@ -0,0 +1,249 @@
1+use std::collections::HashMap;
2+use std::path::{Path, PathBuf};
3+use std::sync::{Arc, OnceLock};
4+
5+use htmlang::parser::ParseResult;
6+use tower_lsp::lsp_types::*;
7+
8+use crate::analysis::document_symbols;
9+
10+/// In-memory document state with a lazily-computed parse cache.
11+///
12+/// The parse is cached per (text, version) tuple — a new `DocumentEntry` is
13+/// created whenever the text changes, so the OnceLock is always consistent
14+/// with the entry's text.
15+pub struct DocumentEntry {
16+ pub text: String,
17+ pub version: i32,
18+ parse: OnceLock<Arc<ParseResult>>,
19+ symbols: OnceLock<Arc<Vec<SymbolInformation>>>,
20+}
21+
22+impl DocumentEntry {
23+ pub fn new(text: String, version: i32) -> Self {
24+ Self {
25+ text,
26+ version,
27+ parse: OnceLock::new(),
28+ symbols: OnceLock::new(),
29+ }
30+ }
31+
32+ pub fn parse(&self) -> Arc<ParseResult> {
33+ self.parse
34+ .get_or_init(|| Arc::new(htmlang::parser::parse(&self.text)))
35+ .clone()
36+ }
37+
38+ pub fn symbols(&self) -> Arc<Vec<SymbolInformation>> {
39+ self.symbols
40+ .get_or_init(|| Arc::new(document_symbols(&self.text)))
41+ .clone()
42+ }
43+}
44+
45+/// Apply a single content change event to a string. Used when the LSP client
46+/// negotiates incremental sync.
47+pub fn apply_change(text: &mut String, change: &TextDocumentContentChangeEvent) {
48+ if let Some(range) = change.range {
49+ let start = position_to_byte(text, range.start);
50+ let end = position_to_byte(text, range.end);
51+ text.replace_range(start..end, &change.text);
52+ } else {
53+ text.clear();
54+ text.push_str(&change.text);
55+ }
56+}
57+
58+/// Convert an LSP `Position` to a byte offset in `text`.
59+///
60+/// We treat `character` as a UTF-8 byte offset within the line. This matches
61+/// the convention used elsewhere in this crate (e.g. `(pos.character as usize).min(line.len())`),
62+/// which assumes UTF-8 positions. We negotiate the `utf-8` position encoding
63+/// during initialize, so this stays consistent for clients that respect it
64+/// (VS Code does).
65+fn position_to_byte(text: &str, pos: Position) -> usize {
66+ let target_line = pos.line as usize;
67+ let mut line_start = 0;
68+ let mut line = 0;
69+ for (i, b) in text.bytes().enumerate() {
70+ if line == target_line {
71+ return (line_start + pos.character as usize).min(text.len());
72+ }
73+ if b == b'\n' {
74+ line += 1;
75+ line_start = i + 1;
76+ }
77+ }
78+ if line == target_line {
79+ (line_start + pos.character as usize).min(text.len())
80+ } else {
81+ text.len()
82+ }
83+}
84+
85+// ---------------------------------------------------------------------------
86+// Workspace index
87+// ---------------------------------------------------------------------------
88+
89+/// Cached symbols for every `.hl` file under the workspace root that the LSP
90+/// has scanned. Built lazily on first workspace-wide query and refreshed via
91+/// the file watcher.
92+pub struct WorkspaceIndex {
93+ /// Per-file symbols. Each entry's location URI already points at the file.
94+ pub by_file: HashMap<PathBuf, Vec<SymbolInformation>>,
95+ pub root: Option<PathBuf>,
96+ /// Set to `true` once a full scan has been performed; subsequent queries
97+ /// skip the rescan and rely on file-watcher updates.
98+ pub scanned: bool,
99+}
100+
101+impl WorkspaceIndex {
102+ pub fn new() -> Self {
103+ Self {
104+ by_file: HashMap::new(),
105+ root: None,
106+ scanned: false,
107+ }
108+ }
109+
110+ /// Set the workspace root once. The root is sticky for the LSP session.
111+ pub fn set_root(&mut self, root: PathBuf) {
112+ if self.root.is_none() {
113+ self.root = Some(root);
114+ }
115+ }
116+
117+ /// Walk the workspace root and index every `.hl` file found, skipping
118+ /// the usual junk directories.
119+ pub fn scan(&mut self) {
120+ let Some(root) = self.root.clone() else {
121+ return;
122+ };
123+ let mut stack = vec![root];
124+ while let Some(dir) = stack.pop() {
125+ let Ok(entries) = std::fs::read_dir(&dir) else {
126+ continue;
127+ };
128+ for entry in entries.flatten() {
129+ let path = entry.path();
130+ if let Some(name) = path.file_name().and_then(|n| n.to_str())
131+ && (name.starts_with('.')
132+ || name == "target"
133+ || name == "node_modules"
134+ || name == "dist"
135+ || name == "out"
136+ || name == "build")
137+ {
138+ continue;
139+ }
140+ if path.is_dir() {
141+ stack.push(path);
142+ continue;
143+ }
144+ if path.extension().is_some_and(|e| e == "hl") {
145+ self.update_from_disk(&path);
146+ }
147+ }
148+ }
149+ self.scanned = true;
150+ }
151+
152+ /// Re-read a file from disk and refresh its entry in the index.
153+ pub fn update_from_disk(&mut self, path: &Path) {
154+ let Ok(text) = std::fs::read_to_string(path) else {
155+ return;
156+ };
157+ self.update_from_text(path, &text);
158+ }
159+
160+ /// Replace the index entry for `path` with symbols extracted from `text`.
161+ pub fn update_from_text(&mut self, path: &Path, text: &str) {
162+ let Ok(uri) = Url::from_file_path(path) else {
163+ return;
164+ };
165+ let mut syms = document_symbols(text);
166+ for s in &mut syms {
167+ s.location.uri = uri.clone();
168+ }
169+ self.by_file.insert(path.to_path_buf(), syms);
170+ }
171+
172+ pub fn remove(&mut self, path: &Path) {
173+ self.by_file.remove(path);
174+ }
175+
176+ /// Look for a symbol with `name` (e.g. `@card` or `$primary`) anywhere in
177+ /// the index, returning every matching location.
178+ pub fn find_symbol(&self, name: &str) -> Vec<Location> {
179+ let mut out = Vec::new();
180+ for syms in self.by_file.values() {
181+ for s in syms {
182+ if s.name == name {
183+ out.push(s.location.clone());
184+ }
185+ }
186+ }
187+ out
188+ }
189+
190+ /// Iterate over every (file path, file text) pair in the index. The text
191+ /// is read fresh from disk — callers that need consistent views with open
192+ /// editor buffers should consult those buffers first.
193+ pub fn iter_files(&self) -> impl Iterator<Item = &PathBuf> {
194+ self.by_file.keys()
195+ }
196+}
197+
198+#[cfg(test)]
199+mod tests {
200+ use super::*;
201+
202+ fn change(start: (u32, u32), end: (u32, u32), text: &str) -> TextDocumentContentChangeEvent {
203+ TextDocumentContentChangeEvent {
204+ range: Some(Range::new(
205+ Position::new(start.0, start.1),
206+ Position::new(end.0, end.1),
207+ )),
208+ range_length: None,
209+ text: text.to_string(),
210+ }
211+ }
212+
213+ #[test]
214+ fn apply_change_full_replace() {
215+ let mut s = String::from("hello\nworld");
216+ apply_change(
217+ &mut s,
218+ &TextDocumentContentChangeEvent {
219+ range: None,
220+ range_length: None,
221+ text: "replaced".into(),
222+ },
223+ );
224+ assert_eq!(s, "replaced");
225+ }
226+
227+ #[test]
228+ fn apply_change_insert_in_middle() {
229+ let mut s = String::from("@row [width 200]\n@text hello");
230+ // Position 15 is just before the closing `]` on line 0.
231+ apply_change(&mut s, &change((0, 15), (0, 15), ", padding 10"));
232+ assert_eq!(s, "@row [width 200, padding 10]\n@text hello");
233+ }
234+
235+ #[test]
236+ fn apply_change_delete_across_lines() {
237+ let mut s = String::from("line one\nline two\nline three");
238+ apply_change(&mut s, &change((0, 4), (2, 4), ""));
239+ assert_eq!(s, "line three");
240+ }
241+
242+ #[test]
243+ fn apply_change_replace_at_eof() {
244+ let mut s = String::from("abc");
245+ apply_change(&mut s, &change((0, 3), (0, 3), "def"));
246+ assert_eq!(s, "abcdef");
247+ }
248+}
249+