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