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