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