@@ -1,7 +1,10 @@
1use std::collections::HashMap;
1use std::collections::HashMap;
2use std::sync::Arc;
2use std::sync::Arc;
3+use std::time::Duration;
4−use tokio::sync::RwLock;
6+use tokio::sync::{Mutex, RwLock};
7+use tokio::task::JoinHandle;
5use tower_lsp::jsonrpc::Result;
8use tower_lsp::jsonrpc::Result;
6use tower_lsp::lsp_types::*;
9use tower_lsp::lsp_types::*;
7use tower_lsp::{Client, LanguageServer, LspService, Server};
10use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -10,16 +13,17 @@ mod analysis;
10mod completion;
13mod completion;
12mod navigation;
15mod navigation;
14use analysis::{
18use analysis::{
15− code_actions, document_symbols, find_colors, folding_ranges, get_signature_help, inlay_hints,
19+ code_actions, find_colors, folding_ranges, get_signature_help, inlay_hints, semantic_tokens,
18use completion::{completions, path_completions, use_symbol_completions};
21use completion::{completions, path_completions, use_symbol_completions};
19use hover::hover_at;
22use hover::hover_at;
20use navigation::{
23use navigation::{
21 definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
24 definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
26+use state::{DocumentEntry, WorkspaceIndex, apply_change};
24// ---------------------------------------------------------------------------
28// ---------------------------------------------------------------------------
@@ -27,57 +31,132 @@ use navigation::{
28struct Backend {
32struct Backend {
29 client: Client,
33 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<()>>>>,
41+const DIAG_DEBOUNCE: Duration = Duration::from_millis(150);
33impl Backend {
43impl Backend {
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.
34− async fn on_change(&self, uri: Url, text: String) {
46+ async fn doc(&self, uri: &Url) -> Option<Arc<DocumentEntry>> {
35− let result = htmlang::parser::parse(&text);
47+ self.documents.read().await.get(uri).cloned()
36− let diags: Vec<Diagnostic> = result
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,
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();
53− .map(|l| l.len() as u32)
54− .unwrap_or(col_start + 1)
56− 1000 // Highlight entire line when no column info
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(),
68− .publish_diagnostics(uri.clone(), diags, None)
70− self.documents.write().await.insert(uri, text);
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));
57+ .insert(uri.clone(), entry.clone());
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() {
66+ .update_from_text(&path, &entry.text);
69+ self.schedule_diagnostics(uri, entry).await;
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);
83+ .publish_diagnostics(uri_for_task, diags, Some(entry.version))
87+ let mut pending = self.pending_diags.lock().await;
88+ if let Some(prev) = pending.insert(uri, handle) {
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) {
97+ let idx = self.index.read().await;
98+ !idx.scanned && idx.root.is_some()
101+ self.index.write().await.scan();
106+fn build_diagnostics(text: &str, result: &htmlang::parser::ParseResult) -> Vec<Diagnostic> {
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,
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();
123+ .map(|l| l.len() as u32)
124+ .unwrap_or(col_start + 1)
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()
74#[tower_lsp::async_trait]
139#[tower_lsp::async_trait]
75impl LanguageServer for Backend {
140impl 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)]
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);
77 Ok(InitializeResult {
155 Ok(InitializeResult {
78 capabilities: ServerCapabilities {
156 capabilities: ServerCapabilities {
157+ position_encoding: Some(PositionEncodingKind::UTF8),
79 text_document_sync: Some(TextDocumentSyncCapability::Kind(
158 text_document_sync: Some(TextDocumentSyncCapability::Kind(
80− TextDocumentSyncKind::FULL,
159+ TextDocumentSyncKind::INCREMENTAL,
82 completion_provider: Some(CompletionOptions {
161 completion_provider: Some(CompletionOptions {
83 trigger_characters: Some(vec!["@".into(), "$".into(), "[".into(), ",".into()]),
162 trigger_characters: Some(vec!["@".into(), "$".into(), "[".into(), ",".into()]),
@@ -113,9 +192,7 @@ impl LanguageServer for Backend {
113 SemanticTokenType::COMMENT,
192 SemanticTokenType::COMMENT,
114 SemanticTokenType::PROPERTY,
193 SemanticTokenType::PROPERTY,
116− token_modifiers: vec![
117− SemanticTokenModifier::new("deprecated"), // bit 0 = 1 -> dimmed/strikethrough
195+ token_modifiers: vec![SemanticTokenModifier::new("deprecated")],
120 full: Some(SemanticTokensFullOptions::Bool(true)),
197 full: Some(SemanticTokensFullOptions::Bool(true)),
121 range: None,
198 range: None,
@@ -143,27 +220,74 @@ impl LanguageServer for Backend {
143 code_lens_provider: Some(CodeLensOptions {
220 code_lens_provider: Some(CodeLensOptions {
144 resolve_provider: Some(false),
221 resolve_provider: Some(false),
223+ execute_command_provider: Some(ExecuteCommandOptions {
224+ commands: vec!["htmlang.showReferences".into()],
225+ ..Default::default()
227+ workspace: Some(WorkspaceServerCapabilities {
228+ workspace_folders: Some(WorkspaceFoldersServerCapabilities {
229+ supported: Some(true),
230+ change_notifications: Some(OneOf::Left(true)),
232+ ..Default::default()
146 ..Default::default()
234 ..Default::default()
148 ..Default::default()
236 ..Default::default()
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" }]
251+ let _ = self.client.register_capability(vec![registration]).await;
252+ self.ensure_index_ready().await;
154 async fn shutdown(&self) -> Result<()> {
255 async fn shutdown(&self) -> Result<()> {
158 async fn did_open(&self, params: DidOpenTextDocumentParams) {
259 async fn did_open(&self, params: DidOpenTextDocumentParams) {
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()
266+ self.index.write().await.set_root(parent.to_path_buf());
267+ self.index.write().await.scan();
271+ params.text_document.uri,
159− self.on_change(params.text_document.uri, params.text_document.text)
272+ params.text_document.text,
273+ params.text_document.version,
163 async fn did_change(&self, params: DidChangeTextDocumentParams) {
278 async fn did_change(&self, params: DidChangeTextDocumentParams) {
164− if let Some(change) = params.content_changes.into_iter().last() {
279+ let uri = params.text_document.uri.clone();
165− self.on_change(params.text_document.uri, change.text).await;
280+ let version = params.text_document.version;
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(),
287+ for change in ¶ms.content_changes {
288+ apply_change(&mut text, change);
290+ self.set_doc(uri, text, version).await;
169 async fn did_close(&self, params: DidCloseTextDocumentParams) {
293 async fn did_close(&self, params: DidCloseTextDocumentParams) {
@@ -171,22 +295,45 @@ impl LanguageServer for Backend {
173 .remove(¶ms.text_document.uri);
297 .remove(¶ms.text_document.uri);
298+ if let Some(handle) = self
302+ .remove(¶ms.text_document.uri)
174 self.client
306 self.client
175 .publish_diagnostics(params.text_document.uri, vec![], None)
307 .publish_diagnostics(params.text_document.uri, vec![], None)
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 {
318+ FileChangeType::CREATED | FileChangeType::CHANGED => {
319+ idx.update_from_disk(&path);
321+ FileChangeType::DELETED => {
179 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
329 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
180 let uri = ¶ms.text_document_position.text_document.uri;
330 let uri = ¶ms.text_document_position.text_document.uri;
181 let pos = params.text_document_position.position;
331 let pos = params.text_document_position.position;
182− let docs = self.documents.read().await;
332+ let Some(doc) = self.doc(uri).await else {
183− let text = match docs.get(uri) {
184− Some(t) => t.clone(),
185− None => return Ok(None),
333+ return Ok(None);
187− drop(docs);
335+ let text = &doc.text;
189− // Check if we're on an @include/@import/@use/@extends line for path/symbol completions
190 let lines: Vec<&str> = text.lines().collect();
337 let lines: Vec<&str> = text.lines().collect();
191 if let Some(line) = lines.get(pos.line as usize) {
338 if let Some(line) = lines.get(pos.line as usize) {
192 let trimmed = line.trim_start();
339 let trimmed = line.trim_start();
@@ -199,9 +346,7 @@ impl LanguageServer for Backend {
199 return Ok(Some(CompletionResponse::Array(items)));
346 return Ok(Some(CompletionResponse::Array(items)));
202− // @use "file.hl" fn1, fn2 -- after the filename, suggest exported names
203 if let Some(after_use) = trimmed.strip_prefix("@use ") {
349 if let Some(after_use) = trimmed.strip_prefix("@use ") {
204− // If we already have a filename (quoted or unquoted), suggest symbols from that file
205 let has_file = after_use.contains(".hl");
350 let has_file = after_use.contains(".hl");
206 if has_file {
351 if has_file {
207 let items = use_symbol_completions(uri, trimmed, pos);
352 let items = use_symbol_completions(uri, trimmed, pos);
@@ -217,7 +362,7 @@ impl LanguageServer for Backend {
220− let items = completions(&text, pos);
365+ let items = completions(text, pos);
221 Ok(if items.is_empty() {
366 Ok(if items.is_empty() {
@@ -228,14 +373,10 @@ impl LanguageServer for Backend {
228 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
373 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
229 let uri = ¶ms.text_document_position_params.text_document.uri;
374 let uri = ¶ms.text_document_position_params.text_document.uri;
230 let pos = params.text_document_position_params.position;
375 let pos = params.text_document_position_params.position;
231− let docs = self.documents.read().await;
376+ let Some(doc) = self.doc(uri).await else {
232− let text = match docs.get(uri) {
233− Some(t) => t.clone(),
234− None => return Ok(None),
377+ return Ok(None);
238− Ok(hover_at(&text, pos))
379+ Ok(hover_at(&doc.text, pos))
241 async fn goto_definition(
382 async fn goto_definition(
@@ -248,14 +389,19 @@ impl LanguageServer for Backend {
249 .clone();
390 .clone();
250 let pos = params.text_document_position_params.position;
391 let pos = params.text_document_position_params.position;
251− let docs = self.documents.read().await;
392+ let Some(doc) = self.doc(&uri).await else {
252− let text = match docs.get(&uri) {
253− Some(t) => t.clone(),
254− None => return Ok(None),
393+ return Ok(None);
396+ // Try the local file first — fast and matches common case.
258− Ok(definition_at(&text, pos, &uri))
397+ if let Some(local) = definition_at(&doc.text, pos, &uri) {
398+ return Ok(Some(local));
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);
261 async fn prepare_rename(
407 async fn prepare_rename(
@@ -264,28 +410,20 @@ impl LanguageServer for Backend {
264 ) -> Result<Option<PrepareRenameResponse>> {
410 ) -> Result<Option<PrepareRenameResponse>> {
265 let uri = ¶ms.text_document.uri;
411 let uri = ¶ms.text_document.uri;
266 let pos = params.position;
412 let pos = params.position;
267− let docs = self.documents.read().await;
413+ let Some(doc) = self.doc(uri).await else {
268− let text = match docs.get(uri) {
269− Some(t) => t.clone(),
270− None => return Ok(None),
414+ return Ok(None);
274− Ok(prepare_rename_at(&text, pos))
416+ Ok(prepare_rename_at(&doc.text, pos))
277 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
419 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
278 let uri = params.text_document_position.text_document.uri.clone();
420 let uri = params.text_document_position.text_document.uri.clone();
279 let pos = params.text_document_position.position;
421 let pos = params.text_document_position.position;
280 let new_name = params.new_name;
422 let new_name = params.new_name;
281− let docs = self.documents.read().await;
423+ let Some(doc) = self.doc(&uri).await else {
282− let text = match docs.get(&uri) {
283− Some(t) => t.clone(),
284− None => return Ok(None),
424+ return Ok(None);
288− Ok(rename_at(&text, pos, &new_name, &uri))
426+ Ok(rename_at(&doc.text, pos, &new_name, &uri))
291 async fn document_symbol(
429 async fn document_symbol(
@@ -293,31 +431,29 @@ impl LanguageServer for Backend {
293 params: DocumentSymbolParams,
431 params: DocumentSymbolParams,
294 ) -> Result<Option<DocumentSymbolResponse>> {
432 ) -> Result<Option<DocumentSymbolResponse>> {
295 let uri = ¶ms.text_document.uri;
433 let uri = ¶ms.text_document.uri;
296− let docs = self.documents.read().await;
434+ let Some(doc) = self.doc(uri).await else {
297− let text = match docs.get(uri) {
298− Some(t) => t.clone(),
299− None => return Ok(None),
435+ return Ok(None);
303− let symbols = document_symbols(&text);
437+ let symbols = doc.symbols();
304 Ok(if symbols.is_empty() {
438 Ok(if symbols.is_empty() {
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();
445+ s.location.uri = uri.clone();
307− Some(DocumentSymbolResponse::Flat(symbols))
447+ Some(DocumentSymbolResponse::Flat(out))
311 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
451 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
312 let uri = params.text_document.uri.clone();
452 let uri = params.text_document.uri.clone();
313− let docs = self.documents.read().await;
453+ let Some(doc) = self.doc(&uri).await else {
314− let text = match docs.get(&uri) {
315− Some(t) => t.clone(),
316− None => return Ok(None),
454+ return Ok(None);
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);
321 Ok(if actions.is_empty() {
457 Ok(if actions.is_empty() {
@@ -327,13 +463,10 @@ impl LanguageServer for Backend {
328 async fn document_color(&self, params: DocumentColorParams) -> Result<Vec<ColorInformation>> {
464 async fn document_color(&self, params: DocumentColorParams) -> Result<Vec<ColorInformation>> {
329 let uri = ¶ms.text_document.uri;
465 let uri = ¶ms.text_document.uri;
330− let docs = self.documents.read().await;
466+ let Some(doc) = self.doc(uri).await else {
331− let text = match docs.get(uri) {
332− Some(t) => t.clone(),
333− None => return Ok(vec![]),
467+ return Ok(vec![]);
336− Ok(find_colors(&text))
469+ Ok(find_colors(&doc.text))
339 async fn color_presentation(
472 async fn color_presentation(
@@ -341,34 +474,45 @@ impl LanguageServer for Backend {
341 params: ColorPresentationParams,
474 params: ColorPresentationParams,
342 ) -> Result<Vec<ColorPresentation>> {
475 ) -> Result<Vec<ColorPresentation>> {
343 let c = params.color;
476 let c = params.color;
344− let r = (c.red * 255.0) as u8;
477+ let r = (c.red * 255.0).round() as u8;
345− let g = (c.green * 255.0) as u8;
478+ let g = (c.green * 255.0).round() as u8;
346− let b = (c.blue * 255.0) as u8;
479+ let b = (c.blue * 255.0).round() as u8;
480+ let a = (c.alpha * 255.0).round() as u8;
347 let hex = if c.alpha < 1.0 {
481 let hex = if c.alpha < 1.0 {
348− let a = (c.alpha * 255.0) as u8;
349 format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
482 format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
351 format!("#{:02x}{:02x}{:02x}", r, g, b)
484 format!("#{:02x}{:02x}{:02x}", r, g, b)
353− Ok(vec![ColorPresentation {
486+ let mut presentations = vec![ColorPresentation {
354 label: hex.clone(),
487 label: hex.clone(),
355 text_edit: Some(TextEdit {
488 text_edit: Some(TextEdit {
356 range: params.range,
489 range: params.range,
357− new_text: hex,
490+ new_text: hex.clone(),
359 additional_text_edits: None,
492 additional_text_edits: None,
494+ // Offer a CSS named-color presentation when one matches exactly.
496+ && let Some(name) = analysis::named_color_for(r, g, b)
498+ presentations.push(ColorPresentation {
499+ label: name.to_string(),
500+ text_edit: Some(TextEdit {
501+ range: params.range,
502+ new_text: name.to_string(),
504+ additional_text_edits: None,
363 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
510 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
364 let uri = ¶ms.text_document.uri;
511 let uri = ¶ms.text_document.uri;
365− let docs = self.documents.read().await;
512+ let Some(doc) = self.doc(uri).await else {
366− let text = match docs.get(uri) {
367− Some(t) => t.clone(),
368− None => return Ok(None),
513+ return Ok(None);
371− let ranges = folding_ranges(&text);
515+ let ranges = folding_ranges(&doc.text);
372 Ok(if ranges.is_empty() {
516 Ok(if ranges.is_empty() {
@@ -381,13 +525,11 @@ impl LanguageServer for Backend {
381 params: SemanticTokensParams,
525 params: SemanticTokensParams,
382 ) -> Result<Option<SemanticTokensResult>> {
526 ) -> Result<Option<SemanticTokensResult>> {
383 let uri = ¶ms.text_document.uri;
527 let uri = ¶ms.text_document.uri;
384− let docs = self.documents.read().await;
528+ let Some(doc) = self.doc(uri).await else {
385− let text = match docs.get(uri) {
386− Some(t) => t.clone(),
387− None => return Ok(None),
529+ return Ok(None);
389− drop(docs);
531+ let parse = doc.parse();
390− let tokens = semantic_tokens(&text);
532+ let tokens = semantic_tokens(&doc.text, &parse);
391 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
533 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
392 result_id: None,
534 result_id: None,
393 data: tokens,
535 data: tokens,
@@ -396,94 +538,49 @@ impl LanguageServer for Backend {
397 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
539 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
398 let uri = ¶ms.text_document.uri;
540 let uri = ¶ms.text_document.uri;
399− let docs = self.documents.read().await;
541+ let Some(doc) = self.doc(uri).await else {
400− let text = match docs.get(uri) {
401− Some(t) => t.clone(),
402− None => return Ok(None),
542+ return Ok(None);
405− let hints = inlay_hints(&text);
544+ let hints = inlay_hints(&doc.text);
406 Ok(if hints.is_empty() { None } else { Some(hints) })
545 Ok(if hints.is_empty() { None } else { Some(hints) })
409− #[allow(deprecated)]
410 async fn symbol(
548 async fn symbol(
412 params: WorkspaceSymbolParams,
550 params: WorkspaceSymbolParams,
413 ) -> Result<Option<Vec<SymbolInformation>>> {
551 ) -> Result<Option<Vec<SymbolInformation>>> {
552+ self.ensure_index_ready().await;
414 let query = params.query.to_lowercase();
554 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.
416 let mut all_symbols: Vec<SymbolInformation> = Vec::new();
557 let mut all_symbols: Vec<SymbolInformation> = Vec::new();
417 let mut covered_files: std::collections::HashSet<std::path::PathBuf> =
558 let mut covered_files: std::collections::HashSet<std::path::PathBuf> =
418 std::collections::HashSet::new();
559 std::collections::HashSet::new();
420− // Open documents first — their in-memory text may be newer than what
561+ let docs = self.documents.read().await;
422− for (uri, text) in docs.iter() {
562+ for (uri, doc) in docs.iter() {
423 if let Ok(path) = uri.to_file_path() {
563 if let Ok(path) = uri.to_file_path() {
424 covered_files.insert(path);
564 covered_files.insert(path);
426− for mut sym in document_symbols(text) {
566+ for sym in doc.symbols().iter() {
427− sym.location.uri = uri.clone();
428 if query.is_empty() || sym.name.to_lowercase().contains(&query) {
567 if query.is_empty() || sym.name.to_lowercase().contains(&query) {
568+ let mut s = sym.clone();
569+ s.location.uri = uri.clone();
429− all_symbols.push(sym);
570+ all_symbols.push(s);
433 drop(docs);
574 drop(docs);
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;
576+ let idx = self.index.read().await;
440− docs.keys()
577+ for (path, syms) in &idx.by_file {
442− .and_then(|u| u.to_file_path().ok())
443− .and_then(|p| p.parent().map(|p| p.to_path_buf()))
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 {
453− for entry in entries.flatten() {
454− if scanned >= max_files {
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('.')
462− || name == "node_modules"
471− if path.extension().is_some_and(|e| e == "hl") && !covered_files.contains(&path)
578+ if covered_files.contains(path) {
474− let Ok(text) = std::fs::read_to_string(&path) else {
477− let Ok(file_uri) = Url::from_file_path(&path) else {
478− continue;
579+ continue;
480− for mut sym in document_symbols(&text) {
581+ for sym in syms {
481− sym.location.uri = file_uri.clone();
482− if query.is_empty() || sym.name.to_lowercase().contains(&query) {
582+ if query.is_empty() || sym.name.to_lowercase().contains(&query) {
483− all_symbols.push(sym);
583+ all_symbols.push(sym.clone());
@@ -501,29 +598,23 @@ impl LanguageServer for Backend {
501 ) -> Result<Option<LinkedEditingRanges>> {
598 ) -> Result<Option<LinkedEditingRanges>> {
502 let uri = ¶ms.text_document_position_params.text_document.uri;
599 let uri = ¶ms.text_document_position_params.text_document.uri;
503 let pos = params.text_document_position_params.position;
600 let pos = params.text_document_position_params.position;
504− let docs = self.documents.read().await;
601+ let Some(doc) = self.doc(uri).await else {
505− let text = match docs.get(uri) {
506− Some(t) => t.clone(),
507− None => return Ok(None),
602+ return Ok(None);
510− Ok(linked_editing_ranges(&text, pos))
604+ Ok(linked_editing_ranges(&doc.text, pos))
513 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
607 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
514 let uri = ¶ms.text_document.uri;
608 let uri = ¶ms.text_document.uri;
515− let docs = self.documents.read().await;
609+ let Some(doc) = self.doc(uri).await else {
516− let text = match docs.get(uri) {
517− Some(t) => t.clone(),
518− None => return Ok(None),
610+ return Ok(None);
521− let formatted = htmlang::fmt::format(&text);
612+ let formatted = htmlang::fmt::format(&doc.text);
522− if formatted == text {
613+ if formatted == doc.text {
523 return Ok(None);
614 return Ok(None);
525− let last_line = text.lines().count().saturating_sub(1) as u32;
616+ let last_line = doc.text.lines().count().saturating_sub(1) as u32;
526− let last_col = text.lines().last().map_or(0, |l| l.len()) as u32;
617+ let last_col = doc.text.lines().last().map_or(0, |l| l.len()) as u32;
527 Ok(Some(vec![TextEdit {
618 Ok(Some(vec![TextEdit {
528 range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
619 range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
529 new_text: formatted,
620 new_text: formatted,
@@ -536,16 +627,11 @@ impl LanguageServer for Backend {
536 ) -> Result<Option<Vec<TextEdit>>> {
627 ) -> Result<Option<Vec<TextEdit>>> {
537 let uri = ¶ms.text_document.uri;
628 let uri = ¶ms.text_document.uri;
538 let range = params.range;
629 let range = params.range;
539− let docs = self.documents.read().await;
630+ let Some(doc) = self.doc(uri).await else {
540− let text = match docs.get(uri) {
541− Some(t) => t.clone(),
542− None => return Ok(None),
631+ return Ok(None);
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();
549 let start_line = range.start.line as usize;
635 let start_line = range.start.line as usize;
550 let end_line = (range.end.line as usize).min(lines.len().saturating_sub(1));
636 let end_line = (range.end.line as usize).min(lines.len().saturating_sub(1));
551 if start_line > end_line {
637 if start_line > end_line {
@@ -571,12 +657,10 @@ impl LanguageServer for Backend {
572 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
658 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
573 let uri = params.text_document.uri.clone();
659 let uri = params.text_document.uri.clone();
574− let docs = self.documents.read().await;
660+ let Some(doc) = self.doc(&uri).await else {
575− let text = match docs.get(&uri) {
576− Some(t) => t.clone(),
577− None => return Ok(None),
661+ return Ok(None);
579− drop(docs);
663+ let text = &doc.text;
581 let Ok(this_path) = uri.to_file_path() else {
665 let Ok(this_path) = uri.to_file_path() else {
582 return Ok(None);
666 return Ok(None);
@@ -594,7 +678,6 @@ impl LanguageServer for Backend {
594 } else if let Some(rest) = trimmed.strip_prefix("@import ") {
678 } else if let Some(rest) = trimmed.strip_prefix("@import ") {
595 ("@import ", rest)
679 ("@import ", rest)
596 } else if let Some(rest) = trimmed.strip_prefix("@use ") {
680 } else if let Some(rest) = trimmed.strip_prefix("@use ") {
597− // @use "file.hl" fn1, fn2 — only the filename token is linkable.
598 ("@use ", rest)
681 ("@use ", rest)
599 } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
682 } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
600 ("@extends ", rest)
683 ("@extends ", rest)
@@ -602,7 +685,6 @@ impl LanguageServer for Backend {
602 continue;
685 continue;
605− // Pull the filename token (stop at whitespace or `,`).
606 let name_token: &str = filename
688 let name_token: &str = filename
607 .trim_start_matches('"')
689 .trim_start_matches('"')
608 .split(|c: char| c.is_whitespace() || c == ',')
690 .split(|c: char| c.is_whitespace() || c == ',')
@@ -612,7 +694,6 @@ impl LanguageServer for Backend {
612 if name_token.is_empty() {
694 if name_token.is_empty() {
613 continue;
695 continue;
615− // Ignore glob patterns — they don't resolve to a single path.
616 if name_token.contains('*') || name_token.contains('?') {
697 if name_token.contains('*') || name_token.contains('?') {
617 continue;
698 continue;
@@ -625,8 +706,6 @@ impl LanguageServer for Backend {
625 continue;
706 continue;
628− // Locate the token in the original line so the link highlights the
629− // filename rather than the whole directive.
630 let scan_from = indent + prefix.len();
709 let scan_from = indent + prefix.len();
631 let Some(rel_start) = raw_line[scan_from..].find(name_token) else {
710 let Some(rel_start) = raw_line[scan_from..].find(name_token) else {
632 continue;
711 continue;
@@ -650,15 +729,10 @@ impl LanguageServer for Backend {
651 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
730 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
652 let uri = params.text_document.uri.clone();
731 let uri = params.text_document.uri.clone();
653− let docs = self.documents.read().await;
732+ let Some(doc) = self.doc(&uri).await else {
654− let text = match docs.get(&uri) {
655− Some(t) => t.clone(),
656− None => return Ok(None),
733+ return Ok(None);
658− drop(docs);
735+ let text = &doc.text;
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.
662 let lines: Vec<&str> = text.lines().collect();
736 let lines: Vec<&str> = text.lines().collect();
664 #[derive(Clone)]
738 #[derive(Clone)]
@@ -673,9 +747,7 @@ impl LanguageServer for Backend {
673 let trimmed = line.trim_start();
747 let trimmed = line.trim_start();
674 if let Some(rest) = trimmed.strip_prefix("@let ") {
748 if let Some(rest) = trimmed.strip_prefix("@let ") {
675 let rest = rest.trim();
749 let rest = rest.trim();
676− // Determine the kind based on the value
677 if let Some(name) = rest.split_whitespace().next() {
750 if let Some(name) = rest.split_whitespace().next() {
678− // Check for indented body (function)
679 let has_body = lines
751 let has_body = lines
680 .get(i + 1)
752 .get(i + 1)
681 .map(|l| l.starts_with(" ") || l.starts_with('\t'))
753 .map(|l| l.starts_with(" ") || l.starts_with('\t'))
@@ -708,8 +780,6 @@ impl LanguageServer for Backend {
709 let mut lenses = Vec::with_capacity(defs.len());
781 let mut lenses = Vec::with_capacity(defs.len());
710 for def in &defs {
782 for def in &defs {
711− // Count references: for functions, look for `@name` at start-of-token;
712− // for variables / attribute bundles, look for `$name`.
713 let mut count: usize = 0;
783 let mut count: usize = 0;
714 let needle = match def.kind {
784 let needle = match def.kind {
715 "fn" => format!("@{}", def.name),
785 "fn" => format!("@{}", def.name),
@@ -722,8 +792,6 @@ impl LanguageServer for Backend {
722 let mut from = 0;
792 let mut from = 0;
723 while let Some(idx) = line[from..].find(&needle) {
793 while let Some(idx) = line[from..].find(&needle) {
724 let pos = from + idx;
794 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`.
727 let after = line.as_bytes().get(pos + needle.len()).copied();
795 let after = line.as_bytes().get(pos + needle.len()).copied();
728 let ok = match after {
796 let ok = match after {
729 None => true,
797 None => true,
@@ -741,15 +809,16 @@ impl LanguageServer for Backend {
742 format!("{} references", count)
810 format!("{} references", count)
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);
744 lenses.push(CodeLens {
816 lenses.push(CodeLens {
745− range: Range::new(Position::new(def.line, 0), Position::new(def.line, 0)),
817+ range: Range::new(line_pos, line_pos),
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.
749 command: Some(Command {
818 command: Some(Command {
751− command: String::new(),
820+ command: "htmlang.showReferences".into(),
752− arguments: None,
821+ arguments: Some(vec![json!(uri), json!(line_pos)]),
754 data: None,
823 data: None,
@@ -762,29 +831,76 @@ impl LanguageServer for Backend {
834+ async fn execute_command(
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.
765 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
847 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
766 let uri = params.text_document_position.text_document.uri.clone();
848 let uri = params.text_document_position.text_document.uri.clone();
767 let pos = params.text_document_position.position;
849 let pos = params.text_document_position.position;
768− let docs = self.documents.read().await;
850+ let Some(doc) = self.doc(&uri).await else {
769− let text = match docs.get(&uri) {
770− Some(t) => t.clone(),
771− None => return Ok(None),
851+ return Ok(None);
774− let refs = find_references(&text, pos, &uri);
853+ let mut refs = find_references(&doc.text, pos, &uri);
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 {
864+ if file_uri == uri {
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) {
872+ let Ok(text) = std::fs::read_to_string(path) else {
875+ refs.extend(navigation::find_references_for_symbol(
876+ &text, &symbol, &file_uri,
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 {
886+ refs.extend(navigation::find_references_for_symbol(
775 Ok(if refs.is_empty() { None } else { Some(refs) })
894 Ok(if refs.is_empty() { None } else { Some(refs) })
778 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
897 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
779 let uri = ¶ms.text_document_position_params.text_document.uri;
898 let uri = ¶ms.text_document_position_params.text_document.uri;
780 let pos = params.text_document_position_params.position;
899 let pos = params.text_document_position_params.position;
781− let docs = self.documents.read().await;
900+ let Some(doc) = self.doc(uri).await else {
782− let text = match docs.get(uri) {
783− Some(t) => t.clone(),
784− None => return Ok(None),
901+ return Ok(None);
787− Ok(get_signature_help(&text, pos))
903+ Ok(get_signature_help(&doc.text, pos))
@@ -799,6 +915,8 @@ async fn main() {
799 let (service, socket) = LspService::new(|client| Backend {
915 let (service, socket) = LspService::new(|client| Backend {
801 documents: Arc::new(RwLock::new(HashMap::new())),
917 documents: Arc::new(RwLock::new(HashMap::new())),
918+ index: Arc::new(RwLock::new(WorkspaceIndex::new())),
919+ pending_diags: Arc::new(Mutex::new(HashMap::new())),
803 Server::new(stdin, stdout, socket).serve(service).await;
921 Server::new(stdin, stdout, socket).serve(service).await;