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::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                    // Count total fixable issues in the document
416                    let total_fixable = warnings.iter().filter(|w| w.fix.is_some()).count();
417
418                    if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &warnings)
419                        && fixed_content != text
420                    {
421                        // Calculate proper end position
422                        let mut line = 0u32;
423                        let mut character = 0u32;
424                        for ch in text.chars() {
425                            if ch == '\n' {
426                                line += 1;
427                                character = 0;
428                            } else {
429                                character += 1;
430                            }
431                        }
432
433                        let fix_all_action = CodeAction {
434                            title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
435                            kind: Some(CodeActionKind::QUICKFIX),
436                            diagnostics: Some(Vec::new()),
437                            edit: Some(WorkspaceEdit {
438                                changes: Some(
439                                    [(
440                                        uri.clone(),
441                                        vec![TextEdit {
442                                            range: Range {
443                                                start: Position { line: 0, character: 0 },
444                                                end: Position { line, character },
445                                            },
446                                            new_text: fixed_content,
447                                        }],
448                                    )]
449                                    .into_iter()
450                                    .collect(),
451                                ),
452                                ..Default::default()
453                            }),
454                            command: None,
455                            is_preferred: Some(true),
456                            disabled: None,
457                            data: None,
458                        };
459
460                        // Insert at the beginning to make it prominent
461                        actions.insert(0, fix_all_action);
462                    }
463                }
464
465                Ok(actions)
466            }
467            Err(e) => {
468                log::error!("Failed to get code actions: {e}");
469                Ok(Vec::new())
470            }
471        }
472    }
473
474    /// Load or reload rumdl configuration from files
475    async fn load_configuration(&self, notify_client: bool) {
476        let config_guard = self.config.read().await;
477        let explicit_config_path = config_guard.config_path.clone();
478        drop(config_guard);
479
480        // Use the same discovery logic as CLI but with LSP-specific error handling
481        match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
482            Ok(sourced_config) => {
483                let loaded_files = sourced_config.loaded_files.clone();
484                *self.rumdl_config.write().await = sourced_config.into();
485
486                if !loaded_files.is_empty() {
487                    let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
488                    log::info!("{message}");
489                    if notify_client {
490                        self.client.log_message(MessageType::INFO, &message).await;
491                    }
492                } else {
493                    log::info!("Using default rumdl configuration (no config files found)");
494                }
495            }
496            Err(e) => {
497                let message = format!("Failed to load rumdl config: {e}");
498                log::warn!("{message}");
499                if notify_client {
500                    self.client.log_message(MessageType::WARNING, &message).await;
501                }
502                // Use default configuration
503                *self.rumdl_config.write().await = crate::config::Config::default();
504            }
505        }
506    }
507
508    /// Reload rumdl configuration from files (with client notification)
509    async fn reload_configuration(&self) {
510        self.load_configuration(true).await;
511    }
512
513    /// Load configuration for LSP - similar to CLI loading but returns Result
514    fn load_config_for_lsp(
515        config_path: Option<&str>,
516    ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
517        // Use the same configuration loading as the CLI
518        crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
519    }
520
521    /// Resolve configuration for a specific file
522    ///
523    /// This method searches for a configuration file starting from the file's directory
524    /// and walking up the directory tree until a workspace root is hit or a config is found.
525    ///
526    /// Results are cached to avoid repeated filesystem access.
527    pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
528        // Get the directory to start searching from
529        let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
530
531        // Check cache first
532        {
533            let cache = self.config_cache.read().await;
534            if let Some(entry) = cache.get(&search_dir) {
535                let source_owned: String; // ensure owned storage for logging
536                let source: &str = if entry.from_global_fallback {
537                    "global/user fallback"
538                } else if let Some(path) = &entry.config_file {
539                    source_owned = path.to_string_lossy().to_string();
540                    &source_owned
541                } else {
542                    "<unknown>"
543                };
544                log::debug!(
545                    "Config cache hit for directory: {} (loaded from: {})",
546                    search_dir.display(),
547                    source
548                );
549                return entry.config.clone();
550            }
551        }
552
553        // Cache miss - need to search for config
554        log::debug!(
555            "Config cache miss for directory: {}, searching for config...",
556            search_dir.display()
557        );
558
559        // Try to find workspace root for this file
560        let workspace_root = {
561            let workspace_roots = self.workspace_roots.read().await;
562            workspace_roots
563                .iter()
564                .find(|root| search_dir.starts_with(root))
565                .map(|p| p.to_path_buf())
566        };
567
568        // Search upward from the file's directory
569        let mut current_dir = search_dir.clone();
570        let mut found_config: Option<(Config, Option<PathBuf>)> = None;
571
572        loop {
573            // Try to find a config file in the current directory
574            const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
575
576            for config_file_name in CONFIG_FILES {
577                let config_path = current_dir.join(config_file_name);
578                if config_path.exists() {
579                    // For pyproject.toml, verify it contains [tool.rumdl] section (same as CLI)
580                    if *config_file_name == "pyproject.toml" {
581                        if let Ok(content) = std::fs::read_to_string(&config_path) {
582                            if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
583                                log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
584                            } else {
585                                log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
586                                continue;
587                            }
588                        } else {
589                            log::warn!("Failed to read pyproject.toml: {}", config_path.display());
590                            continue;
591                        }
592                    } else {
593                        log::debug!("Found config file: {}", config_path.display());
594                    }
595
596                    // Load the config
597                    if let Some(config_path_str) = config_path.to_str() {
598                        if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
599                            found_config = Some((sourced.into(), Some(config_path)));
600                            break;
601                        }
602                    } else {
603                        log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
604                    }
605                }
606            }
607
608            if found_config.is_some() {
609                break;
610            }
611
612            // Check if we've hit a workspace root
613            if let Some(ref root) = workspace_root
614                && &current_dir == root
615            {
616                log::debug!("Hit workspace root without finding config: {}", root.display());
617                break;
618            }
619
620            // Move up to parent directory
621            if let Some(parent) = current_dir.parent() {
622                current_dir = parent.to_path_buf();
623            } else {
624                // Hit filesystem root
625                break;
626            }
627        }
628
629        // Use found config or fall back to global/user config loaded at initialization
630        let (config, config_file) = if let Some((cfg, path)) = found_config {
631            (cfg, path)
632        } else {
633            log::debug!("No project config found; using global/user fallback config");
634            let fallback = self.rumdl_config.read().await.clone();
635            (fallback, None)
636        };
637
638        // Cache the result
639        let from_global = config_file.is_none();
640        let entry = ConfigCacheEntry {
641            config: config.clone(),
642            config_file,
643            from_global_fallback: from_global,
644        };
645
646        self.config_cache.write().await.insert(search_dir, entry);
647
648        config
649    }
650}
651
652#[tower_lsp::async_trait]
653impl LanguageServer for RumdlLanguageServer {
654    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
655        log::info!("Initializing rumdl Language Server");
656
657        // Parse client capabilities and configuration
658        if let Some(options) = params.initialization_options
659            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
660        {
661            *self.config.write().await = config;
662        }
663
664        // Extract and store workspace roots
665        let mut roots = Vec::new();
666        if let Some(workspace_folders) = params.workspace_folders {
667            for folder in workspace_folders {
668                if let Ok(path) = folder.uri.to_file_path() {
669                    log::info!("Workspace root: {}", path.display());
670                    roots.push(path);
671                }
672            }
673        } else if let Some(root_uri) = params.root_uri
674            && let Ok(path) = root_uri.to_file_path()
675        {
676            log::info!("Workspace root: {}", path.display());
677            roots.push(path);
678        }
679        *self.workspace_roots.write().await = roots;
680
681        // Load rumdl configuration with auto-discovery (fallback/default)
682        self.load_configuration(false).await;
683
684        Ok(InitializeResult {
685            capabilities: ServerCapabilities {
686                text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
687                    open_close: Some(true),
688                    change: Some(TextDocumentSyncKind::FULL),
689                    will_save: Some(false),
690                    will_save_wait_until: Some(true),
691                    save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
692                        include_text: Some(false),
693                    })),
694                })),
695                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
696                document_formatting_provider: Some(OneOf::Left(true)),
697                document_range_formatting_provider: Some(OneOf::Left(true)),
698                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
699                    identifier: Some("rumdl".to_string()),
700                    inter_file_dependencies: false,
701                    workspace_diagnostics: false,
702                    work_done_progress_options: WorkDoneProgressOptions::default(),
703                })),
704                workspace: Some(WorkspaceServerCapabilities {
705                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
706                        supported: Some(true),
707                        change_notifications: Some(OneOf::Left(true)),
708                    }),
709                    file_operations: None,
710                }),
711                ..Default::default()
712            },
713            server_info: Some(ServerInfo {
714                name: "rumdl".to_string(),
715                version: Some(env!("CARGO_PKG_VERSION").to_string()),
716            }),
717        })
718    }
719
720    async fn initialized(&self, _: InitializedParams) {
721        let version = env!("CARGO_PKG_VERSION");
722
723        // Get binary path and build time
724        let (binary_path, build_time) = std::env::current_exe()
725            .ok()
726            .map(|path| {
727                let path_str = path.to_str().unwrap_or("unknown").to_string();
728                let build_time = std::fs::metadata(&path)
729                    .ok()
730                    .and_then(|metadata| metadata.modified().ok())
731                    .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
732                    .and_then(|duration| {
733                        let secs = duration.as_secs();
734                        chrono::DateTime::from_timestamp(secs as i64, 0)
735                            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
736                    })
737                    .unwrap_or_else(|| "unknown".to_string());
738                (path_str, build_time)
739            })
740            .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
741
742        let working_dir = std::env::current_dir()
743            .ok()
744            .and_then(|p| p.to_str().map(|s| s.to_string()))
745            .unwrap_or_else(|| "unknown".to_string());
746
747        log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
748        log::info!("Working directory: {working_dir}");
749
750        self.client
751            .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
752            .await;
753    }
754
755    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
756        // Update workspace roots
757        let mut roots = self.workspace_roots.write().await;
758
759        // Remove deleted workspace folders
760        for removed in &params.event.removed {
761            if let Ok(path) = removed.uri.to_file_path() {
762                roots.retain(|r| r != &path);
763                log::info!("Removed workspace root: {}", path.display());
764            }
765        }
766
767        // Add new workspace folders
768        for added in &params.event.added {
769            if let Ok(path) = added.uri.to_file_path()
770                && !roots.contains(&path)
771            {
772                log::info!("Added workspace root: {}", path.display());
773                roots.push(path);
774            }
775        }
776        drop(roots);
777
778        // Clear config cache as workspace structure changed
779        self.config_cache.write().await.clear();
780
781        // Reload fallback configuration
782        self.reload_configuration().await;
783    }
784
785    async fn shutdown(&self) -> JsonRpcResult<()> {
786        log::info!("Shutting down rumdl Language Server");
787        Ok(())
788    }
789
790    async fn did_open(&self, params: DidOpenTextDocumentParams) {
791        let uri = params.text_document.uri;
792        let text = params.text_document.text;
793        let version = params.text_document.version;
794
795        let entry = DocumentEntry {
796            content: text.clone(),
797            version: Some(version),
798            from_disk: false,
799        };
800        self.documents.write().await.insert(uri.clone(), entry);
801
802        self.update_diagnostics(uri, text).await;
803    }
804
805    async fn did_change(&self, params: DidChangeTextDocumentParams) {
806        let uri = params.text_document.uri;
807        let version = params.text_document.version;
808
809        if let Some(change) = params.content_changes.into_iter().next() {
810            let text = change.text;
811
812            let entry = DocumentEntry {
813                content: text.clone(),
814                version: Some(version),
815                from_disk: false,
816            };
817            self.documents.write().await.insert(uri.clone(), entry);
818
819            self.update_diagnostics(uri, text).await;
820        }
821    }
822
823    async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
824        let config_guard = self.config.read().await;
825        let enable_auto_fix = config_guard.enable_auto_fix;
826        drop(config_guard);
827
828        if !enable_auto_fix {
829            return Ok(None);
830        }
831
832        // Get the current document content
833        let Some(text) = self.get_document_content(&params.text_document.uri).await else {
834            return Ok(None);
835        };
836
837        // Apply all fixes
838        match self.apply_all_fixes(&params.text_document.uri, &text).await {
839            Ok(Some(fixed_text)) => {
840                // Return a single edit that replaces the entire document
841                Ok(Some(vec![TextEdit {
842                    range: Range {
843                        start: Position { line: 0, character: 0 },
844                        end: self.get_end_position(&text),
845                    },
846                    new_text: fixed_text,
847                }]))
848            }
849            Ok(None) => Ok(None),
850            Err(e) => {
851                log::error!("Failed to generate fixes in will_save_wait_until: {e}");
852                Ok(None)
853            }
854        }
855    }
856
857    async fn did_save(&self, params: DidSaveTextDocumentParams) {
858        // Re-lint the document after save
859        // Note: Auto-fixing is now handled by will_save_wait_until which runs before the save
860        if let Some(entry) = self.documents.read().await.get(&params.text_document.uri) {
861            self.update_diagnostics(params.text_document.uri, entry.content.clone())
862                .await;
863        }
864    }
865
866    async fn did_close(&self, params: DidCloseTextDocumentParams) {
867        // Remove document from storage
868        self.documents.write().await.remove(&params.text_document.uri);
869
870        // Clear diagnostics
871        self.client
872            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
873            .await;
874    }
875
876    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
877        // Check if any of the changed files are config files
878        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
879
880        for change in &params.changes {
881            if let Ok(path) = change.uri.to_file_path()
882                && let Some(file_name) = path.file_name().and_then(|f| f.to_str())
883                && CONFIG_FILES.contains(&file_name)
884            {
885                log::info!("Config file changed: {}, invalidating config cache", path.display());
886
887                // Invalidate all cache entries that were loaded from this config file
888                let mut cache = self.config_cache.write().await;
889                cache.retain(|_, entry| {
890                    if let Some(config_file) = &entry.config_file {
891                        config_file != &path
892                    } else {
893                        true
894                    }
895                });
896
897                // Also reload the global fallback configuration
898                drop(cache);
899                self.reload_configuration().await;
900
901                // Re-lint all open documents
902                // First collect URIs and content to avoid holding lock during async operations
903                let docs_to_update: Vec<(Url, String)> = {
904                    let docs = self.documents.read().await;
905                    docs.iter()
906                        .filter(|(_, entry)| !entry.from_disk)
907                        .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
908                        .collect()
909                };
910
911                // Now update diagnostics without holding the lock
912                for (uri, text) in docs_to_update {
913                    self.update_diagnostics(uri, text).await;
914                }
915
916                break;
917            }
918        }
919    }
920
921    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
922        let uri = params.text_document.uri;
923        let range = params.range;
924
925        if let Some(text) = self.get_document_content(&uri).await {
926            match self.get_code_actions(&uri, &text, range).await {
927                Ok(actions) => {
928                    let response: Vec<CodeActionOrCommand> =
929                        actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
930                    Ok(Some(response))
931                }
932                Err(e) => {
933                    log::error!("Failed to get code actions: {e}");
934                    Ok(None)
935                }
936            }
937        } else {
938            Ok(None)
939        }
940    }
941
942    async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
943        // For markdown linting, we format the entire document because:
944        // 1. Many markdown rules have document-wide implications (e.g., heading hierarchy, list consistency)
945        // 2. Fixes often need surrounding context to be applied correctly
946        // 3. This approach is common among linters (ESLint, rustfmt, etc. do similar)
947        log::debug!(
948            "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
949            params.range
950        );
951
952        let formatting_params = DocumentFormattingParams {
953            text_document: params.text_document,
954            options: params.options,
955            work_done_progress_params: params.work_done_progress_params,
956        };
957
958        self.formatting(formatting_params).await
959    }
960
961    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
962        let uri = params.text_document.uri;
963
964        log::debug!("Formatting request for: {uri}");
965
966        if let Some(text) = self.get_document_content(&uri).await {
967            // Get config with LSP overrides
968            let config_guard = self.config.read().await;
969            let lsp_config = config_guard.clone();
970            drop(config_guard);
971
972            // Resolve configuration for this specific file
973            let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
974                self.resolve_config_for_file(&file_path).await
975            } else {
976                // Fallback to global config for non-file URIs
977                self.rumdl_config.read().await.clone()
978            };
979
980            let all_rules = rules::all_rules(&rumdl_config);
981            let flavor = rumdl_config.markdown_flavor();
982
983            // Use the standard filter_rules function which respects config's disabled rules
984            let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
985
986            // Apply LSP config overrides
987            filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
988
989            // Use warning fixes for all rules
990            match crate::lint(&text, &filtered_rules, false, flavor) {
991                Ok(warnings) => {
992                    log::debug!(
993                        "Found {} warnings, {} with fixes",
994                        warnings.len(),
995                        warnings.iter().filter(|w| w.fix.is_some()).count()
996                    );
997
998                    let has_fixes = warnings.iter().any(|w| w.fix.is_some());
999                    if has_fixes {
1000                        match crate::utils::fix_utils::apply_warning_fixes(&text, &warnings) {
1001                            Ok(fixed_content) => {
1002                                if fixed_content != text {
1003                                    log::debug!("Returning formatting edits");
1004                                    let end_position = self.get_end_position(&text);
1005                                    let edit = TextEdit {
1006                                        range: Range {
1007                                            start: Position { line: 0, character: 0 },
1008                                            end: end_position,
1009                                        },
1010                                        new_text: fixed_content,
1011                                    };
1012                                    return Ok(Some(vec![edit]));
1013                                }
1014                            }
1015                            Err(e) => {
1016                                log::error!("Failed to apply fixes: {e}");
1017                            }
1018                        }
1019                    }
1020                    Ok(Some(Vec::new()))
1021                }
1022                Err(e) => {
1023                    log::error!("Failed to format document: {e}");
1024                    Ok(Some(Vec::new()))
1025                }
1026            }
1027        } else {
1028            log::warn!("Document not found: {uri}");
1029            Ok(None)
1030        }
1031    }
1032
1033    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1034        let uri = params.text_document.uri;
1035
1036        if let Some(text) = self.get_document_content(&uri).await {
1037            match self.lint_document(&uri, &text).await {
1038                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1039                    RelatedFullDocumentDiagnosticReport {
1040                        related_documents: None,
1041                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
1042                            result_id: None,
1043                            items: diagnostics,
1044                        },
1045                    },
1046                ))),
1047                Err(e) => {
1048                    log::error!("Failed to get diagnostics: {e}");
1049                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1050                        RelatedFullDocumentDiagnosticReport {
1051                            related_documents: None,
1052                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
1053                                result_id: None,
1054                                items: Vec::new(),
1055                            },
1056                        },
1057                    )))
1058                }
1059            }
1060        } else {
1061            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1062                RelatedFullDocumentDiagnosticReport {
1063                    related_documents: None,
1064                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
1065                        result_id: None,
1066                        items: Vec::new(),
1067                    },
1068                },
1069            )))
1070        }
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::rule::LintWarning;
1078    use tower_lsp::LspService;
1079
1080    fn create_test_server() -> RumdlLanguageServer {
1081        let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1082        service.inner().clone()
1083    }
1084
1085    #[tokio::test]
1086    async fn test_server_creation() {
1087        let server = create_test_server();
1088
1089        // Verify default configuration
1090        let config = server.config.read().await;
1091        assert!(config.enable_linting);
1092        assert!(!config.enable_auto_fix);
1093    }
1094
1095    #[tokio::test]
1096    async fn test_lint_document() {
1097        let server = create_test_server();
1098
1099        // Test linting with a simple markdown document
1100        let uri = Url::parse("file:///test.md").unwrap();
1101        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1102
1103        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1104
1105        // Should find trailing spaces violations
1106        assert!(!diagnostics.is_empty());
1107        assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1108    }
1109
1110    #[tokio::test]
1111    async fn test_lint_document_disabled() {
1112        let server = create_test_server();
1113
1114        // Disable linting
1115        server.config.write().await.enable_linting = false;
1116
1117        let uri = Url::parse("file:///test.md").unwrap();
1118        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1119
1120        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1121
1122        // Should return empty diagnostics when disabled
1123        assert!(diagnostics.is_empty());
1124    }
1125
1126    #[tokio::test]
1127    async fn test_get_code_actions() {
1128        let server = create_test_server();
1129
1130        let uri = Url::parse("file:///test.md").unwrap();
1131        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1132
1133        // Create a range covering the whole document
1134        let range = Range {
1135            start: Position { line: 0, character: 0 },
1136            end: Position { line: 3, character: 21 },
1137        };
1138
1139        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1140
1141        // Should have code actions for fixing trailing spaces
1142        assert!(!actions.is_empty());
1143        assert!(actions.iter().any(|a| a.title.contains("trailing")));
1144    }
1145
1146    #[tokio::test]
1147    async fn test_get_code_actions_outside_range() {
1148        let server = create_test_server();
1149
1150        let uri = Url::parse("file:///test.md").unwrap();
1151        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1152
1153        // Create a range that doesn't cover the violations
1154        let range = Range {
1155            start: Position { line: 0, character: 0 },
1156            end: Position { line: 0, character: 6 },
1157        };
1158
1159        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1160
1161        // Should have no code actions for this range
1162        assert!(actions.is_empty());
1163    }
1164
1165    #[tokio::test]
1166    async fn test_document_storage() {
1167        let server = create_test_server();
1168
1169        let uri = Url::parse("file:///test.md").unwrap();
1170        let text = "# Test Document";
1171
1172        // Store document
1173        let entry = DocumentEntry {
1174            content: text.to_string(),
1175            version: Some(1),
1176            from_disk: false,
1177        };
1178        server.documents.write().await.insert(uri.clone(), entry);
1179
1180        // Verify storage
1181        let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1182        assert_eq!(stored, Some(text.to_string()));
1183
1184        // Remove document
1185        server.documents.write().await.remove(&uri);
1186
1187        // Verify removal
1188        let stored = server.documents.read().await.get(&uri).cloned();
1189        assert_eq!(stored, None);
1190    }
1191
1192    #[tokio::test]
1193    async fn test_configuration_loading() {
1194        let server = create_test_server();
1195
1196        // Load configuration with auto-discovery
1197        server.load_configuration(false).await;
1198
1199        // Verify configuration was loaded successfully
1200        // The config could be from: .rumdl.toml, pyproject.toml, .markdownlint.json, or default
1201        let rumdl_config = server.rumdl_config.read().await;
1202        // The loaded config is valid regardless of source
1203        drop(rumdl_config); // Just verify we can access it without panic
1204    }
1205
1206    #[tokio::test]
1207    async fn test_load_config_for_lsp() {
1208        // Test with no config file
1209        let result = RumdlLanguageServer::load_config_for_lsp(None);
1210        assert!(result.is_ok());
1211
1212        // Test with non-existent config file
1213        let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1214        assert!(result.is_err());
1215    }
1216
1217    #[tokio::test]
1218    async fn test_warning_conversion() {
1219        let warning = LintWarning {
1220            message: "Test warning".to_string(),
1221            line: 1,
1222            column: 1,
1223            end_line: 1,
1224            end_column: 10,
1225            severity: crate::rule::Severity::Warning,
1226            fix: None,
1227            rule_name: Some("MD001".to_string()),
1228        };
1229
1230        // Test diagnostic conversion
1231        let diagnostic = warning_to_diagnostic(&warning);
1232        assert_eq!(diagnostic.message, "Test warning");
1233        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1234        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1235
1236        // Test code action conversion (no fix, but should have ignore action)
1237        let uri = Url::parse("file:///test.md").unwrap();
1238        let actions = warning_to_code_actions(&warning, &uri, "Test content");
1239        // Should have 1 action: ignore-line (no fix available)
1240        assert_eq!(actions.len(), 1);
1241        assert_eq!(actions[0].title, "Ignore MD001 for this line");
1242    }
1243
1244    #[tokio::test]
1245    async fn test_multiple_documents() {
1246        let server = create_test_server();
1247
1248        let uri1 = Url::parse("file:///test1.md").unwrap();
1249        let uri2 = Url::parse("file:///test2.md").unwrap();
1250        let text1 = "# Document 1";
1251        let text2 = "# Document 2";
1252
1253        // Store multiple documents
1254        {
1255            let mut docs = server.documents.write().await;
1256            let entry1 = DocumentEntry {
1257                content: text1.to_string(),
1258                version: Some(1),
1259                from_disk: false,
1260            };
1261            let entry2 = DocumentEntry {
1262                content: text2.to_string(),
1263                version: Some(1),
1264                from_disk: false,
1265            };
1266            docs.insert(uri1.clone(), entry1);
1267            docs.insert(uri2.clone(), entry2);
1268        }
1269
1270        // Verify both are stored
1271        let docs = server.documents.read().await;
1272        assert_eq!(docs.len(), 2);
1273        assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1274        assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1275    }
1276
1277    #[tokio::test]
1278    async fn test_auto_fix_on_save() {
1279        let server = create_test_server();
1280
1281        // Enable auto-fix
1282        {
1283            let mut config = server.config.write().await;
1284            config.enable_auto_fix = true;
1285        }
1286
1287        let uri = Url::parse("file:///test.md").unwrap();
1288        let text = "#Heading without space"; // MD018 violation
1289
1290        // Store document
1291        let entry = DocumentEntry {
1292            content: text.to_string(),
1293            version: Some(1),
1294            from_disk: false,
1295        };
1296        server.documents.write().await.insert(uri.clone(), entry);
1297
1298        // Test apply_all_fixes
1299        let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1300        assert!(fixed.is_some());
1301        // MD018 adds space, MD047 adds trailing newline
1302        assert_eq!(fixed.unwrap(), "# Heading without space\n");
1303    }
1304
1305    #[tokio::test]
1306    async fn test_get_end_position() {
1307        let server = create_test_server();
1308
1309        // Single line
1310        let pos = server.get_end_position("Hello");
1311        assert_eq!(pos.line, 0);
1312        assert_eq!(pos.character, 5);
1313
1314        // Multiple lines
1315        let pos = server.get_end_position("Hello\nWorld\nTest");
1316        assert_eq!(pos.line, 2);
1317        assert_eq!(pos.character, 4);
1318
1319        // Empty string
1320        let pos = server.get_end_position("");
1321        assert_eq!(pos.line, 0);
1322        assert_eq!(pos.character, 0);
1323
1324        // Ends with newline - position should be at start of next line
1325        let pos = server.get_end_position("Hello\n");
1326        assert_eq!(pos.line, 1);
1327        assert_eq!(pos.character, 0);
1328    }
1329
1330    #[tokio::test]
1331    async fn test_empty_document_handling() {
1332        let server = create_test_server();
1333
1334        let uri = Url::parse("file:///empty.md").unwrap();
1335        let text = "";
1336
1337        // Test linting empty document
1338        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1339        assert!(diagnostics.is_empty());
1340
1341        // Test code actions on empty document
1342        let range = Range {
1343            start: Position { line: 0, character: 0 },
1344            end: Position { line: 0, character: 0 },
1345        };
1346        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1347        assert!(actions.is_empty());
1348    }
1349
1350    #[tokio::test]
1351    async fn test_config_update() {
1352        let server = create_test_server();
1353
1354        // Update config
1355        {
1356            let mut config = server.config.write().await;
1357            config.enable_auto_fix = true;
1358            config.config_path = Some("/custom/path.toml".to_string());
1359        }
1360
1361        // Verify update
1362        let config = server.config.read().await;
1363        assert!(config.enable_auto_fix);
1364        assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1365    }
1366
1367    #[tokio::test]
1368    async fn test_document_formatting() {
1369        let server = create_test_server();
1370        let uri = Url::parse("file:///test.md").unwrap();
1371        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1372
1373        // Store document
1374        let entry = DocumentEntry {
1375            content: text.to_string(),
1376            version: Some(1),
1377            from_disk: false,
1378        };
1379        server.documents.write().await.insert(uri.clone(), entry);
1380
1381        // Create formatting params
1382        let params = DocumentFormattingParams {
1383            text_document: TextDocumentIdentifier { uri: uri.clone() },
1384            options: FormattingOptions {
1385                tab_size: 4,
1386                insert_spaces: true,
1387                properties: HashMap::new(),
1388                trim_trailing_whitespace: Some(true),
1389                insert_final_newline: Some(true),
1390                trim_final_newlines: Some(true),
1391            },
1392            work_done_progress_params: WorkDoneProgressParams::default(),
1393        };
1394
1395        // Call formatting
1396        let result = server.formatting(params).await.unwrap();
1397
1398        // Should return text edits that fix the trailing spaces
1399        assert!(result.is_some());
1400        let edits = result.unwrap();
1401        assert!(!edits.is_empty());
1402
1403        // The new text should have trailing spaces removed
1404        let edit = &edits[0];
1405        // The formatted text should have the trailing spaces removed from the middle line
1406        // and a final newline added
1407        let expected = "# Test\n\nThis is a test  \nWith trailing spaces\n";
1408        assert_eq!(edit.new_text, expected);
1409    }
1410
1411    /// Test that resolve_config_for_file() finds the correct config in multi-root workspace
1412    #[tokio::test]
1413    async fn test_resolve_config_for_file_multi_root() {
1414        use std::fs;
1415        use tempfile::tempdir;
1416
1417        let temp_dir = tempdir().unwrap();
1418        let temp_path = temp_dir.path();
1419
1420        // Setup project A with line_length=60
1421        let project_a = temp_path.join("project_a");
1422        let project_a_docs = project_a.join("docs");
1423        fs::create_dir_all(&project_a_docs).unwrap();
1424
1425        let config_a = project_a.join(".rumdl.toml");
1426        fs::write(
1427            &config_a,
1428            r#"
1429[global]
1430
1431[MD013]
1432line_length = 60
1433"#,
1434        )
1435        .unwrap();
1436
1437        // Setup project B with line_length=120
1438        let project_b = temp_path.join("project_b");
1439        fs::create_dir(&project_b).unwrap();
1440
1441        let config_b = project_b.join(".rumdl.toml");
1442        fs::write(
1443            &config_b,
1444            r#"
1445[global]
1446
1447[MD013]
1448line_length = 120
1449"#,
1450        )
1451        .unwrap();
1452
1453        // Create LSP server and initialize with workspace roots
1454        let server = create_test_server();
1455
1456        // Set workspace roots
1457        {
1458            let mut roots = server.workspace_roots.write().await;
1459            roots.push(project_a.clone());
1460            roots.push(project_b.clone());
1461        }
1462
1463        // Test file in project A
1464        let file_a = project_a_docs.join("test.md");
1465        fs::write(&file_a, "# Test A\n").unwrap();
1466
1467        let config_for_a = server.resolve_config_for_file(&file_a).await;
1468        let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1469        assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1470
1471        // Test file in project B
1472        let file_b = project_b.join("test.md");
1473        fs::write(&file_b, "# Test B\n").unwrap();
1474
1475        let config_for_b = server.resolve_config_for_file(&file_b).await;
1476        let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1477        assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1478    }
1479
1480    /// Test that config resolution respects workspace root boundaries
1481    #[tokio::test]
1482    async fn test_config_resolution_respects_workspace_boundaries() {
1483        use std::fs;
1484        use tempfile::tempdir;
1485
1486        let temp_dir = tempdir().unwrap();
1487        let temp_path = temp_dir.path();
1488
1489        // Create parent config that should NOT be used
1490        let parent_config = temp_path.join(".rumdl.toml");
1491        fs::write(
1492            &parent_config,
1493            r#"
1494[global]
1495
1496[MD013]
1497line_length = 80
1498"#,
1499        )
1500        .unwrap();
1501
1502        // Create workspace root with its own config
1503        let workspace_root = temp_path.join("workspace");
1504        let workspace_subdir = workspace_root.join("subdir");
1505        fs::create_dir_all(&workspace_subdir).unwrap();
1506
1507        let workspace_config = workspace_root.join(".rumdl.toml");
1508        fs::write(
1509            &workspace_config,
1510            r#"
1511[global]
1512
1513[MD013]
1514line_length = 100
1515"#,
1516        )
1517        .unwrap();
1518
1519        let server = create_test_server();
1520
1521        // Register workspace_root as a workspace root
1522        {
1523            let mut roots = server.workspace_roots.write().await;
1524            roots.push(workspace_root.clone());
1525        }
1526
1527        // Test file deep in subdirectory
1528        let test_file = workspace_subdir.join("deep").join("test.md");
1529        fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1530        fs::write(&test_file, "# Test\n").unwrap();
1531
1532        let config = server.resolve_config_for_file(&test_file).await;
1533        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1534
1535        // Should find workspace_root/.rumdl.toml (100), NOT parent config (80)
1536        assert_eq!(
1537            line_length,
1538            Some(100),
1539            "Should find workspace config, not parent config outside workspace"
1540        );
1541    }
1542
1543    /// Test that config cache works (cache hit scenario)
1544    #[tokio::test]
1545    async fn test_config_cache_hit() {
1546        use std::fs;
1547        use tempfile::tempdir;
1548
1549        let temp_dir = tempdir().unwrap();
1550        let temp_path = temp_dir.path();
1551
1552        let project = temp_path.join("project");
1553        fs::create_dir(&project).unwrap();
1554
1555        let config_file = project.join(".rumdl.toml");
1556        fs::write(
1557            &config_file,
1558            r#"
1559[global]
1560
1561[MD013]
1562line_length = 75
1563"#,
1564        )
1565        .unwrap();
1566
1567        let server = create_test_server();
1568        {
1569            let mut roots = server.workspace_roots.write().await;
1570            roots.push(project.clone());
1571        }
1572
1573        let test_file = project.join("test.md");
1574        fs::write(&test_file, "# Test\n").unwrap();
1575
1576        // First call - cache miss
1577        let config1 = server.resolve_config_for_file(&test_file).await;
1578        let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1579        assert_eq!(line_length1, Some(75));
1580
1581        // Verify cache was populated
1582        {
1583            let cache = server.config_cache.read().await;
1584            let search_dir = test_file.parent().unwrap();
1585            assert!(
1586                cache.contains_key(search_dir),
1587                "Cache should be populated after first call"
1588            );
1589        }
1590
1591        // Second call - cache hit (should return same config without filesystem access)
1592        let config2 = server.resolve_config_for_file(&test_file).await;
1593        let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1594        assert_eq!(line_length2, Some(75));
1595    }
1596
1597    /// Test nested directory config search (file searches upward)
1598    #[tokio::test]
1599    async fn test_nested_directory_config_search() {
1600        use std::fs;
1601        use tempfile::tempdir;
1602
1603        let temp_dir = tempdir().unwrap();
1604        let temp_path = temp_dir.path();
1605
1606        let project = temp_path.join("project");
1607        fs::create_dir(&project).unwrap();
1608
1609        // Config at project root
1610        let config = project.join(".rumdl.toml");
1611        fs::write(
1612            &config,
1613            r#"
1614[global]
1615
1616[MD013]
1617line_length = 110
1618"#,
1619        )
1620        .unwrap();
1621
1622        // File deep in nested structure
1623        let deep_dir = project.join("src").join("docs").join("guides");
1624        fs::create_dir_all(&deep_dir).unwrap();
1625        let deep_file = deep_dir.join("test.md");
1626        fs::write(&deep_file, "# Test\n").unwrap();
1627
1628        let server = create_test_server();
1629        {
1630            let mut roots = server.workspace_roots.write().await;
1631            roots.push(project.clone());
1632        }
1633
1634        let resolved_config = server.resolve_config_for_file(&deep_file).await;
1635        let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1636
1637        assert_eq!(
1638            line_length,
1639            Some(110),
1640            "Should find config by searching upward from deep directory"
1641        );
1642    }
1643
1644    /// Test fallback to default config when no config file found
1645    #[tokio::test]
1646    async fn test_fallback_to_default_config() {
1647        use std::fs;
1648        use tempfile::tempdir;
1649
1650        let temp_dir = tempdir().unwrap();
1651        let temp_path = temp_dir.path();
1652
1653        let project = temp_path.join("project");
1654        fs::create_dir(&project).unwrap();
1655
1656        // No config file created!
1657
1658        let test_file = project.join("test.md");
1659        fs::write(&test_file, "# Test\n").unwrap();
1660
1661        let server = create_test_server();
1662        {
1663            let mut roots = server.workspace_roots.write().await;
1664            roots.push(project.clone());
1665        }
1666
1667        let config = server.resolve_config_for_file(&test_file).await;
1668
1669        // Default global line_length is 80
1670        assert_eq!(
1671            config.global.line_length, 80,
1672            "Should fall back to default config when no config file found"
1673        );
1674    }
1675
1676    /// Test config priority: closer config wins over parent config
1677    #[tokio::test]
1678    async fn test_config_priority_closer_wins() {
1679        use std::fs;
1680        use tempfile::tempdir;
1681
1682        let temp_dir = tempdir().unwrap();
1683        let temp_path = temp_dir.path();
1684
1685        let project = temp_path.join("project");
1686        fs::create_dir(&project).unwrap();
1687
1688        // Parent config
1689        let parent_config = project.join(".rumdl.toml");
1690        fs::write(
1691            &parent_config,
1692            r#"
1693[global]
1694
1695[MD013]
1696line_length = 100
1697"#,
1698        )
1699        .unwrap();
1700
1701        // Subdirectory with its own config (should override parent)
1702        let subdir = project.join("subdir");
1703        fs::create_dir(&subdir).unwrap();
1704
1705        let subdir_config = subdir.join(".rumdl.toml");
1706        fs::write(
1707            &subdir_config,
1708            r#"
1709[global]
1710
1711[MD013]
1712line_length = 50
1713"#,
1714        )
1715        .unwrap();
1716
1717        let server = create_test_server();
1718        {
1719            let mut roots = server.workspace_roots.write().await;
1720            roots.push(project.clone());
1721        }
1722
1723        // File in subdirectory
1724        let test_file = subdir.join("test.md");
1725        fs::write(&test_file, "# Test\n").unwrap();
1726
1727        let config = server.resolve_config_for_file(&test_file).await;
1728        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1729
1730        assert_eq!(
1731            line_length,
1732            Some(50),
1733            "Closer config (subdir) should override parent config"
1734        );
1735    }
1736
1737    /// Test for issue #131: LSP should skip pyproject.toml without [tool.rumdl] section
1738    ///
1739    /// This test verifies the fix in resolve_config_for_file() at lines 574-585 that checks
1740    /// for [tool.rumdl] presence before loading pyproject.toml. The fix ensures LSP behavior
1741    /// matches CLI behavior.
1742    #[tokio::test]
1743    async fn test_issue_131_pyproject_without_rumdl_section() {
1744        use std::fs;
1745        use tempfile::tempdir;
1746
1747        // Create a parent temp dir that we control
1748        let parent_dir = tempdir().unwrap();
1749
1750        // Create a child subdirectory for the project
1751        let project_dir = parent_dir.path().join("project");
1752        fs::create_dir(&project_dir).unwrap();
1753
1754        // Create pyproject.toml WITHOUT [tool.rumdl] section in project dir
1755        fs::write(
1756            project_dir.join("pyproject.toml"),
1757            r#"
1758[project]
1759name = "test-project"
1760version = "0.1.0"
1761"#,
1762        )
1763        .unwrap();
1764
1765        // Create .rumdl.toml in PARENT that SHOULD be found
1766        // because pyproject.toml without [tool.rumdl] should be skipped
1767        fs::write(
1768            parent_dir.path().join(".rumdl.toml"),
1769            r#"
1770[global]
1771disable = ["MD013"]
1772"#,
1773        )
1774        .unwrap();
1775
1776        let test_file = project_dir.join("test.md");
1777        fs::write(&test_file, "# Test\n").unwrap();
1778
1779        let server = create_test_server();
1780
1781        // Set workspace root to parent so upward search doesn't stop at project_dir
1782        {
1783            let mut roots = server.workspace_roots.write().await;
1784            roots.push(parent_dir.path().to_path_buf());
1785        }
1786
1787        // Resolve config for file in project_dir
1788        let config = server.resolve_config_for_file(&test_file).await;
1789
1790        // CRITICAL TEST: The pyproject.toml in project_dir should be SKIPPED because it lacks
1791        // [tool.rumdl], and the search should continue upward to find parent .rumdl.toml
1792        assert!(
1793            config.global.disable.contains(&"MD013".to_string()),
1794            "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
1795             and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
1796        );
1797
1798        // Verify the config came from the parent directory, not project_dir
1799        // (we can check this by looking at the cache)
1800        let cache = server.config_cache.read().await;
1801        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1802
1803        assert!(
1804            cache_entry.config_file.is_some(),
1805            "Should have found a config file (parent .rumdl.toml)"
1806        );
1807
1808        let found_config_path = cache_entry.config_file.as_ref().unwrap();
1809        assert!(
1810            found_config_path.ends_with(".rumdl.toml"),
1811            "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
1812        );
1813        assert!(
1814            found_config_path.parent().unwrap() == parent_dir.path(),
1815            "Should have loaded config from parent directory, not project_dir"
1816        );
1817    }
1818
1819    /// Test for issue #131: LSP should detect and load pyproject.toml WITH [tool.rumdl] section
1820    ///
1821    /// This test verifies that when pyproject.toml contains [tool.rumdl], the fix at lines 574-585
1822    /// correctly allows it through and loads the configuration.
1823    #[tokio::test]
1824    async fn test_issue_131_pyproject_with_rumdl_section() {
1825        use std::fs;
1826        use tempfile::tempdir;
1827
1828        // Create a parent temp dir that we control
1829        let parent_dir = tempdir().unwrap();
1830
1831        // Create a child subdirectory for the project
1832        let project_dir = parent_dir.path().join("project");
1833        fs::create_dir(&project_dir).unwrap();
1834
1835        // Create pyproject.toml WITH [tool.rumdl] section in project dir
1836        fs::write(
1837            project_dir.join("pyproject.toml"),
1838            r#"
1839[project]
1840name = "test-project"
1841
1842[tool.rumdl.global]
1843disable = ["MD033"]
1844"#,
1845        )
1846        .unwrap();
1847
1848        // Create a parent directory with different config that should NOT be used
1849        fs::write(
1850            parent_dir.path().join(".rumdl.toml"),
1851            r#"
1852[global]
1853disable = ["MD041"]
1854"#,
1855        )
1856        .unwrap();
1857
1858        let test_file = project_dir.join("test.md");
1859        fs::write(&test_file, "# Test\n").unwrap();
1860
1861        let server = create_test_server();
1862
1863        // Set workspace root to parent
1864        {
1865            let mut roots = server.workspace_roots.write().await;
1866            roots.push(parent_dir.path().to_path_buf());
1867        }
1868
1869        // Resolve config for file
1870        let config = server.resolve_config_for_file(&test_file).await;
1871
1872        // CRITICAL TEST: The pyproject.toml should be LOADED (not skipped) because it has [tool.rumdl]
1873        assert!(
1874            config.global.disable.contains(&"MD033".to_string()),
1875            "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
1876             Expected MD033 from project_dir pyproject.toml to be disabled."
1877        );
1878
1879        // Verify we did NOT get the parent config
1880        assert!(
1881            !config.global.disable.contains(&"MD041".to_string()),
1882            "Should use project_dir pyproject.toml, not parent .rumdl.toml"
1883        );
1884
1885        // Verify the config came from pyproject.toml specifically
1886        let cache = server.config_cache.read().await;
1887        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1888
1889        assert!(cache_entry.config_file.is_some(), "Should have found a config file");
1890
1891        let found_config_path = cache_entry.config_file.as_ref().unwrap();
1892        assert!(
1893            found_config_path.ends_with("pyproject.toml"),
1894            "Should have loaded pyproject.toml. Found: {found_config_path:?}"
1895        );
1896        assert!(
1897            found_config_path.parent().unwrap() == project_dir,
1898            "Should have loaded pyproject.toml from project_dir, not parent"
1899        );
1900    }
1901
1902    /// Test for issue #131: Verify pyproject.toml with only "tool.rumdl" (no brackets) is detected
1903    ///
1904    /// The fix checks for both "[tool.rumdl]" and "tool.rumdl" (line 576), ensuring it catches
1905    /// any valid TOML structure like [tool.rumdl.global] or [[tool.rumdl.something]].
1906    #[tokio::test]
1907    async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
1908        use std::fs;
1909        use tempfile::tempdir;
1910
1911        let temp_dir = tempdir().unwrap();
1912
1913        // Create pyproject.toml with [tool.rumdl.global] but not [tool.rumdl] directly
1914        fs::write(
1915            temp_dir.path().join("pyproject.toml"),
1916            r#"
1917[project]
1918name = "test-project"
1919
1920[tool.rumdl.global]
1921disable = ["MD022"]
1922"#,
1923        )
1924        .unwrap();
1925
1926        let test_file = temp_dir.path().join("test.md");
1927        fs::write(&test_file, "# Test\n").unwrap();
1928
1929        let server = create_test_server();
1930
1931        // Set workspace root
1932        {
1933            let mut roots = server.workspace_roots.write().await;
1934            roots.push(temp_dir.path().to_path_buf());
1935        }
1936
1937        // Resolve config for file
1938        let config = server.resolve_config_for_file(&test_file).await;
1939
1940        // Should detect "tool.rumdl" substring and load the config
1941        assert!(
1942            config.global.disable.contains(&"MD022".to_string()),
1943            "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
1944        );
1945
1946        // Verify it loaded pyproject.toml
1947        let cache = server.config_cache.read().await;
1948        let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
1949        assert!(
1950            cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
1951            "Should have loaded pyproject.toml"
1952        );
1953    }
1954}