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