Skip to main content

rumdl_lib/lsp/
server.rs

1//! Main Language Server Protocol server implementation for rumdl
2//!
3//! This module implements the core LSP server following Ruff's architecture.
4//! It provides real-time markdown linting, diagnostics, and code actions.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use tokio::sync::{RwLock, mpsc};
11use tower_lsp::jsonrpc::Result as JsonRpcResult;
12use tower_lsp::lsp_types::*;
13use tower_lsp::{Client, LanguageServer};
14
15use crate::config::{Config, ConfigValidated, SourcedConfig, is_valid_rule_name};
16use crate::discovery::{ExcludeMatchers, is_markdown_extension};
17use crate::lsp::index_worker::{IndexWorker, SharedIndexState};
18use crate::lsp::types::{IndexState, IndexUpdate, LspRuleSettings, RelintRequest, RumdlLspConfig};
19use crate::workspace_index::WorkspaceIndex;
20
21/// Maximum number of rules in enable/disable lists (DoS protection)
22const MAX_RULE_LIST_SIZE: usize = 100;
23
24/// Maximum allowed line length value (DoS protection)
25const MAX_LINE_LENGTH: usize = 10_000;
26
27/// Whether a `workspace/didChangeConfiguration` payload names at least one
28/// `RumdlLspConfig` field. Presence decides, not value: a flag set back to its
29/// default (`{"enableAutoFix": false}` after `true`) is as much a server setting
30/// as one moved away from it.
31fn names_lsp_config_field(settings: &serde_json::Value) -> bool {
32    let serde_json::Value::Object(settings) = settings else {
33        return false;
34    };
35    let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(RumdlLspConfig::default()) else {
36        return false;
37    };
38    settings.keys().any(|key| fields.contains_key(key))
39}
40
41/// Merge the keys present in a `workspace/didChangeConfiguration` payload onto the
42/// current LSP config, returning the merged config.
43///
44/// Only the keys the client actually sent are changed; every other field keeps its
45/// current value, so a partial payload (e.g. just `{"enableSymbols": false}`) never
46/// resets omitted fields to their defaults. A client that sends a full snapshot
47/// still fully applies. Returns `None` only if `incoming` is not a JSON object, the
48/// current config cannot be represented as one, or the merged object fails to
49/// deserialize -- all unreachable for the current field types, which round-trip
50/// through serde JSON; the caller treats `None` as "leave the config unchanged"
51/// rather than clobbering omitted fields.
52fn merge_lsp_config(current: &RumdlLspConfig, incoming: &serde_json::Value) -> Option<RumdlLspConfig> {
53    let serde_json::Value::Object(incoming) = incoming else {
54        return None;
55    };
56    let serde_json::Value::Object(mut base) = serde_json::to_value(current).ok()? else {
57        return None;
58    };
59    for (key, value) in incoming {
60        base.insert(key.clone(), value.clone());
61    }
62    serde_json::from_value(serde_json::Value::Object(base)).ok()
63}
64
65/// Represents a document in the LSP server's cache
66#[derive(Clone, Debug, PartialEq)]
67pub(crate) struct DocumentEntry {
68    /// The document content
69    pub(crate) content: String,
70    /// Version number from the editor (None for disk-loaded documents)
71    pub(crate) version: Option<i32>,
72    /// Whether the document was loaded from disk (true) or opened in editor (false)
73    pub(crate) from_disk: bool,
74}
75
76/// Cache entry for resolved configuration
77#[derive(Clone, Debug)]
78pub(crate) struct ConfigCacheEntry {
79    /// The resolved configuration
80    pub(crate) config: Config,
81    /// The same configuration with provenance intact, kept only when it opts
82    /// into `.editorconfig` reading. That layering is per file (a section glob
83    /// can match one file in a directory and not its neighbour) while this cache
84    /// is per directory, so the sourced form has to survive the cache hit.
85    pub(crate) sourced: Option<Arc<SourcedConfig<ConfigValidated>>>,
86    /// Config file path that was loaded (for invalidation)
87    pub(crate) config_file: Option<PathBuf>,
88    /// True if this entry came from the global/user fallback (no project config)
89    pub(crate) from_global_fallback: bool,
90}
91
92/// Shared per-file configuration resolution used by request handlers and the
93/// background workspace index.
94///
95/// The server keeps the individual handles as part of its established state
96/// surface; this value holds clones of those same `Arc`s, so both consumers use
97/// one cache and observe the same reloads and invalidations.
98#[derive(Clone)]
99pub(crate) struct ConfigResolver {
100    pub(super) config: Arc<RwLock<RumdlLspConfig>>,
101    pub(super) rumdl_config: Arc<RwLock<Config>>,
102    pub(super) rumdl_sourced: Arc<RwLock<Option<Arc<SourcedConfig<ConfigValidated>>>>>,
103    pub(super) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
104    pub(super) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
105    pub(super) cli_config_path: Option<String>,
106}
107
108/// Main LSP server for rumdl
109///
110/// Following Ruff's pattern, this server provides:
111/// - Real-time diagnostics as users type
112/// - Code actions for automatic fixes
113/// - Configuration management
114/// - Multi-file support
115/// - Multi-root workspace support with per-file config resolution
116/// - Cross-file analysis with workspace indexing
117#[derive(Clone)]
118pub struct RumdlLanguageServer {
119    pub(crate) client: Client,
120    /// Configuration for the LSP server
121    pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
122    /// Rumdl core configuration (fallback/default)
123    pub(crate) rumdl_config: Arc<RwLock<Config>>,
124    /// `rumdl_config` with provenance intact, kept only when it opts into
125    /// `.editorconfig` reading; written wherever `rumdl_config` is.
126    pub(crate) rumdl_sourced: Arc<RwLock<Option<Arc<SourcedConfig<ConfigValidated>>>>>,
127    /// Document store for open files and cached disk files
128    pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
129    /// Maps a document's resolved URI to every open spelling that names it.
130    ///
131    /// The store is keyed by the editor's spelling, because that is the spelling
132    /// diagnostics must be published against. Navigation asks for a document by
133    /// its resolved spelling, which differs only when the editor reached the file
134    /// through a symlinked ancestor, so this stays empty for most workspaces.
135    /// Without it such a request would read the file on disk and miss the buffer.
136    ///
137    /// One resolved path can have several spellings open at once (two symlinks
138    /// to the same directory, each opened), so this holds all of them rather
139    /// than the latest. A single slot would let the second open displace the
140    /// first and the first close strand the second.
141    pub(crate) document_aliases: Arc<RwLock<HashMap<Url, Vec<Url>>>>,
142    /// Workspace root folders from the client
143    pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
144    /// Configuration cache: maps directory path to resolved config
145    /// Key is the directory where config search started (file's parent dir)
146    pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
147    /// Shared resolver consumed by document requests and workspace indexing.
148    pub(crate) config_resolver: ConfigResolver,
149    /// Workspace index for cross-file analysis (MD051)
150    pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
151    /// Current state of the workspace index (building/ready/error)
152    pub(crate) index_state: Arc<RwLock<IndexState>>,
153    /// Channel to send updates to the background index worker.
154    ///
155    /// `None` on the copy a background task holds (see
156    /// [`Self::detached_for_background`]), which must not be able to queue
157    /// index work: it would keep the index worker waiting on a channel that
158    /// can never close, so neither task would stop when the editor goes away.
159    /// Queue through [`Self::queue_index_update`] rather than reading it.
160    update_tx: Option<mpsc::Sender<IndexUpdate>>,
161    /// Whether the client supports pull diagnostics (textDocument/diagnostic)
162    /// When true, we skip pushing diagnostics to avoid duplicates
163    pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
164    /// Whether the client supports hierarchical (nested) document symbols.
165    /// When false, `textDocument/documentSymbol` must return the flat
166    /// `SymbolInformation[]` form instead of a `DocumentSymbol` tree.
167    pub(crate) client_supports_hierarchical_symbols: Arc<RwLock<bool>>,
168    /// Config path supplied via `rumdl server --config <path>`.
169    ///
170    /// Held in an immutable field (not in `self.config`) so that client-driven
171    /// updates -- `initialize` initialization options or `workspace/didChangeConfiguration`
172    /// notifications -- cannot drop it. Treated as the highest-priority config source:
173    /// it outranks both client-supplied `configPath` and per-file discovery, mirroring
174    /// the CLI semantics where an explicit `--config` is standalone.
175    pub(crate) cli_config_path: Option<String>,
176}
177
178impl RumdlLanguageServer {
179    pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
180        let initial_config = RumdlLspConfig::default();
181        let cli_config_path = cli_config_path.map(str::to_string);
182
183        // Create shared state for workspace indexing
184        let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
185        let index_state = Arc::new(RwLock::new(IndexState::default()));
186        let workspace_roots = Arc::new(RwLock::new(Vec::new()));
187        let config = Arc::new(RwLock::new(initial_config));
188        let rumdl_config = Arc::new(RwLock::new(Config::default()));
189        let rumdl_sourced = Arc::new(RwLock::new(None));
190        let config_cache = Arc::new(RwLock::new(HashMap::new()));
191        let documents = Arc::new(RwLock::new(HashMap::new()));
192
193        let config_resolver = ConfigResolver {
194            config: config.clone(),
195            rumdl_config: rumdl_config.clone(),
196            rumdl_sourced: rumdl_sourced.clone(),
197            workspace_roots: workspace_roots.clone(),
198            config_cache: config_cache.clone(),
199            cli_config_path: cli_config_path.clone(),
200        };
201
202        // Create channels for index worker communication
203        let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
204        let (relint_tx, relint_rx) = mpsc::channel::<RelintRequest>(100);
205
206        let server = Self {
207            client,
208            config,
209            rumdl_config,
210            rumdl_sourced,
211            documents,
212            document_aliases: Arc::new(RwLock::new(HashMap::new())),
213            workspace_roots,
214            config_cache,
215            config_resolver: config_resolver.clone(),
216            workspace_index,
217            index_state,
218            update_tx: Some(update_tx),
219            client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
220            client_supports_hierarchical_symbols: Arc::new(RwLock::new(false)),
221            cli_config_path,
222        };
223
224        // Spawn the background index worker after every shared configuration
225        // handle exists, so indexing and request handling receive the same
226        // resolver rather than parallel snapshots.
227        let worker = IndexWorker::new(
228            update_rx,
229            server.client.clone(),
230            relint_tx,
231            SharedIndexState {
232                workspace_index: server.workspace_index.clone(),
233                index_state: server.index_state.clone(),
234                workspace_roots: server.workspace_roots.clone(),
235                config_resolver,
236                documents: server.documents.clone(),
237            },
238        );
239        tokio::spawn(worker.run());
240
241        // Consume the index worker's re-lint requests. Cross-file diagnostics are
242        // computed from the workspace index, so the events that change an answer
243        // reach this server rather than the editor: another file's headings moved,
244        // or the initial scan finished after a document was already linted.
245        tokio::spawn(server.detached_for_background().run_relint_worker(relint_rx));
246
247        server
248    }
249
250    /// A copy of this server for a background task, holding the same state but
251    /// not the connection's claim on the index worker.
252    ///
253    /// A task parked on a channel holds its copy for as long as it runs, and
254    /// the index worker runs until every sender is dropped. A plain clone would
255    /// therefore make the two keep each other alive: the worker waiting on a
256    /// channel the re-lint task holds open, the re-lint task waiting on a
257    /// channel the worker holds open, with the whole server state behind them.
258    /// A client that closes its connection without sending `shutdown` is what
259    /// reaches that.
260    fn detached_for_background(&self) -> Self {
261        Self {
262            update_tx: None,
263            ..self.clone()
264        }
265    }
266
267    /// Queue work for the background index worker.
268    ///
269    /// Answers whether the worker took it. `false` means the worker is gone,
270    /// which is the normal state after shutdown and on a background copy of the
271    /// server; a caller that wants to report it decides what that is worth.
272    pub(crate) async fn queue_index_update(&self, update: IndexUpdate) -> bool {
273        let Some(update_tx) = &self.update_tx else {
274            return false;
275        };
276        update_tx.send(update).await.is_ok()
277    }
278
279    /// Get document content, either from cache or by reading from disk
280    ///
281    /// This method first checks if the document is in the cache (opened in editor).
282    /// If not found, it attempts to read the file from disk and caches it for
283    /// future requests.
284    pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
285        let uri = &self.store_uri(uri).await;
286
287        // First check the cache
288        {
289            let docs = self.documents.read().await;
290            if let Some(entry) = docs.get(uri) {
291                return Some(entry.content.clone());
292            }
293        }
294
295        // If not in cache and it's a file URI, try to read from disk
296        if let Ok(path) = uri.to_file_path() {
297            if let Ok(content) = tokio::fs::read_to_string(&path).await {
298                // Cache the document for future requests
299                let entry = DocumentEntry {
300                    content: content.clone(),
301                    version: None,
302                    from_disk: true,
303                };
304
305                let mut docs = self.documents.write().await;
306                docs.insert(uri.clone(), entry);
307
308                log::debug!("Loaded document from disk and cached: {uri}");
309                return Some(content);
310            } else {
311                log::debug!("Failed to read file from disk: {uri}");
312            }
313        }
314
315        None
316    }
317
318    /// Get document content only if the document is currently open in the editor.
319    ///
320    /// We intentionally do not read from disk here because diagnostics should be
321    /// scoped to open documents. This avoids lingering diagnostics after a file
322    /// is closed when clients use pull diagnostics.
323    async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
324        let uri = self.store_uri(uri).await;
325        let docs = self.documents.read().await;
326        docs.get(&uri)
327            .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
328    }
329
330    /// Bring the index entry for `path` in line with the file on disk, as the
331    /// workspace scan would record it.
332    ///
333    /// A file the scan skips (outside every root, not Markdown, ignored or
334    /// excluded, unreadable, gone) loses its entry: one indexed before an ignore
335    /// rule began matching it, or from a buffer that was never saved, would
336    /// otherwise keep surfacing in completions and navigation. Removing an entry
337    /// that does not exist is a no-op.
338    async fn reindex_from_disk(&self, path: PathBuf) {
339        let roots = self.workspace_roots.read().await.clone();
340        let (options, includes, excludes) = {
341            let config = self.rumdl_config.read().await;
342            (
343                crate::lsp::index_worker::index_walk_options(&config),
344                config.global.include.clone(),
345                ExcludeMatchers::new(&config.global.exclude),
346            )
347        };
348        let scanned = roots.iter().any(|root| path.starts_with(root))
349            && path.extension().is_some_and(is_markdown_extension)
350            && !crate::lsp::index_worker::path_is_ignored_for_index(&roots, &path, &options, &includes, &excludes);
351        let content = if scanned {
352            crate::lsp::read_markdown_lossy(&path).await.ok()
353        } else {
354            None
355        };
356        let update = match content {
357            Some(content) => IndexUpdate::FileChanged { path, content },
358            None => IndexUpdate::FileRemoved { path },
359        };
360        self.queue_index_update(update).await;
361    }
362
363    /// The URI a document is stored under, given any spelling that names it.
364    ///
365    /// Answers with the request's own URI, except when the file is open only
366    /// under a different spelling of the same path: an alias then finds the
367    /// editor's buffer instead of falling through to the file on disk.
368    ///
369    /// An open buffer under the requested spelling wins over any alias, because
370    /// one file can be open under several spellings at once and the editor holds
371    /// a separate buffer for each. A disk copy cached under the requested
372    /// spelling does not win: it was read before the document was opened
373    /// elsewhere, and the buffer an alias names has since become the truth.
374    async fn store_uri(&self, uri: &Url) -> Url {
375        let Some(spellings) = self.document_aliases.read().await.get(uri).cloned() else {
376            return uri.clone();
377        };
378        let docs = self.documents.read().await;
379        let is_open = |u: &Url| matches!(docs.get(u), Some(entry) if !entry.from_disk);
380        if is_open(uri) {
381            return uri.clone();
382        }
383        // The most recently opened spelling, so a reopen supersedes an older one.
384        spellings
385            .iter()
386            .rev()
387            .find(|u| is_open(u))
388            .cloned()
389            .unwrap_or_else(|| uri.clone())
390    }
391
392    /// Resolve the Markdown flavor for a document, mirroring the per-file flavor
393    /// resolution used by diagnostics and formatting so symbol parsing matches.
394    pub(super) async fn resolve_flavor_for_uri(&self, uri: &Url) -> crate::config::MarkdownFlavor {
395        match super::resolve_uri(uri) {
396            Some(path) => self.resolve_config_for_file(&path).await.get_flavor_for_file(&path),
397            None => self.rumdl_config.read().await.markdown_flavor(),
398        }
399    }
400}
401
402#[tower_lsp::async_trait]
403impl LanguageServer for RumdlLanguageServer {
404    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
405        log::info!("Initializing rumdl Language Server");
406
407        // Parse client capabilities and configuration
408        if let Some(options) = params.initialization_options
409            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
410        {
411            *self.config.write().await = config;
412        }
413
414        // Detect if client supports pull diagnostics (textDocument/diagnostic)
415        // When the client supports pull, we avoid pushing to prevent duplicate diagnostics
416        let supports_pull = params
417            .capabilities
418            .text_document
419            .as_ref()
420            .and_then(|td| td.diagnostic.as_ref())
421            .is_some();
422
423        if supports_pull {
424            log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
425            *self.client_supports_pull_diagnostics.write().await = true;
426        } else {
427            log::info!("Client does not support pull diagnostics - using push model");
428        }
429
430        // Detect hierarchical document symbol support; without it the client expects
431        // the legacy flat `SymbolInformation[]` form.
432        let supports_hierarchical_symbols = params
433            .capabilities
434            .text_document
435            .as_ref()
436            .and_then(|td| td.document_symbol.as_ref())
437            .and_then(|ds| ds.hierarchical_document_symbol_support)
438            .unwrap_or(false);
439        *self.client_supports_hierarchical_symbols.write().await = supports_hierarchical_symbols;
440
441        // Extract and store workspace roots
442        let mut roots = Vec::new();
443        if let Some(workspace_folders) = params.workspace_folders {
444            for folder in workspace_folders {
445                if let Ok(path) = folder.uri.to_file_path() {
446                    let path = super::resolve_workspace_root(&path);
447                    log::info!("Workspace root: {}", path.display());
448                    roots.push(path);
449                }
450            }
451        } else if let Some(root_uri) = params.root_uri
452            && let Ok(path) = root_uri.to_file_path()
453        {
454            let path = super::resolve_workspace_root(&path);
455            log::info!("Workspace root: {}", path.display());
456            roots.push(path);
457        }
458        *self.workspace_roots.write().await = roots;
459
460        // Load rumdl configuration with auto-discovery (fallback/default)
461        self.load_configuration(false).await;
462
463        let (enable_link_navigation, enable_link_completions, enable_symbols) = {
464            let config = self.config.read().await;
465            (
466                config.enable_link_navigation,
467                config.enable_link_completions,
468                config.enable_symbols,
469            )
470        };
471
472        Ok(InitializeResult {
473            capabilities: ServerCapabilities {
474                text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
475                    open_close: Some(true),
476                    change: Some(TextDocumentSyncKind::FULL),
477                    will_save: Some(false),
478                    will_save_wait_until: Some(true),
479                    save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
480                        include_text: Some(false),
481                    })),
482                })),
483                code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
484                    code_action_kinds: Some(vec![
485                        CodeActionKind::QUICKFIX,
486                        CodeActionKind::SOURCE_FIX_ALL,
487                        CodeActionKind::new("source.fixAll.rumdl"),
488                    ]),
489                    work_done_progress_options: WorkDoneProgressOptions::default(),
490                    resolve_provider: None,
491                })),
492                document_formatting_provider: Some(OneOf::Left(true)),
493                document_range_formatting_provider: Some(OneOf::Left(true)),
494                document_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
495                workspace_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
496                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
497                    identifier: Some("rumdl".to_string()),
498                    inter_file_dependencies: true,
499                    workspace_diagnostics: false,
500                    work_done_progress_options: WorkDoneProgressOptions::default(),
501                })),
502                // Completion always stays available for fenced code-block language
503                // labels (backtick trigger). The link-target triggers (`(` `#` `/`
504                // `.` `-`) are only registered when link completions are enabled, so
505                // a client with its own link-completion source (e.g. a PKM-focused
506                // LSP) is not invoked on those characters when the feature is off.
507                completion_provider: Some(CompletionOptions {
508                    trigger_characters: Some(if enable_link_completions {
509                        vec![
510                            "`".to_string(),
511                            "(".to_string(),
512                            "#".to_string(),
513                            "/".to_string(),
514                            ".".to_string(),
515                            "-".to_string(),
516                        ]
517                    } else {
518                        vec!["`".to_string()]
519                    }),
520                    resolve_provider: Some(false),
521                    work_done_progress_options: WorkDoneProgressOptions::default(),
522                    all_commit_characters: None,
523                    completion_item: None,
524                }),
525                definition_provider: enable_link_navigation.then_some(OneOf::Left(true)),
526                references_provider: enable_link_navigation.then_some(OneOf::Left(true)),
527                hover_provider: enable_link_navigation.then_some(HoverProviderCapability::Simple(true)),
528                rename_provider: enable_link_navigation.then_some(OneOf::Right(RenameOptions {
529                    prepare_provider: Some(true),
530                    work_done_progress_options: WorkDoneProgressOptions::default(),
531                })),
532                workspace: Some(WorkspaceServerCapabilities {
533                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
534                        supported: Some(true),
535                        change_notifications: Some(OneOf::Left(true)),
536                    }),
537                    file_operations: None,
538                }),
539                ..Default::default()
540            },
541            server_info: Some(ServerInfo {
542                name: "rumdl".to_string(),
543                version: Some(env!("CARGO_PKG_VERSION").to_string()),
544            }),
545        })
546    }
547
548    async fn initialized(&self, _: InitializedParams) {
549        let version = env!("CARGO_PKG_VERSION");
550
551        // Get binary path and build time
552        let (binary_path, build_time) = std::env::current_exe().ok().map_or_else(
553            || ("unknown".to_string(), "unknown".to_string()),
554            |path| {
555                let path_str = path.to_str().unwrap_or("unknown").to_string();
556                let build_time = std::fs::metadata(&path)
557                    .ok()
558                    .and_then(|metadata| metadata.modified().ok())
559                    .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
560                    .and_then(|duration| {
561                        let secs = duration.as_secs();
562                        chrono::DateTime::from_timestamp(secs as i64, 0)
563                            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
564                    })
565                    .unwrap_or_else(|| "unknown".to_string());
566                (path_str, build_time)
567            },
568        );
569
570        let working_dir = std::env::current_dir()
571            .ok()
572            .and_then(|p| p.to_str().map(std::string::ToString::to_string))
573            .unwrap_or_else(|| "unknown".to_string());
574
575        log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
576        log::info!("Working directory: {working_dir}");
577
578        self.client
579            .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
580            .await;
581
582        // Trigger initial workspace indexing for cross-file analysis
583        if !self.queue_index_update(IndexUpdate::FullRescan).await {
584            log::warn!("Failed to trigger initial workspace indexing");
585        } else {
586            log::info!("Triggered initial workspace indexing for cross-file analysis");
587        }
588
589        // Register file watchers for markdown files and config files
590        let markdown_patterns = [
591            "**/*.md",
592            "**/*.markdown",
593            "**/*.mdx",
594            "**/*.mkd",
595            "**/*.mkdn",
596            "**/*.mdown",
597            "**/*.mdwn",
598            "**/*.qmd",
599            "**/*.rmd",
600        ];
601        // `.editorconfig` is subscribed to unconditionally: a project can opt in
602        // after the client registered these, and the handler decides whether an
603        // event counts.
604        let config_patterns = [
605            "**/.rumdl.toml",
606            "**/rumdl.toml",
607            "**/pyproject.toml",
608            "**/.markdownlint.json",
609            "**/.markdownlint-cli2.yaml",
610            "**/.markdownlint-cli2.jsonc",
611            "**/.editorconfig",
612        ];
613        let watchers: Vec<_> = markdown_patterns
614            .iter()
615            .chain(config_patterns.iter())
616            .map(|pattern| FileSystemWatcher {
617                glob_pattern: GlobPattern::String((*pattern).to_string()),
618                kind: Some(WatchKind::all()),
619            })
620            .collect();
621
622        let registration = Registration {
623            id: "markdown-watcher".to_string(),
624            method: "workspace/didChangeWatchedFiles".to_string(),
625            register_options: Some(
626                serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
627            ),
628        };
629
630        if self.client.register_capability(vec![registration]).await.is_err() {
631            log::debug!("Client does not support file watching capability");
632        }
633    }
634
635    async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
636        let uri = params.text_document_position.text_document.uri;
637        let position = params.text_document_position.position;
638
639        // Get document content
640        let Some(text) = self.get_document_content(&uri).await else {
641            return Ok(None);
642        };
643
644        // Code fence language completion (backtick trigger)
645        if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
646            log::debug!(
647                "Code fence completion triggered at {}:{}, current text: '{}'",
648                position.line,
649                position.character,
650                current_text
651            );
652            let items = self
653                .get_language_completions(&uri, &current_text, start_col, position)
654                .await;
655            if !items.is_empty() {
656                return Ok(Some(CompletionResponse::Array(items)));
657            }
658        }
659
660        // Link target completion: file paths and heading anchors
661        if self.config.read().await.enable_link_completions {
662            // For trigger characters that fire on many non-link contexts (`.`, `-`),
663            // skip the full parse when there is no `](` on the current line before
664            // the cursor.  This avoids needless work on list items and contractions.
665            let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
666            let skip_link_check = matches!(trigger, Some("." | "-")) && {
667                let line_num = position.line as usize;
668                // Scan the whole line — no byte-slicing at a UTF-16 offset needed.
669                // A line without `](` anywhere cannot contain a link target.
670                !text.lines().nth(line_num).is_some_and(|line| line.contains("]("))
671            };
672
673            if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
674                if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
675                    log::debug!(
676                        "Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
677                        position.line,
678                        position.character,
679                        link_info.file_path,
680                        partial_anchor
681                    );
682                    let items = self
683                        .get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
684                        .await;
685                    if !items.is_empty() {
686                        return Ok(Some(CompletionResponse::Array(items)));
687                    }
688                } else {
689                    log::debug!(
690                        "File path completion triggered at {}:{}, partial: '{}'",
691                        position.line,
692                        position.character,
693                        link_info.file_path
694                    );
695                    let list = self
696                        .get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
697                        .await;
698                    if !list.items.is_empty() {
699                        return Ok(Some(CompletionResponse::List(list)));
700                    }
701                }
702            }
703        }
704
705        Ok(None)
706    }
707
708    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
709        // Update workspace roots
710        let mut roots = self.workspace_roots.write().await;
711
712        // Resolved the same way `initialize` resolves a root, so a folder added
713        // or removed later is comparable with the ones already recorded.
714        // Remove deleted workspace folders
715        for removed in &params.event.removed {
716            if let Ok(path) = removed.uri.to_file_path() {
717                let path = super::resolve_workspace_root(&path);
718                roots.retain(|r| r != &path);
719                log::info!("Removed workspace root: {}", path.display());
720            }
721        }
722
723        // Add new workspace folders
724        for added in &params.event.added {
725            if let Ok(path) = added.uri.to_file_path()
726                && let path = super::resolve_workspace_root(&path)
727                && !roots.contains(&path)
728            {
729                log::info!("Added workspace root: {}", path.display());
730                roots.push(path);
731            }
732        }
733        drop(roots);
734
735        // Clear config cache as workspace structure changed
736        self.config_cache.write().await.clear();
737
738        // Reload fallback configuration
739        self.reload_configuration().await;
740
741        // Trigger full workspace rescan for cross-file index
742        if !self.queue_index_update(IndexUpdate::FullRescan).await {
743            log::warn!("Failed to trigger workspace rescan after folder change");
744        }
745    }
746
747    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
748        log::debug!("Configuration changed: {:?}", params.settings);
749
750        // Parse settings from the notification
751        // Neovim sends: { "rumdl": { "MD013": {...}, ... } }
752        // VSCode might send the full RumdlLspConfig or similar structure
753        let settings_value = params.settings;
754
755        // Try to extract "rumdl" key from settings (Neovim style)
756        let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
757            obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
758        } else {
759            settings_value
760        };
761
762        // Track if we successfully applied any configuration
763        let mut config_applied = false;
764        let mut warnings: Vec<String> = Vec::new();
765
766        // Try to parse as LspRuleSettings first (Neovim style with "disable", "enable", rule keys)
767        // We check this first because RumdlLspConfig with #[serde(default)] will accept any JSON
768        // and just ignore unknown fields, which would lose the Neovim-style settings
769        if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
770            && (rule_settings.disable.is_some()
771                || rule_settings.enable.is_some()
772                || rule_settings.line_length.is_some()
773                || (!rule_settings.rules.is_empty() && rule_settings.rules.keys().all(|k| is_valid_rule_name(k))))
774        {
775            // Validate rule names in disable/enable lists
776            if let Some(ref disable) = rule_settings.disable {
777                for rule in disable {
778                    if !is_valid_rule_name(rule) {
779                        warnings.push(format!("Unknown rule in disable list: {rule}"));
780                    }
781                }
782            }
783            if let Some(ref enable) = rule_settings.enable {
784                for rule in enable {
785                    if !is_valid_rule_name(rule) {
786                        warnings.push(format!("Unknown rule in enable list: {rule}"));
787                    }
788                }
789            }
790            // Validate rule-specific settings
791            for rule_name in rule_settings.rules.keys() {
792                if !is_valid_rule_name(rule_name) {
793                    warnings.push(format!("Unknown rule in settings: {rule_name}"));
794                }
795            }
796
797            log::info!("Applied rule settings from configuration (Neovim style)");
798            let mut config = self.config.write().await;
799            config.settings = Some(rule_settings);
800            drop(config);
801            config_applied = true;
802        } else if names_lsp_config_field(&rumdl_settings)
803            && let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
804        {
805            // Validate rule names
806            if let Some(ref rules) = full_config.enable_rules {
807                for rule in rules {
808                    if !is_valid_rule_name(rule) {
809                        warnings.push(format!("Unknown rule in enableRules: {rule}"));
810                    }
811                }
812            }
813            if let Some(ref rules) = full_config.disable_rules {
814                for rule in rules {
815                    if !is_valid_rule_name(rule) {
816                        warnings.push(format!("Unknown rule in disableRules: {rule}"));
817                    }
818                }
819            }
820
821            // Merge only the keys the client sent onto the current config (see
822            // `merge_lsp_config`), so a partial payload never clobbers previously-set
823            // fields. The write lock is held across the merge so the read-modify-write
824            // is atomic; the merge is synchronous and `.await`-free, so it cannot
825            // deadlock or stall the executor. `full_config` was already validated above
826            // and is no longer needed here (a merge failure leaves the config unchanged
827            // rather than falling back to a clobbering whole-struct replace).
828            {
829                let mut config = self.config.write().await;
830                if let Some(merged) = merge_lsp_config(&config, &rumdl_settings) {
831                    *config = merged;
832                    drop(config);
833                    log::info!("Merged LSP configuration from client settings");
834                    config_applied = true;
835                } else {
836                    drop(config);
837                    warnings.push("Could not merge LSP configuration update; keeping current settings".to_string());
838                }
839            }
840        } else if let serde_json::Value::Object(obj) = rumdl_settings {
841            // Otherwise, treat as per-rule settings with manual parsing
842            // Format: { "MD013": { "lineLength": 80 }, "disable": ["MD009"] }
843            let mut config = self.config.write().await;
844
845            // Manual parsing for Neovim format
846            let mut rules = std::collections::HashMap::new();
847            let mut disable = Vec::new();
848            let mut enable = Vec::new();
849            let mut line_length = None;
850            // Whether any key carried a rule setting. A payload that names none
851            // (another extension's keys, `{}`) says nothing about rule settings,
852            // so it must not replace the ones already in effect.
853            let mut names_a_rule_setting = false;
854
855            for (key, value) in obj {
856                match key.as_str() {
857                    "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
858                        Ok(d) => {
859                            if d.len() > MAX_RULE_LIST_SIZE {
860                                warnings.push(format!(
861                                    "Too many rules in 'disable' ({} > {}), truncating",
862                                    d.len(),
863                                    MAX_RULE_LIST_SIZE
864                                ));
865                            }
866                            for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
867                                if !is_valid_rule_name(rule) {
868                                    warnings.push(format!("Unknown rule in disable: {rule}"));
869                                }
870                            }
871                            disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
872                            names_a_rule_setting = true;
873                        }
874                        Err(_) => {
875                            warnings.push(format!(
876                                "Invalid 'disable' value: expected array of strings, got {value}"
877                            ));
878                        }
879                    },
880                    "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
881                        Ok(e) => {
882                            if e.len() > MAX_RULE_LIST_SIZE {
883                                warnings.push(format!(
884                                    "Too many rules in 'enable' ({} > {}), truncating",
885                                    e.len(),
886                                    MAX_RULE_LIST_SIZE
887                                ));
888                            }
889                            for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
890                                if !is_valid_rule_name(rule) {
891                                    warnings.push(format!("Unknown rule in enable: {rule}"));
892                                }
893                            }
894                            enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
895                            names_a_rule_setting = true;
896                        }
897                        Err(_) => {
898                            warnings.push(format!(
899                                "Invalid 'enable' value: expected array of strings, got {value}"
900                            ));
901                        }
902                    },
903                    "lineLength" | "line_length" | "line-length" => {
904                        if let Some(l) = value.as_u64() {
905                            match usize::try_from(l) {
906                                Ok(len) if len <= MAX_LINE_LENGTH => {
907                                    line_length = Some(len);
908                                    names_a_rule_setting = true;
909                                }
910                                Ok(len) => warnings.push(format!(
911                                    "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
912                                )),
913                                Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
914                            }
915                        } else {
916                            warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
917                        }
918                    }
919                    // Rule-specific settings (e.g., "MD013": { "lineLength": 80 })
920                    _ if key.starts_with("MD") || key.starts_with("md") => {
921                        let normalized = key.to_uppercase();
922                        if !is_valid_rule_name(&normalized) {
923                            warnings.push(format!("Unknown rule: {key}"));
924                        }
925                        rules.insert(normalized, value);
926                        names_a_rule_setting = true;
927                    }
928                    _ => {
929                        // Unknown key - warn and ignore
930                        warnings.push(format!("Unknown configuration key: {key}"));
931                    }
932                }
933            }
934
935            let settings = LspRuleSettings {
936                line_length,
937                disable: if disable.is_empty() { None } else { Some(disable) },
938                enable: if enable.is_empty() { None } else { Some(enable) },
939                rules,
940            };
941
942            if names_a_rule_setting {
943                log::info!("Applied Neovim-style rule settings (manual parse)");
944                config.settings = Some(settings);
945                config_applied = true;
946            }
947            drop(config);
948        } else {
949            log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
950        }
951
952        // Log warnings for invalid configuration
953        for warning in &warnings {
954            log::warn!("{warning}");
955        }
956
957        // Notify client of configuration warnings via window/logMessage
958        if !warnings.is_empty() {
959            let message = if warnings.len() == 1 {
960                format!("rumdl: {}", warnings[0])
961            } else {
962                format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
963            };
964            self.client.log_message(MessageType::WARNING, message).await;
965        }
966
967        if !config_applied {
968            log::debug!("No configuration changes applied");
969        }
970
971        // Clear config cache to pick up new settings
972        self.config_cache.write().await.clear();
973
974        // Reload the global rumdl config so a runtime change to `configPath`
975        // (handled by the parser branches above) takes effect on the next
976        // resolve. Without this, `resolve_config_for_file` would keep returning
977        // the previously-loaded `rumdl_config`, silently ignoring the new path.
978        // Skip the client notification: the diagnostics refresh below already
979        // surfaces the result, and notifying here can stall when a test or
980        // misbehaving client isn't draining the LSP message channel.
981        if config_applied {
982            self.load_configuration(false).await;
983
984            // Rebuild the workspace index under the reloaded config: a new
985            // configPath can change exclude patterns or respect_gitignore,
986            // which the scan reads from the shared config.
987            if !self.queue_index_update(IndexUpdate::FullRescan).await {
988                log::warn!("Failed to request workspace rescan after configuration change");
989            }
990        }
991
992        // Collect all open documents first (to avoid holding lock during async
993        // operations). Files cached from disk to answer a request are not open:
994        // publishing for one puts diagnostics on screen for a document the
995        // editor never opened, and no `didClose` will ever clear them.
996        let doc_list: Vec<_> = {
997            let documents = self.documents.read().await;
998            documents
999                .iter()
1000                .filter(|(_, entry)| !entry.from_disk)
1001                .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1002                .collect()
1003        };
1004
1005        // Refresh diagnostics for all open documents concurrently. Collecting the
1006        // handles is what starts every task: a lazy iterator would spawn each one
1007        // only as the loop below awaits it, running them one at a time.
1008        let tasks: Vec<_> = doc_list
1009            .into_iter()
1010            .map(|(uri, text)| {
1011                let server = self.clone();
1012                tokio::spawn(async move {
1013                    server.update_diagnostics(uri, text, true).await;
1014                })
1015            })
1016            .collect();
1017
1018        // Wait for all diagnostics to complete
1019        for task in tasks {
1020            let _ = task.await;
1021        }
1022    }
1023
1024    async fn shutdown(&self) -> JsonRpcResult<()> {
1025        log::info!("Shutting down rumdl Language Server");
1026
1027        // Signal the index worker to shut down
1028        self.queue_index_update(IndexUpdate::Shutdown).await;
1029
1030        Ok(())
1031    }
1032
1033    async fn did_open(&self, params: DidOpenTextDocumentParams) {
1034        let uri = params.text_document.uri;
1035        let text = params.text_document.text;
1036        let version = params.text_document.version;
1037
1038        let entry = DocumentEntry {
1039            content: text.clone(),
1040            version: Some(version),
1041            from_disk: false,
1042        };
1043        self.documents.write().await.insert(uri.clone(), entry);
1044
1045        // Make the document reachable by the spelling navigation resolves it to.
1046        let resolved = super::resolve_uri_spelling(&uri);
1047        if resolved != uri {
1048            let mut aliases = self.document_aliases.write().await;
1049            let spellings = aliases.entry(resolved).or_default();
1050            if !spellings.contains(&uri) {
1051                spellings.push(uri.clone());
1052            }
1053        }
1054
1055        // Send update to index worker for cross-file analysis
1056        if let Some(path) = super::resolve_uri(&uri) {
1057            self.queue_index_update(IndexUpdate::FileChanged {
1058                path,
1059                content: text.clone(),
1060            })
1061            .await;
1062        }
1063
1064        self.update_diagnostics(uri, text, true).await;
1065    }
1066
1067    async fn did_change(&self, params: DidChangeTextDocumentParams) {
1068        let uri = params.text_document.uri;
1069        let version = params.text_document.version;
1070
1071        // rumdl advertises full sync, but a client may still send ranged
1072        // changes; those apply to the text the server already holds.
1073        let base = if params.content_changes.iter().all(|change| change.range.is_some()) {
1074            match self.documents.read().await.get(&uri) {
1075                Some(entry) => Some(entry.content.clone()),
1076                None => {
1077                    log::warn!("Ignoring an incremental change to {uri}, which is not open");
1078                    return;
1079                }
1080            }
1081        } else {
1082            None
1083        };
1084
1085        if !params.content_changes.is_empty() {
1086            let text = super::position::apply_content_changes(base.unwrap_or_default(), params.content_changes);
1087
1088            let entry = DocumentEntry {
1089                content: text.clone(),
1090                version: Some(version),
1091                from_disk: false,
1092            };
1093            self.documents.write().await.insert(uri.clone(), entry);
1094
1095            // Send update to index worker for cross-file analysis
1096            if let Some(path) = super::resolve_uri(&uri) {
1097                self.queue_index_update(IndexUpdate::FileChanged {
1098                    path,
1099                    content: text.clone(),
1100                })
1101                .await;
1102            }
1103
1104            self.update_diagnostics(uri, text, false).await;
1105        }
1106    }
1107
1108    async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1109        // Only apply fixes on manual saves (Cmd+S / Ctrl+S), not on autosave
1110        // This respects VSCode's editor.formatOnSave: "explicit" setting
1111        if params.reason != TextDocumentSaveReason::MANUAL {
1112            return Ok(None);
1113        }
1114
1115        let config_guard = self.config.read().await;
1116        let enable_auto_fix = config_guard.enable_auto_fix;
1117        drop(config_guard);
1118
1119        if !enable_auto_fix {
1120            return Ok(None);
1121        }
1122
1123        // Get the current document content
1124        let Some(text) = self.get_document_content(&params.text_document.uri).await else {
1125            return Ok(None);
1126        };
1127
1128        // Apply all fixes
1129        match self.apply_all_fixes(&params.text_document.uri, &text).await {
1130            Ok(Some(fixed_text)) => {
1131                // Return a single edit that replaces the entire document
1132                Ok(Some(vec![TextEdit {
1133                    range: Range {
1134                        start: Position { line: 0, character: 0 },
1135                        end: self.get_end_position(&text),
1136                    },
1137                    new_text: fixed_text,
1138                }]))
1139            }
1140            Ok(None) => Ok(None),
1141            Err(e) => {
1142                log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1143                Ok(None)
1144            }
1145        }
1146    }
1147
1148    async fn did_save(&self, params: DidSaveTextDocumentParams) {
1149        // Re-lint the document after save
1150        // Note: Auto-fixing is now handled by will_save_wait_until which runs before the save
1151        if let Some(entry) = self.documents.read().await.get(&params.text_document.uri) {
1152            self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
1153                .await;
1154        }
1155    }
1156
1157    async fn did_close(&self, params: DidCloseTextDocumentParams) {
1158        // Remove document from storage
1159        self.documents.write().await.remove(&params.text_document.uri);
1160        // Drop only this spelling. Another one naming the same file can still be
1161        // open, and it stays reachable under the resolved URI.
1162        let resolved = super::resolve_uri_spelling(&params.text_document.uri);
1163        if resolved != params.text_document.uri {
1164            let mut aliases = self.document_aliases.write().await;
1165            if let Some(spellings) = aliases.get_mut(&resolved) {
1166                spellings.retain(|u| u != &params.text_document.uri);
1167                if spellings.is_empty() {
1168                    aliases.remove(&resolved);
1169                }
1170            }
1171        }
1172
1173        // The closed buffer was the index's source for this file. What replaces
1174        // it is the same file still open under another spelling, or otherwise
1175        // whatever the workspace scan would record.
1176        if let Some(path) = super::resolve_uri(&params.text_document.uri) {
1177            match self.get_open_document_content(&resolved).await {
1178                Some(content) => {
1179                    self.queue_index_update(IndexUpdate::FileChanged { path, content })
1180                        .await;
1181                }
1182                None => self.reindex_from_disk(path).await,
1183            }
1184        }
1185
1186        // Always clear diagnostics on close to ensure cleanup
1187        // (Ruff does this unconditionally as a defensive measure)
1188        self.client
1189            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1190            .await;
1191    }
1192
1193    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1194        // Check if any of the changed files are config files
1195        const CONFIG_FILES: &[&str] = &[
1196            ".rumdl.toml",
1197            "rumdl.toml",
1198            "pyproject.toml",
1199            ".markdownlint.json",
1200            ".markdownlint-cli2.jsonc",
1201            ".markdownlint-cli2.yaml",
1202            ".markdownlint-cli2.yml",
1203        ];
1204
1205        let mut config_changed = false;
1206        // An `.editorconfig` supplies settings only while a config opts into
1207        // reading it, so it is a config file here only in a workspace that did.
1208        let reads_editorconfig = self.reads_editorconfig().await;
1209
1210        for change in &params.changes {
1211            // Resolved like every other path the server records, so a watch event
1212            // is comparable with the workspace roots and with the index keys the
1213            // scan produced. A deleted file still resolves: only its directory is.
1214            if let Some(path) = super::resolve_uri(&change.uri) {
1215                let file_name = path.file_name().and_then(|f| f.to_str());
1216
1217                // Handle config file changes
1218                if let Some(name) = file_name
1219                    && (CONFIG_FILES.contains(&name) || (reads_editorconfig && name == ".editorconfig"))
1220                    && !config_changed
1221                {
1222                    log::info!("Config file changed: {}, invalidating config cache", path.display());
1223
1224                    // Clear the entire config cache when any config file changes.
1225                    // Fallback entries (no config_file) become stale when a new config file
1226                    // is created, and directory-scoped entries may resolve differently after edits.
1227                    let mut cache = self.config_cache.write().await;
1228                    cache.clear();
1229
1230                    // Also reload the global fallback configuration
1231                    drop(cache);
1232                    self.reload_configuration().await;
1233                    config_changed = true;
1234                }
1235
1236                // Handle markdown file changes for workspace index
1237                if let Some(ext) = path.extension()
1238                    && is_markdown_extension(ext)
1239                {
1240                    match change.typ {
1241                        FileChangeType::CREATED | FileChangeType::CHANGED => {
1242                            // The filesystem does not speak for a document an editor
1243                            // holds: what is on disk is the last save, and opening a
1244                            // document indexes it whatever discovery says. Re-queue
1245                            // the buffer rather than skipping the event, so a file
1246                            // deleted and recreated underneath the editor (a branch
1247                            // switch) keeps the version the user is looking at. The
1248                            // lookup goes through the spelling the server identifies
1249                            // documents by, because a watch event words the path the
1250                            // way the filesystem does and not the way the editor did.
1251                            if let Some(content) = self
1252                                .get_open_document_content(&super::resolve_uri_spelling(&change.uri))
1253                                .await
1254                            {
1255                                self.queue_index_update(IndexUpdate::FileChanged {
1256                                    path: path.clone(),
1257                                    content,
1258                                })
1259                                .await;
1260                                continue;
1261                            }
1262                            self.reindex_from_disk(path.clone()).await;
1263                        }
1264                        FileChangeType::DELETED => {
1265                            self.queue_index_update(IndexUpdate::FileRemoved { path: path.clone() })
1266                                .await;
1267                        }
1268                        _ => {}
1269                    }
1270                }
1271            }
1272        }
1273
1274        // Re-lint all open documents if config changed
1275        if config_changed {
1276            // Rebuild the workspace index: discovery-relevant settings
1277            // (exclude patterns, respect_gitignore) may have changed, and the
1278            // scan reads them from the shared config.
1279            if !self.queue_index_update(IndexUpdate::FullRescan).await {
1280                log::warn!("Failed to request workspace rescan after config change");
1281            }
1282
1283            let docs_to_update: Vec<(Url, String)> = {
1284                let docs = self.documents.read().await;
1285                docs.iter()
1286                    .filter(|(_, entry)| !entry.from_disk)
1287                    .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1288                    .collect()
1289            };
1290
1291            for (uri, text) in docs_to_update {
1292                self.update_diagnostics(uri, text, true).await;
1293            }
1294        }
1295    }
1296
1297    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1298        let uri = params.text_document.uri;
1299        let range = params.range;
1300        let requested_kinds = params.context.only;
1301
1302        if let Some(text) = self.get_document_content(&uri).await {
1303            match self.get_code_actions(&uri, &text, range).await {
1304                Ok(actions) => {
1305                    // Filter actions by requested kinds (if specified and non-empty)
1306                    // LSP spec: "If provided with no kinds, all supported kinds are returned"
1307                    // LSP code action kinds are hierarchical: source.fixAll.rumdl matches source.fixAll
1308                    let filtered_actions = if let Some(ref kinds) = requested_kinds
1309                        && !kinds.is_empty()
1310                    {
1311                        actions
1312                            .into_iter()
1313                            .filter(|action| {
1314                                action.kind.as_ref().is_some_and(|action_kind| {
1315                                    let action_kind_str = action_kind.as_str();
1316                                    kinds.iter().any(|requested| {
1317                                        // A kind matches itself and the kinds beneath it:
1318                                        // "source.fixAll" takes "source.fixAll.rumdl", but
1319                                        // "source.fix" takes neither. The empty kind is the
1320                                        // root and takes every kind.
1321                                        let requested = requested.as_str();
1322                                        requested.is_empty()
1323                                            || action_kind_str
1324                                                .strip_prefix(requested)
1325                                                .is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
1326                                    })
1327                                })
1328                            })
1329                            .collect()
1330                    } else {
1331                        actions
1332                    };
1333
1334                    let response: Vec<CodeActionOrCommand> = filtered_actions
1335                        .into_iter()
1336                        .map(CodeActionOrCommand::CodeAction)
1337                        .collect();
1338                    Ok(Some(response))
1339                }
1340                Err(e) => {
1341                    log::error!("Failed to get code actions: {e}");
1342                    Ok(None)
1343                }
1344            }
1345        } else {
1346            Ok(None)
1347        }
1348    }
1349
1350    async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1351        let uri = params.text_document.uri;
1352        log::debug!("Range formatting request for {uri} {:?}", params.range);
1353
1354        let Some((text, formatted)) = self.format_document(&uri, &params.options).await else {
1355            log::warn!("Document not found: {uri}");
1356            return Ok(None);
1357        };
1358        Ok(Some(Self::range_edits(&text, &formatted, params.range)))
1359    }
1360
1361    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1362        let uri = params.text_document.uri;
1363        log::debug!("Formatting request for: {uri}");
1364
1365        let Some((text, formatted)) = self.format_document(&uri, &params.options).await else {
1366            log::warn!("Document not found: {uri}");
1367            return Ok(None);
1368        };
1369        if formatted == text {
1370            return Ok(Some(Vec::new()));
1371        }
1372        let edit = TextEdit {
1373            range: Range {
1374                start: Position { line: 0, character: 0 },
1375                end: self.get_end_position(&text),
1376            },
1377            new_text: formatted,
1378        };
1379        Ok(Some(vec![edit]))
1380    }
1381
1382    async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1383        if !self.config.read().await.enable_link_navigation {
1384            return Ok(None);
1385        }
1386        let uri = params.text_document_position_params.text_document.uri;
1387        let position = params.text_document_position_params.position;
1388
1389        log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1390
1391        Ok(self.handle_goto_definition(&uri, position).await)
1392    }
1393
1394    async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1395        if !self.config.read().await.enable_link_navigation {
1396            return Ok(None);
1397        }
1398        let uri = params.text_document_position.text_document.uri;
1399        let position = params.text_document_position.position;
1400
1401        log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1402
1403        Ok(self.handle_references(&uri, position).await)
1404    }
1405
1406    async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1407        if !self.config.read().await.enable_link_navigation {
1408            return Ok(None);
1409        }
1410        let uri = params.text_document_position_params.text_document.uri;
1411        let position = params.text_document_position_params.position;
1412
1413        log::debug!("Hover at {uri} {}:{}", position.line, position.character);
1414
1415        Ok(self.handle_hover(&uri, position).await)
1416    }
1417
1418    async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1419        if !self.config.read().await.enable_link_navigation {
1420            return Ok(None);
1421        }
1422        let uri = params.text_document.uri;
1423        let position = params.position;
1424
1425        log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
1426
1427        Ok(self.handle_prepare_rename(&uri, position).await)
1428    }
1429
1430    async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1431        if !self.config.read().await.enable_link_navigation {
1432            return Ok(None);
1433        }
1434        let uri = params.text_document_position.text_document.uri;
1435        let position = params.text_document_position.position;
1436        let new_name = params.new_name;
1437
1438        log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
1439
1440        Ok(self.handle_rename(&uri, position, &new_name).await)
1441    }
1442
1443    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1444        let uri = params.text_document.uri;
1445
1446        if let Some(text) = self.get_open_document_content(&uri).await {
1447            match self.lint_document(&uri, &text, true).await {
1448                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1449                    RelatedFullDocumentDiagnosticReport {
1450                        related_documents: None,
1451                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
1452                            result_id: None,
1453                            items: diagnostics,
1454                        },
1455                    },
1456                ))),
1457                Err(e) => {
1458                    log::error!("Failed to get diagnostics: {e}");
1459                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1460                        RelatedFullDocumentDiagnosticReport {
1461                            related_documents: None,
1462                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
1463                                result_id: None,
1464                                items: Vec::new(),
1465                            },
1466                        },
1467                    )))
1468                }
1469            }
1470        } else {
1471            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1472                RelatedFullDocumentDiagnosticReport {
1473                    related_documents: None,
1474                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
1475                        result_id: None,
1476                        items: Vec::new(),
1477                    },
1478                },
1479            )))
1480        }
1481    }
1482
1483    async fn document_symbol(&self, params: DocumentSymbolParams) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
1484        if !self.config.read().await.enable_symbols {
1485            return Ok(None);
1486        }
1487
1488        let uri = params.text_document.uri;
1489        let Some(text) = self.get_document_content(&uri).await else {
1490            return Ok(None);
1491        };
1492
1493        let flavor = self.resolve_flavor_for_uri(&uri).await;
1494        let ctx = crate::lint_context::LintContext::new(&text, flavor, None);
1495
1496        if *self.client_supports_hierarchical_symbols.read().await {
1497            let symbols = super::symbols::document_symbols(&ctx);
1498            Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Nested(symbols)))
1499        } else {
1500            let symbols = super::symbols::document_symbols_flat(&ctx, &uri);
1501            Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Flat(symbols)))
1502        }
1503    }
1504
1505    async fn symbol(&self, params: WorkspaceSymbolParams) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
1506        if !self.config.read().await.enable_symbols {
1507            return Ok(None);
1508        }
1509
1510        let query = params.query.to_lowercase();
1511        let index = self.workspace_index.read().await;
1512        let symbols = super::symbols::workspace_symbols(&index, &query);
1513        Ok(if symbols.is_empty() { None } else { Some(symbols) })
1514    }
1515}
1516
1517#[cfg(test)]
1518#[path = "tests.rs"]
1519mod tests;