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