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