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