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