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