@@ -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 ¶ms.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(¶ms.text_document.uri);
298+ if let Some(handle) = self
299+ .pending_diags
300+ .lock()
301+ .await
302+ .remove(¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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, ¶ms.range, ¶ms.context.diagnostics, &uri);
456+ let actions = code_actions(&doc.text, ¶ms.range, ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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}