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