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