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 anyhow::Result;
11use tokio::sync::RwLock;
12use tower_lsp::jsonrpc::Result as JsonRpcResult;
13use tower_lsp::lsp_types::*;
14use tower_lsp::{Client, LanguageServer};
15
16use crate::config::Config;
17use crate::lint;
18use crate::lsp::types::{RumdlLspConfig, warning_to_code_actions, warning_to_diagnostic};
19use crate::rule::{FixCapability, Rule};
20use crate::rules;
21
22/// Represents a document in the LSP server's cache
23#[derive(Clone, Debug, PartialEq)]
24struct DocumentEntry {
25    /// The document content
26    content: String,
27    /// Version number from the editor (None for disk-loaded documents)
28    version: Option<i32>,
29    /// Whether the document was loaded from disk (true) or opened in editor (false)
30    from_disk: bool,
31}
32
33/// Cache entry for resolved configuration
34#[derive(Clone, Debug)]
35pub(crate) struct ConfigCacheEntry {
36    /// The resolved configuration
37    pub(crate) config: Config,
38    /// Config file path that was loaded (for invalidation)
39    pub(crate) config_file: Option<PathBuf>,
40    /// True if this entry came from the global/user fallback (no project config)
41    pub(crate) from_global_fallback: bool,
42}
43
44/// Main LSP server for rumdl
45///
46/// Following Ruff's pattern, this server provides:
47/// - Real-time diagnostics as users type
48/// - Code actions for automatic fixes
49/// - Configuration management
50/// - Multi-file support
51/// - Multi-root workspace support with per-file config resolution
52#[derive(Clone)]
53pub struct RumdlLanguageServer {
54    client: Client,
55    /// Configuration for the LSP server
56    config: Arc<RwLock<RumdlLspConfig>>,
57    /// Rumdl core configuration (fallback/default)
58    #[cfg_attr(test, allow(dead_code))]
59    pub(crate) rumdl_config: Arc<RwLock<Config>>,
60    /// Document store for open files and cached disk files
61    documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
62    /// Workspace root folders from the client
63    #[cfg_attr(test, allow(dead_code))]
64    pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
65    /// Configuration cache: maps directory path to resolved config
66    /// Key is the directory where config search started (file's parent dir)
67    #[cfg_attr(test, allow(dead_code))]
68    pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
69}
70
71impl RumdlLanguageServer {
72    pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
73        // Initialize with CLI config path if provided (for `rumdl server --config` convenience)
74        let mut initial_config = RumdlLspConfig::default();
75        if let Some(path) = cli_config_path {
76            initial_config.config_path = Some(path.to_string());
77        }
78
79        Self {
80            client,
81            config: Arc::new(RwLock::new(initial_config)),
82            rumdl_config: Arc::new(RwLock::new(Config::default())),
83            documents: Arc::new(RwLock::new(HashMap::new())),
84            workspace_roots: Arc::new(RwLock::new(Vec::new())),
85            config_cache: Arc::new(RwLock::new(HashMap::new())),
86        }
87    }
88
89    /// Get document content, either from cache or by reading from disk
90    ///
91    /// This method first checks if the document is in the cache (opened in editor).
92    /// If not found, it attempts to read the file from disk and caches it for
93    /// future requests.
94    async fn get_document_content(&self, uri: &Url) -> Option<String> {
95        // First check the cache
96        {
97            let docs = self.documents.read().await;
98            if let Some(entry) = docs.get(uri) {
99                return Some(entry.content.clone());
100            }
101        }
102
103        // If not in cache and it's a file URI, try to read from disk
104        if let Ok(path) = uri.to_file_path() {
105            if let Ok(content) = tokio::fs::read_to_string(&path).await {
106                // Cache the document for future requests
107                let entry = DocumentEntry {
108                    content: content.clone(),
109                    version: None,
110                    from_disk: true,
111                };
112
113                let mut docs = self.documents.write().await;
114                docs.insert(uri.clone(), entry);
115
116                log::debug!("Loaded document from disk and cached: {uri}");
117                return Some(content);
118            } else {
119                log::debug!("Failed to read file from disk: {uri}");
120            }
121        }
122
123        None
124    }
125
126    /// Apply LSP config overrides to the filtered rules
127    fn apply_lsp_config_overrides(
128        &self,
129        mut filtered_rules: Vec<Box<dyn Rule>>,
130        lsp_config: &RumdlLspConfig,
131    ) -> Vec<Box<dyn Rule>> {
132        // Apply enable_rules override from LSP config (if specified, only these rules are active)
133        if let Some(enable) = &lsp_config.enable_rules
134            && !enable.is_empty()
135        {
136            let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
137            filtered_rules.retain(|rule| enable_set.contains(rule.name()));
138        }
139
140        // Apply disable_rules override from LSP config
141        if let Some(disable) = &lsp_config.disable_rules
142            && !disable.is_empty()
143        {
144            let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
145            filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
146        }
147
148        filtered_rules
149    }
150
151    /// Check if a file URI should be excluded based on exclude patterns
152    async fn should_exclude_uri(&self, uri: &Url) -> bool {
153        // Try to convert URI to file path
154        let file_path = match uri.to_file_path() {
155            Ok(path) => path,
156            Err(_) => return false, // If we can't get a path, don't exclude
157        };
158
159        // Resolve configuration for this specific file to get its exclude patterns
160        let rumdl_config = self.resolve_config_for_file(&file_path).await;
161        let exclude_patterns = &rumdl_config.global.exclude;
162
163        // If no exclude patterns, don't exclude
164        if exclude_patterns.is_empty() {
165            return false;
166        }
167
168        // Convert path to relative path for pattern matching
169        // This matches the CLI behavior in find_markdown_files
170        let path_to_check = if file_path.is_absolute() {
171            // Try to make it relative to the current directory
172            if let Ok(cwd) = std::env::current_dir() {
173                // Canonicalize both paths to handle symlinks
174                if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
175                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
176                        relative.to_string_lossy().to_string()
177                    } else {
178                        // Path is absolute but not under cwd
179                        file_path.to_string_lossy().to_string()
180                    }
181                } else {
182                    // Canonicalization failed
183                    file_path.to_string_lossy().to_string()
184                }
185            } else {
186                file_path.to_string_lossy().to_string()
187            }
188        } else {
189            // Already relative
190            file_path.to_string_lossy().to_string()
191        };
192
193        // Check if path matches any exclude pattern
194        for pattern in exclude_patterns {
195            if let Ok(glob) = globset::Glob::new(pattern) {
196                let matcher = glob.compile_matcher();
197                if matcher.is_match(&path_to_check) {
198                    log::debug!("Excluding file from LSP linting: {path_to_check}");
199                    return true;
200                }
201            }
202        }
203
204        false
205    }
206
207    /// Lint a document and return diagnostics
208    pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
209        let config_guard = self.config.read().await;
210
211        // Skip linting if disabled
212        if !config_guard.enable_linting {
213            return Ok(Vec::new());
214        }
215
216        let lsp_config = config_guard.clone();
217        drop(config_guard); // Release config lock early
218
219        // Check if file should be excluded based on exclude patterns
220        if self.should_exclude_uri(uri).await {
221            return Ok(Vec::new());
222        }
223
224        // Resolve configuration for this specific file
225        let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
226            self.resolve_config_for_file(&file_path).await
227        } else {
228            // Fallback to global config for non-file URIs
229            (*self.rumdl_config.read().await).clone()
230        };
231
232        let all_rules = rules::all_rules(&rumdl_config);
233        let flavor = rumdl_config.markdown_flavor();
234
235        // Use the standard filter_rules function which respects config's disabled rules
236        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
237
238        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
239        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
240
241        // Run rumdl linting with the configured flavor
242        match crate::lint(text, &filtered_rules, false, flavor) {
243            Ok(warnings) => {
244                let diagnostics = warnings.iter().map(warning_to_diagnostic).collect();
245                Ok(diagnostics)
246            }
247            Err(e) => {
248                log::error!("Failed to lint document {uri}: {e}");
249                Ok(Vec::new())
250            }
251        }
252    }
253
254    /// Update diagnostics for a document
255    async fn update_diagnostics(&self, uri: Url, text: String) {
256        // Get the document version if available
257        let version = {
258            let docs = self.documents.read().await;
259            docs.get(&uri).and_then(|entry| entry.version)
260        };
261
262        match self.lint_document(&uri, &text).await {
263            Ok(diagnostics) => {
264                self.client.publish_diagnostics(uri, diagnostics, version).await;
265            }
266            Err(e) => {
267                log::error!("Failed to update diagnostics: {e}");
268            }
269        }
270    }
271
272    /// Apply all available fixes to a document
273    async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
274        // Check if file should be excluded based on exclude patterns
275        if self.should_exclude_uri(uri).await {
276            return Ok(None);
277        }
278
279        let config_guard = self.config.read().await;
280        let lsp_config = config_guard.clone();
281        drop(config_guard);
282
283        // Resolve configuration for this specific file
284        let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
285            self.resolve_config_for_file(&file_path).await
286        } else {
287            // Fallback to global config for non-file URIs
288            (*self.rumdl_config.read().await).clone()
289        };
290
291        let all_rules = rules::all_rules(&rumdl_config);
292        let flavor = rumdl_config.markdown_flavor();
293
294        // Use the standard filter_rules function which respects config's disabled rules
295        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
296
297        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
298        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
299
300        // First, run lint to get active warnings (respecting ignore comments)
301        // This tells us which rules actually have unfixed issues
302        let mut rules_with_warnings = std::collections::HashSet::new();
303        let mut fixed_text = text.to_string();
304
305        match lint(&fixed_text, &filtered_rules, false, flavor) {
306            Ok(warnings) => {
307                for warning in warnings {
308                    if let Some(rule_name) = &warning.rule_name {
309                        rules_with_warnings.insert(rule_name.clone());
310                    }
311                }
312            }
313            Err(e) => {
314                log::warn!("Failed to lint document for auto-fix: {e}");
315                return Ok(None);
316            }
317        }
318
319        // Early return if no warnings to fix
320        if rules_with_warnings.is_empty() {
321            return Ok(None);
322        }
323
324        // Only apply fixes for rules that have active warnings
325        let mut any_changes = false;
326
327        for rule in &filtered_rules {
328            // Skip rules that don't have any active warnings
329            if !rules_with_warnings.contains(rule.name()) {
330                continue;
331            }
332
333            let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
334            match rule.fix(&ctx) {
335                Ok(new_text) => {
336                    if new_text != fixed_text {
337                        fixed_text = new_text;
338                        any_changes = true;
339                    }
340                }
341                Err(e) => {
342                    // Only log if it's an actual error, not just "rule doesn't support auto-fix"
343                    let msg = e.to_string();
344                    if !msg.contains("does not support automatic fixing") {
345                        log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
346                    }
347                }
348            }
349        }
350
351        if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
352    }
353
354    /// Get the end position of a document
355    fn get_end_position(&self, text: &str) -> Position {
356        let mut line = 0u32;
357        let mut character = 0u32;
358
359        for ch in text.chars() {
360            if ch == '\n' {
361                line += 1;
362                character = 0;
363            } else {
364                character += 1;
365            }
366        }
367
368        Position { line, character }
369    }
370
371    /// Get code actions for diagnostics at a position
372    async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
373        let config_guard = self.config.read().await;
374        let lsp_config = config_guard.clone();
375        drop(config_guard);
376
377        // Resolve configuration for this specific file
378        let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
379            self.resolve_config_for_file(&file_path).await
380        } else {
381            // Fallback to global config for non-file URIs
382            (*self.rumdl_config.read().await).clone()
383        };
384
385        let all_rules = rules::all_rules(&rumdl_config);
386        let flavor = rumdl_config.markdown_flavor();
387
388        // Use the standard filter_rules function which respects config's disabled rules
389        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
390
391        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
392        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
393
394        match crate::lint(text, &filtered_rules, false, flavor) {
395            Ok(warnings) => {
396                let mut actions = Vec::new();
397                let mut fixable_count = 0;
398
399                for warning in &warnings {
400                    // Check if warning is within the requested range
401                    let warning_line = (warning.line.saturating_sub(1)) as u32;
402                    if warning_line >= range.start.line && warning_line <= range.end.line {
403                        // Get all code actions for this warning (fix + ignore actions)
404                        let mut warning_actions = warning_to_code_actions(warning, uri, text);
405                        actions.append(&mut warning_actions);
406
407                        if warning.fix.is_some() {
408                            fixable_count += 1;
409                        }
410                    }
411                }
412
413                // Add "Fix all" action if there are multiple fixable issues in range
414                if fixable_count > 1 {
415                    // Only apply fixes from fixable rules during "Fix all"
416                    // Unfixable rules provide warning-level fixes for individual Quick Fix actions
417                    let fixable_warnings: Vec<_> = warnings
418                        .iter()
419                        .filter(|w| {
420                            if let Some(rule_name) = &w.rule_name {
421                                filtered_rules
422                                    .iter()
423                                    .find(|r| r.name() == rule_name)
424                                    .map(|r| r.fix_capability() != FixCapability::Unfixable)
425                                    .unwrap_or(false)
426                            } else {
427                                false
428                            }
429                        })
430                        .cloned()
431                        .collect();
432
433                    // Count total fixable issues (excluding Unfixable rules)
434                    let total_fixable = fixable_warnings.len();
435
436                    if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
437                        && fixed_content != text
438                    {
439                        // Calculate proper end position
440                        let mut line = 0u32;
441                        let mut character = 0u32;
442                        for ch in text.chars() {
443                            if ch == '\n' {
444                                line += 1;
445                                character = 0;
446                            } else {
447                                character += 1;
448                            }
449                        }
450
451                        let fix_all_action = CodeAction {
452                            title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
453                            kind: Some(CodeActionKind::QUICKFIX),
454                            diagnostics: Some(Vec::new()),
455                            edit: Some(WorkspaceEdit {
456                                changes: Some(
457                                    [(
458                                        uri.clone(),
459                                        vec![TextEdit {
460                                            range: Range {
461                                                start: Position { line: 0, character: 0 },
462                                                end: Position { line, character },
463                                            },
464                                            new_text: fixed_content,
465                                        }],
466                                    )]
467                                    .into_iter()
468                                    .collect(),
469                                ),
470                                ..Default::default()
471                            }),
472                            command: None,
473                            is_preferred: Some(true),
474                            disabled: None,
475                            data: None,
476                        };
477
478                        // Insert at the beginning to make it prominent
479                        actions.insert(0, fix_all_action);
480                    }
481                }
482
483                Ok(actions)
484            }
485            Err(e) => {
486                log::error!("Failed to get code actions: {e}");
487                Ok(Vec::new())
488            }
489        }
490    }
491
492    /// Load or reload rumdl configuration from files
493    async fn load_configuration(&self, notify_client: bool) {
494        let config_guard = self.config.read().await;
495        let explicit_config_path = config_guard.config_path.clone();
496        drop(config_guard);
497
498        // Use the same discovery logic as CLI but with LSP-specific error handling
499        match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
500            Ok(sourced_config) => {
501                let loaded_files = sourced_config.loaded_files.clone();
502                *self.rumdl_config.write().await = sourced_config.into();
503
504                if !loaded_files.is_empty() {
505                    let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
506                    log::info!("{message}");
507                    if notify_client {
508                        self.client.log_message(MessageType::INFO, &message).await;
509                    }
510                } else {
511                    log::info!("Using default rumdl configuration (no config files found)");
512                }
513            }
514            Err(e) => {
515                let message = format!("Failed to load rumdl config: {e}");
516                log::warn!("{message}");
517                if notify_client {
518                    self.client.log_message(MessageType::WARNING, &message).await;
519                }
520                // Use default configuration
521                *self.rumdl_config.write().await = crate::config::Config::default();
522            }
523        }
524    }
525
526    /// Reload rumdl configuration from files (with client notification)
527    async fn reload_configuration(&self) {
528        self.load_configuration(true).await;
529    }
530
531    /// Load configuration for LSP - similar to CLI loading but returns Result
532    fn load_config_for_lsp(
533        config_path: Option<&str>,
534    ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
535        // Use the same configuration loading as the CLI
536        crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
537    }
538
539    /// Resolve configuration for a specific file
540    ///
541    /// This method searches for a configuration file starting from the file's directory
542    /// and walking up the directory tree until a workspace root is hit or a config is found.
543    ///
544    /// Results are cached to avoid repeated filesystem access.
545    pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
546        // Get the directory to start searching from
547        let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
548
549        // Check cache first
550        {
551            let cache = self.config_cache.read().await;
552            if let Some(entry) = cache.get(&search_dir) {
553                let source_owned: String; // ensure owned storage for logging
554                let source: &str = if entry.from_global_fallback {
555                    "global/user fallback"
556                } else if let Some(path) = &entry.config_file {
557                    source_owned = path.to_string_lossy().to_string();
558                    &source_owned
559                } else {
560                    "<unknown>"
561                };
562                log::debug!(
563                    "Config cache hit for directory: {} (loaded from: {})",
564                    search_dir.display(),
565                    source
566                );
567                return entry.config.clone();
568            }
569        }
570
571        // Cache miss - need to search for config
572        log::debug!(
573            "Config cache miss for directory: {}, searching for config...",
574            search_dir.display()
575        );
576
577        // Try to find workspace root for this file
578        let workspace_root = {
579            let workspace_roots = self.workspace_roots.read().await;
580            workspace_roots
581                .iter()
582                .find(|root| search_dir.starts_with(root))
583                .map(|p| p.to_path_buf())
584        };
585
586        // Search upward from the file's directory
587        let mut current_dir = search_dir.clone();
588        let mut found_config: Option<(Config, Option<PathBuf>)> = None;
589
590        loop {
591            // Try to find a config file in the current directory
592            const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
593
594            for config_file_name in CONFIG_FILES {
595                let config_path = current_dir.join(config_file_name);
596                if config_path.exists() {
597                    // For pyproject.toml, verify it contains [tool.rumdl] section (same as CLI)
598                    if *config_file_name == "pyproject.toml" {
599                        if let Ok(content) = std::fs::read_to_string(&config_path) {
600                            if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
601                                log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
602                            } else {
603                                log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
604                                continue;
605                            }
606                        } else {
607                            log::warn!("Failed to read pyproject.toml: {}", config_path.display());
608                            continue;
609                        }
610                    } else {
611                        log::debug!("Found config file: {}", config_path.display());
612                    }
613
614                    // Load the config
615                    if let Some(config_path_str) = config_path.to_str() {
616                        if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
617                            found_config = Some((sourced.into(), Some(config_path)));
618                            break;
619                        }
620                    } else {
621                        log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
622                    }
623                }
624            }
625
626            if found_config.is_some() {
627                break;
628            }
629
630            // Check if we've hit a workspace root
631            if let Some(ref root) = workspace_root
632                && &current_dir == root
633            {
634                log::debug!("Hit workspace root without finding config: {}", root.display());
635                break;
636            }
637
638            // Move up to parent directory
639            if let Some(parent) = current_dir.parent() {
640                current_dir = parent.to_path_buf();
641            } else {
642                // Hit filesystem root
643                break;
644            }
645        }
646
647        // Use found config or fall back to global/user config loaded at initialization
648        let (config, config_file) = if let Some((cfg, path)) = found_config {
649            (cfg, path)
650        } else {
651            log::debug!("No project config found; using global/user fallback config");
652            let fallback = self.rumdl_config.read().await.clone();
653            (fallback, None)
654        };
655
656        // Cache the result
657        let from_global = config_file.is_none();
658        let entry = ConfigCacheEntry {
659            config: config.clone(),
660            config_file,
661            from_global_fallback: from_global,
662        };
663
664        self.config_cache.write().await.insert(search_dir, entry);
665
666        config
667    }
668}
669
670#[tower_lsp::async_trait]
671impl LanguageServer for RumdlLanguageServer {
672    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
673        log::info!("Initializing rumdl Language Server");
674
675        // Parse client capabilities and configuration
676        if let Some(options) = params.initialization_options
677            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
678        {
679            *self.config.write().await = config;
680        }
681
682        // Extract and store workspace roots
683        let mut roots = Vec::new();
684        if let Some(workspace_folders) = params.workspace_folders {
685            for folder in workspace_folders {
686                if let Ok(path) = folder.uri.to_file_path() {
687                    log::info!("Workspace root: {}", path.display());
688                    roots.push(path);
689                }
690            }
691        } else if let Some(root_uri) = params.root_uri
692            && let Ok(path) = root_uri.to_file_path()
693        {
694            log::info!("Workspace root: {}", path.display());
695            roots.push(path);
696        }
697        *self.workspace_roots.write().await = roots;
698
699        // Load rumdl configuration with auto-discovery (fallback/default)
700        self.load_configuration(false).await;
701
702        Ok(InitializeResult {
703            capabilities: ServerCapabilities {
704                text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
705                    open_close: Some(true),
706                    change: Some(TextDocumentSyncKind::FULL),
707                    will_save: Some(false),
708                    will_save_wait_until: Some(true),
709                    save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
710                        include_text: Some(false),
711                    })),
712                })),
713                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
714                document_formatting_provider: Some(OneOf::Left(true)),
715                document_range_formatting_provider: Some(OneOf::Left(true)),
716                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
717                    identifier: Some("rumdl".to_string()),
718                    inter_file_dependencies: false,
719                    workspace_diagnostics: false,
720                    work_done_progress_options: WorkDoneProgressOptions::default(),
721                })),
722                workspace: Some(WorkspaceServerCapabilities {
723                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
724                        supported: Some(true),
725                        change_notifications: Some(OneOf::Left(true)),
726                    }),
727                    file_operations: None,
728                }),
729                ..Default::default()
730            },
731            server_info: Some(ServerInfo {
732                name: "rumdl".to_string(),
733                version: Some(env!("CARGO_PKG_VERSION").to_string()),
734            }),
735        })
736    }
737
738    async fn initialized(&self, _: InitializedParams) {
739        let version = env!("CARGO_PKG_VERSION");
740
741        // Get binary path and build time
742        let (binary_path, build_time) = std::env::current_exe()
743            .ok()
744            .map(|path| {
745                let path_str = path.to_str().unwrap_or("unknown").to_string();
746                let build_time = std::fs::metadata(&path)
747                    .ok()
748                    .and_then(|metadata| metadata.modified().ok())
749                    .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
750                    .and_then(|duration| {
751                        let secs = duration.as_secs();
752                        chrono::DateTime::from_timestamp(secs as i64, 0)
753                            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
754                    })
755                    .unwrap_or_else(|| "unknown".to_string());
756                (path_str, build_time)
757            })
758            .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
759
760        let working_dir = std::env::current_dir()
761            .ok()
762            .and_then(|p| p.to_str().map(|s| s.to_string()))
763            .unwrap_or_else(|| "unknown".to_string());
764
765        log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
766        log::info!("Working directory: {working_dir}");
767
768        self.client
769            .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
770            .await;
771    }
772
773    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
774        // Update workspace roots
775        let mut roots = self.workspace_roots.write().await;
776
777        // Remove deleted workspace folders
778        for removed in &params.event.removed {
779            if let Ok(path) = removed.uri.to_file_path() {
780                roots.retain(|r| r != &path);
781                log::info!("Removed workspace root: {}", path.display());
782            }
783        }
784
785        // Add new workspace folders
786        for added in &params.event.added {
787            if let Ok(path) = added.uri.to_file_path()
788                && !roots.contains(&path)
789            {
790                log::info!("Added workspace root: {}", path.display());
791                roots.push(path);
792            }
793        }
794        drop(roots);
795
796        // Clear config cache as workspace structure changed
797        self.config_cache.write().await.clear();
798
799        // Reload fallback configuration
800        self.reload_configuration().await;
801    }
802
803    async fn shutdown(&self) -> JsonRpcResult<()> {
804        log::info!("Shutting down rumdl Language Server");
805        Ok(())
806    }
807
808    async fn did_open(&self, params: DidOpenTextDocumentParams) {
809        let uri = params.text_document.uri;
810        let text = params.text_document.text;
811        let version = params.text_document.version;
812
813        let entry = DocumentEntry {
814            content: text.clone(),
815            version: Some(version),
816            from_disk: false,
817        };
818        self.documents.write().await.insert(uri.clone(), entry);
819
820        self.update_diagnostics(uri, text).await;
821    }
822
823    async fn did_change(&self, params: DidChangeTextDocumentParams) {
824        let uri = params.text_document.uri;
825        let version = params.text_document.version;
826
827        if let Some(change) = params.content_changes.into_iter().next() {
828            let text = change.text;
829
830            let entry = DocumentEntry {
831                content: text.clone(),
832                version: Some(version),
833                from_disk: false,
834            };
835            self.documents.write().await.insert(uri.clone(), entry);
836
837            self.update_diagnostics(uri, text).await;
838        }
839    }
840
841    async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
842        let config_guard = self.config.read().await;
843        let enable_auto_fix = config_guard.enable_auto_fix;
844        drop(config_guard);
845
846        if !enable_auto_fix {
847            return Ok(None);
848        }
849
850        // Get the current document content
851        let Some(text) = self.get_document_content(&params.text_document.uri).await else {
852            return Ok(None);
853        };
854
855        // Apply all fixes
856        match self.apply_all_fixes(&params.text_document.uri, &text).await {
857            Ok(Some(fixed_text)) => {
858                // Return a single edit that replaces the entire document
859                Ok(Some(vec![TextEdit {
860                    range: Range {
861                        start: Position { line: 0, character: 0 },
862                        end: self.get_end_position(&text),
863                    },
864                    new_text: fixed_text,
865                }]))
866            }
867            Ok(None) => Ok(None),
868            Err(e) => {
869                log::error!("Failed to generate fixes in will_save_wait_until: {e}");
870                Ok(None)
871            }
872        }
873    }
874
875    async fn did_save(&self, params: DidSaveTextDocumentParams) {
876        // Re-lint the document after save
877        // Note: Auto-fixing is now handled by will_save_wait_until which runs before the save
878        if let Some(entry) = self.documents.read().await.get(&params.text_document.uri) {
879            self.update_diagnostics(params.text_document.uri, entry.content.clone())
880                .await;
881        }
882    }
883
884    async fn did_close(&self, params: DidCloseTextDocumentParams) {
885        // Remove document from storage
886        self.documents.write().await.remove(&params.text_document.uri);
887
888        // Clear diagnostics
889        self.client
890            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
891            .await;
892    }
893
894    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
895        // Check if any of the changed files are config files
896        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
897
898        for change in &params.changes {
899            if let Ok(path) = change.uri.to_file_path()
900                && let Some(file_name) = path.file_name().and_then(|f| f.to_str())
901                && CONFIG_FILES.contains(&file_name)
902            {
903                log::info!("Config file changed: {}, invalidating config cache", path.display());
904
905                // Invalidate all cache entries that were loaded from this config file
906                let mut cache = self.config_cache.write().await;
907                cache.retain(|_, entry| {
908                    if let Some(config_file) = &entry.config_file {
909                        config_file != &path
910                    } else {
911                        true
912                    }
913                });
914
915                // Also reload the global fallback configuration
916                drop(cache);
917                self.reload_configuration().await;
918
919                // Re-lint all open documents
920                // First collect URIs and content to avoid holding lock during async operations
921                let docs_to_update: Vec<(Url, String)> = {
922                    let docs = self.documents.read().await;
923                    docs.iter()
924                        .filter(|(_, entry)| !entry.from_disk)
925                        .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
926                        .collect()
927                };
928
929                // Now update diagnostics without holding the lock
930                for (uri, text) in docs_to_update {
931                    self.update_diagnostics(uri, text).await;
932                }
933
934                break;
935            }
936        }
937    }
938
939    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
940        let uri = params.text_document.uri;
941        let range = params.range;
942
943        if let Some(text) = self.get_document_content(&uri).await {
944            match self.get_code_actions(&uri, &text, range).await {
945                Ok(actions) => {
946                    let response: Vec<CodeActionOrCommand> =
947                        actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
948                    Ok(Some(response))
949                }
950                Err(e) => {
951                    log::error!("Failed to get code actions: {e}");
952                    Ok(None)
953                }
954            }
955        } else {
956            Ok(None)
957        }
958    }
959
960    async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
961        // For markdown linting, we format the entire document because:
962        // 1. Many markdown rules have document-wide implications (e.g., heading hierarchy, list consistency)
963        // 2. Fixes often need surrounding context to be applied correctly
964        // 3. This approach is common among linters (ESLint, rustfmt, etc. do similar)
965        log::debug!(
966            "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
967            params.range
968        );
969
970        let formatting_params = DocumentFormattingParams {
971            text_document: params.text_document,
972            options: params.options,
973            work_done_progress_params: params.work_done_progress_params,
974        };
975
976        self.formatting(formatting_params).await
977    }
978
979    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
980        let uri = params.text_document.uri;
981
982        log::debug!("Formatting request for: {uri}");
983
984        if let Some(text) = self.get_document_content(&uri).await {
985            // Get config with LSP overrides
986            let config_guard = self.config.read().await;
987            let lsp_config = config_guard.clone();
988            drop(config_guard);
989
990            // Resolve configuration for this specific file
991            let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
992                self.resolve_config_for_file(&file_path).await
993            } else {
994                // Fallback to global config for non-file URIs
995                self.rumdl_config.read().await.clone()
996            };
997
998            let all_rules = rules::all_rules(&rumdl_config);
999            let flavor = rumdl_config.markdown_flavor();
1000
1001            // Use the standard filter_rules function which respects config's disabled rules
1002            let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1003
1004            // Apply LSP config overrides
1005            filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1006
1007            // Use warning fixes for all rules
1008            match crate::lint(&text, &filtered_rules, false, flavor) {
1009                Ok(warnings) => {
1010                    log::debug!(
1011                        "Found {} warnings, {} with fixes",
1012                        warnings.len(),
1013                        warnings.iter().filter(|w| w.fix.is_some()).count()
1014                    );
1015
1016                    let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1017                    if has_fixes {
1018                        // Only apply fixes from fixable rules during formatting
1019                        // Unfixable rules provide warning-level fixes for Quick Fix actions,
1020                        // but should not be applied during bulk format operations
1021                        let fixable_warnings: Vec<_> = warnings
1022                            .iter()
1023                            .filter(|w| {
1024                                if let Some(rule_name) = &w.rule_name {
1025                                    filtered_rules
1026                                        .iter()
1027                                        .find(|r| r.name() == rule_name)
1028                                        .map(|r| r.fix_capability() != FixCapability::Unfixable)
1029                                        .unwrap_or(false)
1030                                } else {
1031                                    false
1032                                }
1033                            })
1034                            .cloned()
1035                            .collect();
1036
1037                        match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1038                            Ok(fixed_content) => {
1039                                if fixed_content != text {
1040                                    log::debug!("Returning formatting edits");
1041                                    let end_position = self.get_end_position(&text);
1042                                    let edit = TextEdit {
1043                                        range: Range {
1044                                            start: Position { line: 0, character: 0 },
1045                                            end: end_position,
1046                                        },
1047                                        new_text: fixed_content,
1048                                    };
1049                                    return Ok(Some(vec![edit]));
1050                                }
1051                            }
1052                            Err(e) => {
1053                                log::error!("Failed to apply fixes: {e}");
1054                            }
1055                        }
1056                    }
1057                    Ok(Some(Vec::new()))
1058                }
1059                Err(e) => {
1060                    log::error!("Failed to format document: {e}");
1061                    Ok(Some(Vec::new()))
1062                }
1063            }
1064        } else {
1065            log::warn!("Document not found: {uri}");
1066            Ok(None)
1067        }
1068    }
1069
1070    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1071        let uri = params.text_document.uri;
1072
1073        if let Some(text) = self.get_document_content(&uri).await {
1074            match self.lint_document(&uri, &text).await {
1075                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1076                    RelatedFullDocumentDiagnosticReport {
1077                        related_documents: None,
1078                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
1079                            result_id: None,
1080                            items: diagnostics,
1081                        },
1082                    },
1083                ))),
1084                Err(e) => {
1085                    log::error!("Failed to get diagnostics: {e}");
1086                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1087                        RelatedFullDocumentDiagnosticReport {
1088                            related_documents: None,
1089                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
1090                                result_id: None,
1091                                items: Vec::new(),
1092                            },
1093                        },
1094                    )))
1095                }
1096            }
1097        } else {
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}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use crate::rule::LintWarning;
1115    use tower_lsp::LspService;
1116
1117    fn create_test_server() -> RumdlLanguageServer {
1118        let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1119        service.inner().clone()
1120    }
1121
1122    #[tokio::test]
1123    async fn test_server_creation() {
1124        let server = create_test_server();
1125
1126        // Verify default configuration
1127        let config = server.config.read().await;
1128        assert!(config.enable_linting);
1129        assert!(!config.enable_auto_fix);
1130    }
1131
1132    #[tokio::test]
1133    async fn test_lint_document() {
1134        let server = create_test_server();
1135
1136        // Test linting with a simple markdown document
1137        let uri = Url::parse("file:///test.md").unwrap();
1138        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1139
1140        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1141
1142        // Should find trailing spaces violations
1143        assert!(!diagnostics.is_empty());
1144        assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1145    }
1146
1147    #[tokio::test]
1148    async fn test_lint_document_disabled() {
1149        let server = create_test_server();
1150
1151        // Disable linting
1152        server.config.write().await.enable_linting = false;
1153
1154        let uri = Url::parse("file:///test.md").unwrap();
1155        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1156
1157        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1158
1159        // Should return empty diagnostics when disabled
1160        assert!(diagnostics.is_empty());
1161    }
1162
1163    #[tokio::test]
1164    async fn test_get_code_actions() {
1165        let server = create_test_server();
1166
1167        let uri = Url::parse("file:///test.md").unwrap();
1168        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1169
1170        // Create a range covering the whole document
1171        let range = Range {
1172            start: Position { line: 0, character: 0 },
1173            end: Position { line: 3, character: 21 },
1174        };
1175
1176        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1177
1178        // Should have code actions for fixing trailing spaces
1179        assert!(!actions.is_empty());
1180        assert!(actions.iter().any(|a| a.title.contains("trailing")));
1181    }
1182
1183    #[tokio::test]
1184    async fn test_get_code_actions_outside_range() {
1185        let server = create_test_server();
1186
1187        let uri = Url::parse("file:///test.md").unwrap();
1188        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1189
1190        // Create a range that doesn't cover the violations
1191        let range = Range {
1192            start: Position { line: 0, character: 0 },
1193            end: Position { line: 0, character: 6 },
1194        };
1195
1196        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1197
1198        // Should have no code actions for this range
1199        assert!(actions.is_empty());
1200    }
1201
1202    #[tokio::test]
1203    async fn test_document_storage() {
1204        let server = create_test_server();
1205
1206        let uri = Url::parse("file:///test.md").unwrap();
1207        let text = "# Test Document";
1208
1209        // Store document
1210        let entry = DocumentEntry {
1211            content: text.to_string(),
1212            version: Some(1),
1213            from_disk: false,
1214        };
1215        server.documents.write().await.insert(uri.clone(), entry);
1216
1217        // Verify storage
1218        let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1219        assert_eq!(stored, Some(text.to_string()));
1220
1221        // Remove document
1222        server.documents.write().await.remove(&uri);
1223
1224        // Verify removal
1225        let stored = server.documents.read().await.get(&uri).cloned();
1226        assert_eq!(stored, None);
1227    }
1228
1229    #[tokio::test]
1230    async fn test_configuration_loading() {
1231        let server = create_test_server();
1232
1233        // Load configuration with auto-discovery
1234        server.load_configuration(false).await;
1235
1236        // Verify configuration was loaded successfully
1237        // The config could be from: .rumdl.toml, pyproject.toml, .markdownlint.json, or default
1238        let rumdl_config = server.rumdl_config.read().await;
1239        // The loaded config is valid regardless of source
1240        drop(rumdl_config); // Just verify we can access it without panic
1241    }
1242
1243    #[tokio::test]
1244    async fn test_load_config_for_lsp() {
1245        // Test with no config file
1246        let result = RumdlLanguageServer::load_config_for_lsp(None);
1247        assert!(result.is_ok());
1248
1249        // Test with non-existent config file
1250        let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1251        assert!(result.is_err());
1252    }
1253
1254    #[tokio::test]
1255    async fn test_warning_conversion() {
1256        let warning = LintWarning {
1257            message: "Test warning".to_string(),
1258            line: 1,
1259            column: 1,
1260            end_line: 1,
1261            end_column: 10,
1262            severity: crate::rule::Severity::Warning,
1263            fix: None,
1264            rule_name: Some("MD001".to_string()),
1265        };
1266
1267        // Test diagnostic conversion
1268        let diagnostic = warning_to_diagnostic(&warning);
1269        assert_eq!(diagnostic.message, "Test warning");
1270        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1271        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1272
1273        // Test code action conversion (no fix, but should have ignore action)
1274        let uri = Url::parse("file:///test.md").unwrap();
1275        let actions = warning_to_code_actions(&warning, &uri, "Test content");
1276        // Should have 1 action: ignore-line (no fix available)
1277        assert_eq!(actions.len(), 1);
1278        assert_eq!(actions[0].title, "Ignore MD001 for this line");
1279    }
1280
1281    #[tokio::test]
1282    async fn test_multiple_documents() {
1283        let server = create_test_server();
1284
1285        let uri1 = Url::parse("file:///test1.md").unwrap();
1286        let uri2 = Url::parse("file:///test2.md").unwrap();
1287        let text1 = "# Document 1";
1288        let text2 = "# Document 2";
1289
1290        // Store multiple documents
1291        {
1292            let mut docs = server.documents.write().await;
1293            let entry1 = DocumentEntry {
1294                content: text1.to_string(),
1295                version: Some(1),
1296                from_disk: false,
1297            };
1298            let entry2 = DocumentEntry {
1299                content: text2.to_string(),
1300                version: Some(1),
1301                from_disk: false,
1302            };
1303            docs.insert(uri1.clone(), entry1);
1304            docs.insert(uri2.clone(), entry2);
1305        }
1306
1307        // Verify both are stored
1308        let docs = server.documents.read().await;
1309        assert_eq!(docs.len(), 2);
1310        assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1311        assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1312    }
1313
1314    #[tokio::test]
1315    async fn test_auto_fix_on_save() {
1316        let server = create_test_server();
1317
1318        // Enable auto-fix
1319        {
1320            let mut config = server.config.write().await;
1321            config.enable_auto_fix = true;
1322        }
1323
1324        let uri = Url::parse("file:///test.md").unwrap();
1325        let text = "#Heading without space"; // MD018 violation
1326
1327        // Store document
1328        let entry = DocumentEntry {
1329            content: text.to_string(),
1330            version: Some(1),
1331            from_disk: false,
1332        };
1333        server.documents.write().await.insert(uri.clone(), entry);
1334
1335        // Test apply_all_fixes
1336        let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1337        assert!(fixed.is_some());
1338        // MD018 adds space, MD047 adds trailing newline
1339        assert_eq!(fixed.unwrap(), "# Heading without space\n");
1340    }
1341
1342    #[tokio::test]
1343    async fn test_get_end_position() {
1344        let server = create_test_server();
1345
1346        // Single line
1347        let pos = server.get_end_position("Hello");
1348        assert_eq!(pos.line, 0);
1349        assert_eq!(pos.character, 5);
1350
1351        // Multiple lines
1352        let pos = server.get_end_position("Hello\nWorld\nTest");
1353        assert_eq!(pos.line, 2);
1354        assert_eq!(pos.character, 4);
1355
1356        // Empty string
1357        let pos = server.get_end_position("");
1358        assert_eq!(pos.line, 0);
1359        assert_eq!(pos.character, 0);
1360
1361        // Ends with newline - position should be at start of next line
1362        let pos = server.get_end_position("Hello\n");
1363        assert_eq!(pos.line, 1);
1364        assert_eq!(pos.character, 0);
1365    }
1366
1367    #[tokio::test]
1368    async fn test_empty_document_handling() {
1369        let server = create_test_server();
1370
1371        let uri = Url::parse("file:///empty.md").unwrap();
1372        let text = "";
1373
1374        // Test linting empty document
1375        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1376        assert!(diagnostics.is_empty());
1377
1378        // Test code actions on empty document
1379        let range = Range {
1380            start: Position { line: 0, character: 0 },
1381            end: Position { line: 0, character: 0 },
1382        };
1383        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1384        assert!(actions.is_empty());
1385    }
1386
1387    #[tokio::test]
1388    async fn test_config_update() {
1389        let server = create_test_server();
1390
1391        // Update config
1392        {
1393            let mut config = server.config.write().await;
1394            config.enable_auto_fix = true;
1395            config.config_path = Some("/custom/path.toml".to_string());
1396        }
1397
1398        // Verify update
1399        let config = server.config.read().await;
1400        assert!(config.enable_auto_fix);
1401        assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1402    }
1403
1404    #[tokio::test]
1405    async fn test_document_formatting() {
1406        let server = create_test_server();
1407        let uri = Url::parse("file:///test.md").unwrap();
1408        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1409
1410        // Store document
1411        let entry = DocumentEntry {
1412            content: text.to_string(),
1413            version: Some(1),
1414            from_disk: false,
1415        };
1416        server.documents.write().await.insert(uri.clone(), entry);
1417
1418        // Create formatting params
1419        let params = DocumentFormattingParams {
1420            text_document: TextDocumentIdentifier { uri: uri.clone() },
1421            options: FormattingOptions {
1422                tab_size: 4,
1423                insert_spaces: true,
1424                properties: HashMap::new(),
1425                trim_trailing_whitespace: Some(true),
1426                insert_final_newline: Some(true),
1427                trim_final_newlines: Some(true),
1428            },
1429            work_done_progress_params: WorkDoneProgressParams::default(),
1430        };
1431
1432        // Call formatting
1433        let result = server.formatting(params).await.unwrap();
1434
1435        // Should return text edits that fix the trailing spaces
1436        assert!(result.is_some());
1437        let edits = result.unwrap();
1438        assert!(!edits.is_empty());
1439
1440        // The new text should have trailing spaces removed
1441        let edit = &edits[0];
1442        // The formatted text should have the trailing spaces removed from the middle line
1443        // and a final newline added
1444        let expected = "# Test\n\nThis is a test  \nWith trailing spaces\n";
1445        assert_eq!(edit.new_text, expected);
1446    }
1447
1448    /// Test that Unfixable rules are excluded from formatting/Fix All but available for Quick Fix
1449    /// Regression test for issue #158: formatting deleted HTML img tags
1450    #[tokio::test]
1451    async fn test_unfixable_rules_excluded_from_formatting() {
1452        let server = create_test_server();
1453        let uri = Url::parse("file:///test.md").unwrap();
1454
1455        // Content with both fixable (trailing spaces) and unfixable (HTML) issues
1456        let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces  ";
1457
1458        // Store document
1459        let entry = DocumentEntry {
1460            content: text.to_string(),
1461            version: Some(1),
1462            from_disk: false,
1463        };
1464        server.documents.write().await.insert(uri.clone(), entry);
1465
1466        // Test 1: Formatting should preserve HTML (Unfixable) but fix trailing spaces (fixable)
1467        let format_params = DocumentFormattingParams {
1468            text_document: TextDocumentIdentifier { uri: uri.clone() },
1469            options: FormattingOptions {
1470                tab_size: 4,
1471                insert_spaces: true,
1472                properties: HashMap::new(),
1473                trim_trailing_whitespace: Some(true),
1474                insert_final_newline: Some(true),
1475                trim_final_newlines: Some(true),
1476            },
1477            work_done_progress_params: WorkDoneProgressParams::default(),
1478        };
1479
1480        let format_result = server.formatting(format_params).await.unwrap();
1481        assert!(format_result.is_some(), "Should return formatting edits");
1482
1483        let edits = format_result.unwrap();
1484        assert!(!edits.is_empty(), "Should have formatting edits");
1485
1486        let formatted = &edits[0].new_text;
1487        assert!(
1488            formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
1489            "HTML should be preserved during formatting (Unfixable rule)"
1490        );
1491        assert!(
1492            !formatted.contains("spaces  "),
1493            "Trailing spaces should be removed (fixable rule)"
1494        );
1495
1496        // Test 2: Quick Fix actions should still be available for Unfixable rules
1497        let range = Range {
1498            start: Position { line: 0, character: 0 },
1499            end: Position { line: 10, character: 0 },
1500        };
1501
1502        let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
1503
1504        // Should have individual Quick Fix actions for each warning
1505        let html_fix_actions: Vec<_> = code_actions
1506            .iter()
1507            .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
1508            .collect();
1509
1510        assert!(
1511            !html_fix_actions.is_empty(),
1512            "Quick Fix actions should be available for HTML (Unfixable rules)"
1513        );
1514
1515        // Test 3: "Fix All" action should exclude Unfixable rules
1516        let fix_all_actions: Vec<_> = code_actions
1517            .iter()
1518            .filter(|action| action.title.contains("Fix all"))
1519            .collect();
1520
1521        if let Some(fix_all_action) = fix_all_actions.first()
1522            && let Some(ref edit) = fix_all_action.edit
1523            && let Some(ref changes) = edit.changes
1524            && let Some(text_edits) = changes.get(&uri)
1525            && let Some(text_edit) = text_edits.first()
1526        {
1527            let fixed_all = &text_edit.new_text;
1528            assert!(
1529                fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
1530                "Fix All should preserve HTML (Unfixable rules)"
1531            );
1532            assert!(
1533                !fixed_all.contains("spaces  "),
1534                "Fix All should remove trailing spaces (fixable rules)"
1535            );
1536        }
1537    }
1538
1539    /// Test that resolve_config_for_file() finds the correct config in multi-root workspace
1540    #[tokio::test]
1541    async fn test_resolve_config_for_file_multi_root() {
1542        use std::fs;
1543        use tempfile::tempdir;
1544
1545        let temp_dir = tempdir().unwrap();
1546        let temp_path = temp_dir.path();
1547
1548        // Setup project A with line_length=60
1549        let project_a = temp_path.join("project_a");
1550        let project_a_docs = project_a.join("docs");
1551        fs::create_dir_all(&project_a_docs).unwrap();
1552
1553        let config_a = project_a.join(".rumdl.toml");
1554        fs::write(
1555            &config_a,
1556            r#"
1557[global]
1558
1559[MD013]
1560line_length = 60
1561"#,
1562        )
1563        .unwrap();
1564
1565        // Setup project B with line_length=120
1566        let project_b = temp_path.join("project_b");
1567        fs::create_dir(&project_b).unwrap();
1568
1569        let config_b = project_b.join(".rumdl.toml");
1570        fs::write(
1571            &config_b,
1572            r#"
1573[global]
1574
1575[MD013]
1576line_length = 120
1577"#,
1578        )
1579        .unwrap();
1580
1581        // Create LSP server and initialize with workspace roots
1582        let server = create_test_server();
1583
1584        // Set workspace roots
1585        {
1586            let mut roots = server.workspace_roots.write().await;
1587            roots.push(project_a.clone());
1588            roots.push(project_b.clone());
1589        }
1590
1591        // Test file in project A
1592        let file_a = project_a_docs.join("test.md");
1593        fs::write(&file_a, "# Test A\n").unwrap();
1594
1595        let config_for_a = server.resolve_config_for_file(&file_a).await;
1596        let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1597        assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1598
1599        // Test file in project B
1600        let file_b = project_b.join("test.md");
1601        fs::write(&file_b, "# Test B\n").unwrap();
1602
1603        let config_for_b = server.resolve_config_for_file(&file_b).await;
1604        let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1605        assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1606    }
1607
1608    /// Test that config resolution respects workspace root boundaries
1609    #[tokio::test]
1610    async fn test_config_resolution_respects_workspace_boundaries() {
1611        use std::fs;
1612        use tempfile::tempdir;
1613
1614        let temp_dir = tempdir().unwrap();
1615        let temp_path = temp_dir.path();
1616
1617        // Create parent config that should NOT be used
1618        let parent_config = temp_path.join(".rumdl.toml");
1619        fs::write(
1620            &parent_config,
1621            r#"
1622[global]
1623
1624[MD013]
1625line_length = 80
1626"#,
1627        )
1628        .unwrap();
1629
1630        // Create workspace root with its own config
1631        let workspace_root = temp_path.join("workspace");
1632        let workspace_subdir = workspace_root.join("subdir");
1633        fs::create_dir_all(&workspace_subdir).unwrap();
1634
1635        let workspace_config = workspace_root.join(".rumdl.toml");
1636        fs::write(
1637            &workspace_config,
1638            r#"
1639[global]
1640
1641[MD013]
1642line_length = 100
1643"#,
1644        )
1645        .unwrap();
1646
1647        let server = create_test_server();
1648
1649        // Register workspace_root as a workspace root
1650        {
1651            let mut roots = server.workspace_roots.write().await;
1652            roots.push(workspace_root.clone());
1653        }
1654
1655        // Test file deep in subdirectory
1656        let test_file = workspace_subdir.join("deep").join("test.md");
1657        fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1658        fs::write(&test_file, "# Test\n").unwrap();
1659
1660        let config = server.resolve_config_for_file(&test_file).await;
1661        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1662
1663        // Should find workspace_root/.rumdl.toml (100), NOT parent config (80)
1664        assert_eq!(
1665            line_length,
1666            Some(100),
1667            "Should find workspace config, not parent config outside workspace"
1668        );
1669    }
1670
1671    /// Test that config cache works (cache hit scenario)
1672    #[tokio::test]
1673    async fn test_config_cache_hit() {
1674        use std::fs;
1675        use tempfile::tempdir;
1676
1677        let temp_dir = tempdir().unwrap();
1678        let temp_path = temp_dir.path();
1679
1680        let project = temp_path.join("project");
1681        fs::create_dir(&project).unwrap();
1682
1683        let config_file = project.join(".rumdl.toml");
1684        fs::write(
1685            &config_file,
1686            r#"
1687[global]
1688
1689[MD013]
1690line_length = 75
1691"#,
1692        )
1693        .unwrap();
1694
1695        let server = create_test_server();
1696        {
1697            let mut roots = server.workspace_roots.write().await;
1698            roots.push(project.clone());
1699        }
1700
1701        let test_file = project.join("test.md");
1702        fs::write(&test_file, "# Test\n").unwrap();
1703
1704        // First call - cache miss
1705        let config1 = server.resolve_config_for_file(&test_file).await;
1706        let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1707        assert_eq!(line_length1, Some(75));
1708
1709        // Verify cache was populated
1710        {
1711            let cache = server.config_cache.read().await;
1712            let search_dir = test_file.parent().unwrap();
1713            assert!(
1714                cache.contains_key(search_dir),
1715                "Cache should be populated after first call"
1716            );
1717        }
1718
1719        // Second call - cache hit (should return same config without filesystem access)
1720        let config2 = server.resolve_config_for_file(&test_file).await;
1721        let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1722        assert_eq!(line_length2, Some(75));
1723    }
1724
1725    /// Test nested directory config search (file searches upward)
1726    #[tokio::test]
1727    async fn test_nested_directory_config_search() {
1728        use std::fs;
1729        use tempfile::tempdir;
1730
1731        let temp_dir = tempdir().unwrap();
1732        let temp_path = temp_dir.path();
1733
1734        let project = temp_path.join("project");
1735        fs::create_dir(&project).unwrap();
1736
1737        // Config at project root
1738        let config = project.join(".rumdl.toml");
1739        fs::write(
1740            &config,
1741            r#"
1742[global]
1743
1744[MD013]
1745line_length = 110
1746"#,
1747        )
1748        .unwrap();
1749
1750        // File deep in nested structure
1751        let deep_dir = project.join("src").join("docs").join("guides");
1752        fs::create_dir_all(&deep_dir).unwrap();
1753        let deep_file = deep_dir.join("test.md");
1754        fs::write(&deep_file, "# Test\n").unwrap();
1755
1756        let server = create_test_server();
1757        {
1758            let mut roots = server.workspace_roots.write().await;
1759            roots.push(project.clone());
1760        }
1761
1762        let resolved_config = server.resolve_config_for_file(&deep_file).await;
1763        let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1764
1765        assert_eq!(
1766            line_length,
1767            Some(110),
1768            "Should find config by searching upward from deep directory"
1769        );
1770    }
1771
1772    /// Test fallback to default config when no config file found
1773    #[tokio::test]
1774    async fn test_fallback_to_default_config() {
1775        use std::fs;
1776        use tempfile::tempdir;
1777
1778        let temp_dir = tempdir().unwrap();
1779        let temp_path = temp_dir.path();
1780
1781        let project = temp_path.join("project");
1782        fs::create_dir(&project).unwrap();
1783
1784        // No config file created!
1785
1786        let test_file = project.join("test.md");
1787        fs::write(&test_file, "# Test\n").unwrap();
1788
1789        let server = create_test_server();
1790        {
1791            let mut roots = server.workspace_roots.write().await;
1792            roots.push(project.clone());
1793        }
1794
1795        let config = server.resolve_config_for_file(&test_file).await;
1796
1797        // Default global line_length is 80
1798        assert_eq!(
1799            config.global.line_length, 80,
1800            "Should fall back to default config when no config file found"
1801        );
1802    }
1803
1804    /// Test config priority: closer config wins over parent config
1805    #[tokio::test]
1806    async fn test_config_priority_closer_wins() {
1807        use std::fs;
1808        use tempfile::tempdir;
1809
1810        let temp_dir = tempdir().unwrap();
1811        let temp_path = temp_dir.path();
1812
1813        let project = temp_path.join("project");
1814        fs::create_dir(&project).unwrap();
1815
1816        // Parent config
1817        let parent_config = project.join(".rumdl.toml");
1818        fs::write(
1819            &parent_config,
1820            r#"
1821[global]
1822
1823[MD013]
1824line_length = 100
1825"#,
1826        )
1827        .unwrap();
1828
1829        // Subdirectory with its own config (should override parent)
1830        let subdir = project.join("subdir");
1831        fs::create_dir(&subdir).unwrap();
1832
1833        let subdir_config = subdir.join(".rumdl.toml");
1834        fs::write(
1835            &subdir_config,
1836            r#"
1837[global]
1838
1839[MD013]
1840line_length = 50
1841"#,
1842        )
1843        .unwrap();
1844
1845        let server = create_test_server();
1846        {
1847            let mut roots = server.workspace_roots.write().await;
1848            roots.push(project.clone());
1849        }
1850
1851        // File in subdirectory
1852        let test_file = subdir.join("test.md");
1853        fs::write(&test_file, "# Test\n").unwrap();
1854
1855        let config = server.resolve_config_for_file(&test_file).await;
1856        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1857
1858        assert_eq!(
1859            line_length,
1860            Some(50),
1861            "Closer config (subdir) should override parent config"
1862        );
1863    }
1864
1865    /// Test for issue #131: LSP should skip pyproject.toml without [tool.rumdl] section
1866    ///
1867    /// This test verifies the fix in resolve_config_for_file() at lines 574-585 that checks
1868    /// for [tool.rumdl] presence before loading pyproject.toml. The fix ensures LSP behavior
1869    /// matches CLI behavior.
1870    #[tokio::test]
1871    async fn test_issue_131_pyproject_without_rumdl_section() {
1872        use std::fs;
1873        use tempfile::tempdir;
1874
1875        // Create a parent temp dir that we control
1876        let parent_dir = tempdir().unwrap();
1877
1878        // Create a child subdirectory for the project
1879        let project_dir = parent_dir.path().join("project");
1880        fs::create_dir(&project_dir).unwrap();
1881
1882        // Create pyproject.toml WITHOUT [tool.rumdl] section in project dir
1883        fs::write(
1884            project_dir.join("pyproject.toml"),
1885            r#"
1886[project]
1887name = "test-project"
1888version = "0.1.0"
1889"#,
1890        )
1891        .unwrap();
1892
1893        // Create .rumdl.toml in PARENT that SHOULD be found
1894        // because pyproject.toml without [tool.rumdl] should be skipped
1895        fs::write(
1896            parent_dir.path().join(".rumdl.toml"),
1897            r#"
1898[global]
1899disable = ["MD013"]
1900"#,
1901        )
1902        .unwrap();
1903
1904        let test_file = project_dir.join("test.md");
1905        fs::write(&test_file, "# Test\n").unwrap();
1906
1907        let server = create_test_server();
1908
1909        // Set workspace root to parent so upward search doesn't stop at project_dir
1910        {
1911            let mut roots = server.workspace_roots.write().await;
1912            roots.push(parent_dir.path().to_path_buf());
1913        }
1914
1915        // Resolve config for file in project_dir
1916        let config = server.resolve_config_for_file(&test_file).await;
1917
1918        // CRITICAL TEST: The pyproject.toml in project_dir should be SKIPPED because it lacks
1919        // [tool.rumdl], and the search should continue upward to find parent .rumdl.toml
1920        assert!(
1921            config.global.disable.contains(&"MD013".to_string()),
1922            "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
1923             and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
1924        );
1925
1926        // Verify the config came from the parent directory, not project_dir
1927        // (we can check this by looking at the cache)
1928        let cache = server.config_cache.read().await;
1929        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1930
1931        assert!(
1932            cache_entry.config_file.is_some(),
1933            "Should have found a config file (parent .rumdl.toml)"
1934        );
1935
1936        let found_config_path = cache_entry.config_file.as_ref().unwrap();
1937        assert!(
1938            found_config_path.ends_with(".rumdl.toml"),
1939            "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
1940        );
1941        assert!(
1942            found_config_path.parent().unwrap() == parent_dir.path(),
1943            "Should have loaded config from parent directory, not project_dir"
1944        );
1945    }
1946
1947    /// Test for issue #131: LSP should detect and load pyproject.toml WITH [tool.rumdl] section
1948    ///
1949    /// This test verifies that when pyproject.toml contains [tool.rumdl], the fix at lines 574-585
1950    /// correctly allows it through and loads the configuration.
1951    #[tokio::test]
1952    async fn test_issue_131_pyproject_with_rumdl_section() {
1953        use std::fs;
1954        use tempfile::tempdir;
1955
1956        // Create a parent temp dir that we control
1957        let parent_dir = tempdir().unwrap();
1958
1959        // Create a child subdirectory for the project
1960        let project_dir = parent_dir.path().join("project");
1961        fs::create_dir(&project_dir).unwrap();
1962
1963        // Create pyproject.toml WITH [tool.rumdl] section in project dir
1964        fs::write(
1965            project_dir.join("pyproject.toml"),
1966            r#"
1967[project]
1968name = "test-project"
1969
1970[tool.rumdl.global]
1971disable = ["MD033"]
1972"#,
1973        )
1974        .unwrap();
1975
1976        // Create a parent directory with different config that should NOT be used
1977        fs::write(
1978            parent_dir.path().join(".rumdl.toml"),
1979            r#"
1980[global]
1981disable = ["MD041"]
1982"#,
1983        )
1984        .unwrap();
1985
1986        let test_file = project_dir.join("test.md");
1987        fs::write(&test_file, "# Test\n").unwrap();
1988
1989        let server = create_test_server();
1990
1991        // Set workspace root to parent
1992        {
1993            let mut roots = server.workspace_roots.write().await;
1994            roots.push(parent_dir.path().to_path_buf());
1995        }
1996
1997        // Resolve config for file
1998        let config = server.resolve_config_for_file(&test_file).await;
1999
2000        // CRITICAL TEST: The pyproject.toml should be LOADED (not skipped) because it has [tool.rumdl]
2001        assert!(
2002            config.global.disable.contains(&"MD033".to_string()),
2003            "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2004             Expected MD033 from project_dir pyproject.toml to be disabled."
2005        );
2006
2007        // Verify we did NOT get the parent config
2008        assert!(
2009            !config.global.disable.contains(&"MD041".to_string()),
2010            "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2011        );
2012
2013        // Verify the config came from pyproject.toml specifically
2014        let cache = server.config_cache.read().await;
2015        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2016
2017        assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2018
2019        let found_config_path = cache_entry.config_file.as_ref().unwrap();
2020        assert!(
2021            found_config_path.ends_with("pyproject.toml"),
2022            "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2023        );
2024        assert!(
2025            found_config_path.parent().unwrap() == project_dir,
2026            "Should have loaded pyproject.toml from project_dir, not parent"
2027        );
2028    }
2029
2030    /// Test for issue #131: Verify pyproject.toml with only "tool.rumdl" (no brackets) is detected
2031    ///
2032    /// The fix checks for both "[tool.rumdl]" and "tool.rumdl" (line 576), ensuring it catches
2033    /// any valid TOML structure like [tool.rumdl.global] or [[tool.rumdl.something]].
2034    #[tokio::test]
2035    async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2036        use std::fs;
2037        use tempfile::tempdir;
2038
2039        let temp_dir = tempdir().unwrap();
2040
2041        // Create pyproject.toml with [tool.rumdl.global] but not [tool.rumdl] directly
2042        fs::write(
2043            temp_dir.path().join("pyproject.toml"),
2044            r#"
2045[project]
2046name = "test-project"
2047
2048[tool.rumdl.global]
2049disable = ["MD022"]
2050"#,
2051        )
2052        .unwrap();
2053
2054        let test_file = temp_dir.path().join("test.md");
2055        fs::write(&test_file, "# Test\n").unwrap();
2056
2057        let server = create_test_server();
2058
2059        // Set workspace root
2060        {
2061            let mut roots = server.workspace_roots.write().await;
2062            roots.push(temp_dir.path().to_path_buf());
2063        }
2064
2065        // Resolve config for file
2066        let config = server.resolve_config_for_file(&test_file).await;
2067
2068        // Should detect "tool.rumdl" substring and load the config
2069        assert!(
2070            config.global.disable.contains(&"MD022".to_string()),
2071            "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2072        );
2073
2074        // Verify it loaded pyproject.toml
2075        let cache = server.config_cache.read().await;
2076        let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2077        assert!(
2078            cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2079            "Should have loaded pyproject.toml"
2080        );
2081    }
2082}