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