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