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