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 futures::future::join_all;
12use tokio::sync::{RwLock, mpsc};
13use tower_lsp::jsonrpc::Result as JsonRpcResult;
14use tower_lsp::lsp_types::*;
15use tower_lsp::{Client, LanguageServer};
16
17use crate::config::Config;
18use crate::lint;
19use crate::lsp::index_worker::IndexWorker;
20use crate::lsp::types::{
21    ConfigurationPreference, IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig, warning_to_code_actions,
22    warning_to_diagnostic,
23};
24use crate::rule::{FixCapability, Rule};
25use crate::rules;
26use crate::workspace_index::WorkspaceIndex;
27
28/// Supported markdown file extensions (without leading dot)
29const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
30
31/// Maximum number of rules in enable/disable lists (DoS protection)
32const MAX_RULE_LIST_SIZE: usize = 100;
33
34/// Maximum allowed line length value (DoS protection)
35const MAX_LINE_LENGTH: usize = 10_000;
36
37/// Check if a file extension is a markdown extension
38#[inline]
39fn is_markdown_extension(ext: &str) -> bool {
40    MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
41}
42
43/// Represents a document in the LSP server's cache
44#[derive(Clone, Debug, PartialEq)]
45struct DocumentEntry {
46    /// The document content
47    content: String,
48    /// Version number from the editor (None for disk-loaded documents)
49    version: Option<i32>,
50    /// Whether the document was loaded from disk (true) or opened in editor (false)
51    from_disk: bool,
52}
53
54/// Cache entry for resolved configuration
55#[derive(Clone, Debug)]
56pub(crate) struct ConfigCacheEntry {
57    /// The resolved configuration
58    pub(crate) config: Config,
59    /// Config file path that was loaded (for invalidation)
60    pub(crate) config_file: Option<PathBuf>,
61    /// True if this entry came from the global/user fallback (no project config)
62    pub(crate) from_global_fallback: bool,
63}
64
65/// Main LSP server for rumdl
66///
67/// Following Ruff's pattern, this server provides:
68/// - Real-time diagnostics as users type
69/// - Code actions for automatic fixes
70/// - Configuration management
71/// - Multi-file support
72/// - Multi-root workspace support with per-file config resolution
73/// - Cross-file analysis with workspace indexing
74#[derive(Clone)]
75pub struct RumdlLanguageServer {
76    client: Client,
77    /// Configuration for the LSP server
78    config: Arc<RwLock<RumdlLspConfig>>,
79    /// Rumdl core configuration (fallback/default)
80    #[cfg_attr(test, allow(dead_code))]
81    pub(crate) rumdl_config: Arc<RwLock<Config>>,
82    /// Document store for open files and cached disk files
83    documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
84    /// Workspace root folders from the client
85    #[cfg_attr(test, allow(dead_code))]
86    pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
87    /// Configuration cache: maps directory path to resolved config
88    /// Key is the directory where config search started (file's parent dir)
89    #[cfg_attr(test, allow(dead_code))]
90    pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
91    /// Workspace index for cross-file analysis (MD051)
92    workspace_index: Arc<RwLock<WorkspaceIndex>>,
93    /// Current state of the workspace index (building/ready/error)
94    index_state: Arc<RwLock<IndexState>>,
95    /// Channel to send updates to the background index worker
96    update_tx: mpsc::Sender<IndexUpdate>,
97    /// Whether the client supports pull diagnostics (textDocument/diagnostic)
98    /// When true, we skip pushing diagnostics to avoid duplicates
99    client_supports_pull_diagnostics: Arc<RwLock<bool>>,
100}
101
102impl RumdlLanguageServer {
103    pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
104        // Initialize with CLI config path if provided (for `rumdl server --config` convenience)
105        let mut initial_config = RumdlLspConfig::default();
106        if let Some(path) = cli_config_path {
107            initial_config.config_path = Some(path.to_string());
108        }
109
110        // Create shared state for workspace indexing
111        let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
112        let index_state = Arc::new(RwLock::new(IndexState::default()));
113        let workspace_roots = Arc::new(RwLock::new(Vec::new()));
114
115        // Create channels for index worker communication
116        let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
117        let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
118
119        // Spawn the background index worker
120        let worker = IndexWorker::new(
121            update_rx,
122            workspace_index.clone(),
123            index_state.clone(),
124            client.clone(),
125            workspace_roots.clone(),
126            relint_tx,
127        );
128        tokio::spawn(worker.run());
129
130        Self {
131            client,
132            config: Arc::new(RwLock::new(initial_config)),
133            rumdl_config: Arc::new(RwLock::new(Config::default())),
134            documents: Arc::new(RwLock::new(HashMap::new())),
135            workspace_roots,
136            config_cache: Arc::new(RwLock::new(HashMap::new())),
137            workspace_index,
138            index_state,
139            update_tx,
140            client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
141        }
142    }
143
144    /// Get document content, either from cache or by reading from disk
145    ///
146    /// This method first checks if the document is in the cache (opened in editor).
147    /// If not found, it attempts to read the file from disk and caches it for
148    /// future requests.
149    async fn get_document_content(&self, uri: &Url) -> Option<String> {
150        // First check the cache
151        {
152            let docs = self.documents.read().await;
153            if let Some(entry) = docs.get(uri) {
154                return Some(entry.content.clone());
155            }
156        }
157
158        // If not in cache and it's a file URI, try to read from disk
159        if let Ok(path) = uri.to_file_path() {
160            if let Ok(content) = tokio::fs::read_to_string(&path).await {
161                // Cache the document for future requests
162                let entry = DocumentEntry {
163                    content: content.clone(),
164                    version: None,
165                    from_disk: true,
166                };
167
168                let mut docs = self.documents.write().await;
169                docs.insert(uri.clone(), entry);
170
171                log::debug!("Loaded document from disk and cached: {uri}");
172                return Some(content);
173            } else {
174                log::debug!("Failed to read file from disk: {uri}");
175            }
176        }
177
178        None
179    }
180
181    /// Check if a rule name is valid (e.g., MD001-MD062, case-insensitive)
182    ///
183    /// Zero-allocation implementation using byte comparisons.
184    fn is_valid_rule_name(name: &str) -> bool {
185        let bytes = name.as_bytes();
186
187        // Check for "all" special value (case-insensitive)
188        if bytes.len() == 3
189            && bytes[0].eq_ignore_ascii_case(&b'A')
190            && bytes[1].eq_ignore_ascii_case(&b'L')
191            && bytes[2].eq_ignore_ascii_case(&b'L')
192        {
193            return true;
194        }
195
196        // Must be exactly 5 characters: "MDnnn"
197        if bytes.len() != 5 {
198            return false;
199        }
200
201        // Check "MD" prefix (case-insensitive)
202        if !bytes[0].eq_ignore_ascii_case(&b'M') || !bytes[1].eq_ignore_ascii_case(&b'D') {
203            return false;
204        }
205
206        // Parse the 3-digit number
207        let d0 = bytes[2].wrapping_sub(b'0');
208        let d1 = bytes[3].wrapping_sub(b'0');
209        let d2 = bytes[4].wrapping_sub(b'0');
210
211        // All must be digits (0-9)
212        if d0 > 9 || d1 > 9 || d2 > 9 {
213            return false;
214        }
215
216        let num = (d0 as u32) * 100 + (d1 as u32) * 10 + (d2 as u32);
217
218        // Valid rules are MD001-MD062, with gaps (no MD002, MD006, MD008, MD015-MD017)
219        matches!(num, 1 | 3..=5 | 7 | 9..=14 | 18..=62)
220    }
221
222    /// Apply LSP config overrides to the filtered rules
223    fn apply_lsp_config_overrides(
224        &self,
225        mut filtered_rules: Vec<Box<dyn Rule>>,
226        lsp_config: &RumdlLspConfig,
227    ) -> Vec<Box<dyn Rule>> {
228        // Collect enable rules from both top-level and settings
229        let mut enable_rules: Vec<String> = Vec::new();
230        if let Some(enable) = &lsp_config.enable_rules {
231            enable_rules.extend(enable.iter().cloned());
232        }
233        if let Some(settings) = &lsp_config.settings
234            && let Some(enable) = &settings.enable
235        {
236            enable_rules.extend(enable.iter().cloned());
237        }
238
239        // Apply enable_rules override (if specified, only these rules are active)
240        if !enable_rules.is_empty() {
241            let enable_set: std::collections::HashSet<String> = enable_rules.into_iter().collect();
242            filtered_rules.retain(|rule| enable_set.contains(rule.name()));
243        }
244
245        // Collect disable rules from both top-level and settings
246        let mut disable_rules: Vec<String> = Vec::new();
247        if let Some(disable) = &lsp_config.disable_rules {
248            disable_rules.extend(disable.iter().cloned());
249        }
250        if let Some(settings) = &lsp_config.settings
251            && let Some(disable) = &settings.disable
252        {
253            disable_rules.extend(disable.iter().cloned());
254        }
255
256        // Apply disable_rules override
257        if !disable_rules.is_empty() {
258            let disable_set: std::collections::HashSet<String> = disable_rules.into_iter().collect();
259            filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
260        }
261
262        filtered_rules
263    }
264
265    /// Merge LSP settings into a Config based on configuration preference
266    ///
267    /// This follows Ruff's pattern where editors can pass per-rule configuration
268    /// via LSP initialization options. The `configuration_preference` controls
269    /// whether editor settings override filesystem configs or vice versa.
270    fn merge_lsp_settings(&self, mut file_config: Config, lsp_config: &RumdlLspConfig) -> Config {
271        let Some(settings) = &lsp_config.settings else {
272            return file_config;
273        };
274
275        match lsp_config.configuration_preference {
276            ConfigurationPreference::EditorFirst => {
277                // Editor settings take priority - apply them on top of file config
278                self.apply_lsp_settings_to_config(&mut file_config, settings);
279            }
280            ConfigurationPreference::FilesystemFirst => {
281                // File config takes priority - only apply settings for values not in file config
282                self.apply_lsp_settings_if_absent(&mut file_config, settings);
283            }
284            ConfigurationPreference::EditorOnly => {
285                // Ignore file config completely - start from default and apply editor settings
286                let mut default_config = Config::default();
287                self.apply_lsp_settings_to_config(&mut default_config, settings);
288                return default_config;
289            }
290        }
291
292        file_config
293    }
294
295    /// Apply all LSP settings to config, overriding existing values
296    fn apply_lsp_settings_to_config(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
297        // Apply global line length
298        if let Some(line_length) = settings.line_length {
299            config.global.line_length = crate::types::LineLength::new(line_length);
300        }
301
302        // Apply disable list
303        if let Some(disable) = &settings.disable {
304            config.global.disable.extend(disable.iter().cloned());
305        }
306
307        // Apply enable list
308        if let Some(enable) = &settings.enable {
309            config.global.enable.extend(enable.iter().cloned());
310        }
311
312        // Apply per-rule settings (e.g., "MD013": { "lineLength": 120 })
313        for (rule_name, rule_config) in &settings.rules {
314            self.apply_rule_config(config, rule_name, rule_config);
315        }
316    }
317
318    /// Apply LSP settings to config only where file config doesn't specify values
319    fn apply_lsp_settings_if_absent(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
320        // Apply global line length only if using default value
321        // LineLength default is 80, so we can check if it's still the default
322        if config.global.line_length.get() == 80
323            && let Some(line_length) = settings.line_length
324        {
325            config.global.line_length = crate::types::LineLength::new(line_length);
326        }
327
328        // For disable/enable lists, we merge them (filesystem values are already there)
329        if let Some(disable) = &settings.disable {
330            config.global.disable.extend(disable.iter().cloned());
331        }
332
333        if let Some(enable) = &settings.enable {
334            config.global.enable.extend(enable.iter().cloned());
335        }
336
337        // Apply per-rule settings only if not already configured in file
338        for (rule_name, rule_config) in &settings.rules {
339            self.apply_rule_config_if_absent(config, rule_name, rule_config);
340        }
341    }
342
343    /// Apply per-rule configuration from LSP settings
344    ///
345    /// Converts JSON values from LSP settings to TOML values and merges them
346    /// into the config's rule-specific BTreeMap.
347    fn apply_rule_config(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
348        let rule_key = rule_name.to_uppercase();
349
350        // Get or create the rule config entry
351        let rule_entry = config.rules.entry(rule_key.clone()).or_default();
352
353        // Convert JSON object to TOML values and merge
354        if let Some(obj) = rule_config.as_object() {
355            for (key, value) in obj {
356                // Convert camelCase to snake_case for config compatibility
357                let config_key = Self::camel_to_snake(key);
358
359                // Convert JSON value to TOML value
360                if let Some(toml_value) = Self::json_to_toml(value) {
361                    rule_entry.values.insert(config_key, toml_value);
362                }
363            }
364        }
365    }
366
367    /// Apply per-rule configuration only if not already set in file config
368    fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
369        let rule_key = rule_name.to_uppercase();
370
371        // Check if rule already has configuration in file
372        let existing_rule = config.rules.get(&rule_key);
373        let has_existing = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
374
375        if has_existing {
376            // Rule already configured in file, skip LSP settings for this rule
377            log::debug!("Rule {rule_key} already configured in file, skipping LSP settings");
378            return;
379        }
380
381        // No existing config, apply LSP settings
382        self.apply_rule_config(config, rule_name, rule_config);
383    }
384
385    /// Convert camelCase to snake_case
386    fn camel_to_snake(s: &str) -> String {
387        let mut result = String::new();
388        for (i, c) in s.chars().enumerate() {
389            if c.is_uppercase() && i > 0 {
390                result.push('_');
391            }
392            result.push(c.to_lowercase().next().unwrap_or(c));
393        }
394        result
395    }
396
397    /// Convert a JSON value to a TOML value
398    fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
399        match json {
400            serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
401            serde_json::Value::Number(n) => {
402                if let Some(i) = n.as_i64() {
403                    Some(toml::Value::Integer(i))
404                } else {
405                    n.as_f64().map(toml::Value::Float)
406                }
407            }
408            serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
409            serde_json::Value::Array(arr) => {
410                let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
411                Some(toml::Value::Array(toml_arr))
412            }
413            serde_json::Value::Object(obj) => {
414                let mut table = toml::map::Map::new();
415                for (k, v) in obj {
416                    if let Some(toml_v) = Self::json_to_toml(v) {
417                        table.insert(Self::camel_to_snake(k), toml_v);
418                    }
419                }
420                Some(toml::Value::Table(table))
421            }
422            serde_json::Value::Null => None,
423        }
424    }
425
426    /// Check if a file URI should be excluded based on exclude patterns
427    async fn should_exclude_uri(&self, uri: &Url) -> bool {
428        // Try to convert URI to file path
429        let file_path = match uri.to_file_path() {
430            Ok(path) => path,
431            Err(_) => return false, // If we can't get a path, don't exclude
432        };
433
434        // Resolve configuration for this specific file to get its exclude patterns
435        let rumdl_config = self.resolve_config_for_file(&file_path).await;
436        let exclude_patterns = &rumdl_config.global.exclude;
437
438        // If no exclude patterns, don't exclude
439        if exclude_patterns.is_empty() {
440            return false;
441        }
442
443        // Convert path to relative path for pattern matching
444        // This matches the CLI behavior in find_markdown_files
445        let path_to_check = if file_path.is_absolute() {
446            // Try to make it relative to the current directory
447            if let Ok(cwd) = std::env::current_dir() {
448                // Canonicalize both paths to handle symlinks
449                if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
450                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
451                        relative.to_string_lossy().to_string()
452                    } else {
453                        // Path is absolute but not under cwd
454                        file_path.to_string_lossy().to_string()
455                    }
456                } else {
457                    // Canonicalization failed
458                    file_path.to_string_lossy().to_string()
459                }
460            } else {
461                file_path.to_string_lossy().to_string()
462            }
463        } else {
464            // Already relative
465            file_path.to_string_lossy().to_string()
466        };
467
468        // Check if path matches any exclude pattern
469        for pattern in exclude_patterns {
470            if let Ok(glob) = globset::Glob::new(pattern) {
471                let matcher = glob.compile_matcher();
472                if matcher.is_match(&path_to_check) {
473                    log::debug!("Excluding file from LSP linting: {path_to_check}");
474                    return true;
475                }
476            }
477        }
478
479        false
480    }
481
482    /// Lint a document and return diagnostics
483    pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
484        let config_guard = self.config.read().await;
485
486        // Skip linting if disabled
487        if !config_guard.enable_linting {
488            return Ok(Vec::new());
489        }
490
491        let lsp_config = config_guard.clone();
492        drop(config_guard); // Release config lock early
493
494        // Check if file should be excluded based on exclude patterns
495        if self.should_exclude_uri(uri).await {
496            return Ok(Vec::new());
497        }
498
499        // Resolve configuration for this specific file
500        let file_path = uri.to_file_path().ok();
501        let file_config = if let Some(ref path) = file_path {
502            self.resolve_config_for_file(path).await
503        } else {
504            // Fallback to global config for non-file URIs
505            (*self.rumdl_config.read().await).clone()
506        };
507
508        // Merge LSP settings with file config based on configuration_preference
509        let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
510
511        let all_rules = rules::all_rules(&rumdl_config);
512        let flavor = rumdl_config.markdown_flavor();
513
514        // Use the standard filter_rules function which respects config's disabled rules
515        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
516
517        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
518        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
519
520        // Run rumdl linting with the configured flavor
521        let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
522            Ok(warnings) => warnings,
523            Err(e) => {
524                log::error!("Failed to lint document {uri}: {e}");
525                return Ok(Vec::new());
526            }
527        };
528
529        // Run cross-file checks if workspace index is ready
530        if let Some(ref path) = file_path {
531            let index_state = self.index_state.read().await.clone();
532            if matches!(index_state, IndexState::Ready) {
533                let workspace_index = self.workspace_index.read().await;
534                if let Some(file_index) = workspace_index.get_file(path) {
535                    match crate::run_cross_file_checks(
536                        path,
537                        file_index,
538                        &filtered_rules,
539                        &workspace_index,
540                        Some(&rumdl_config),
541                    ) {
542                        Ok(cross_file_warnings) => {
543                            all_warnings.extend(cross_file_warnings);
544                        }
545                        Err(e) => {
546                            log::warn!("Failed to run cross-file checks for {uri}: {e}");
547                        }
548                    }
549                }
550            }
551        }
552
553        let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
554        Ok(diagnostics)
555    }
556
557    /// Update diagnostics for a document
558    ///
559    /// This method pushes diagnostics to the client via publishDiagnostics.
560    /// When the client supports pull diagnostics (textDocument/diagnostic),
561    /// we skip pushing to avoid duplicate diagnostics.
562    async fn update_diagnostics(&self, uri: Url, text: String) {
563        // Skip pushing if client supports pull diagnostics to avoid duplicates
564        if *self.client_supports_pull_diagnostics.read().await {
565            log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
566            return;
567        }
568
569        // Get the document version if available
570        let version = {
571            let docs = self.documents.read().await;
572            docs.get(&uri).and_then(|entry| entry.version)
573        };
574
575        match self.lint_document(&uri, &text).await {
576            Ok(diagnostics) => {
577                self.client.publish_diagnostics(uri, diagnostics, version).await;
578            }
579            Err(e) => {
580                log::error!("Failed to update diagnostics: {e}");
581            }
582        }
583    }
584
585    /// Apply all available fixes to a document
586    async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
587        // Check if file should be excluded based on exclude patterns
588        if self.should_exclude_uri(uri).await {
589            return Ok(None);
590        }
591
592        let config_guard = self.config.read().await;
593        let lsp_config = config_guard.clone();
594        drop(config_guard);
595
596        // Resolve configuration for this specific file
597        let file_config = if let Ok(file_path) = uri.to_file_path() {
598            self.resolve_config_for_file(&file_path).await
599        } else {
600            // Fallback to global config for non-file URIs
601            (*self.rumdl_config.read().await).clone()
602        };
603
604        // Merge LSP settings with file config based on configuration_preference
605        let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
606
607        let all_rules = rules::all_rules(&rumdl_config);
608        let flavor = rumdl_config.markdown_flavor();
609
610        // Use the standard filter_rules function which respects config's disabled rules
611        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
612
613        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
614        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
615
616        // First, run lint to get active warnings (respecting ignore comments)
617        // This tells us which rules actually have unfixed issues
618        let mut rules_with_warnings = std::collections::HashSet::new();
619        let mut fixed_text = text.to_string();
620
621        match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
622            Ok(warnings) => {
623                for warning in warnings {
624                    if let Some(rule_name) = &warning.rule_name {
625                        rules_with_warnings.insert(rule_name.clone());
626                    }
627                }
628            }
629            Err(e) => {
630                log::warn!("Failed to lint document for auto-fix: {e}");
631                return Ok(None);
632            }
633        }
634
635        // Early return if no warnings to fix
636        if rules_with_warnings.is_empty() {
637            return Ok(None);
638        }
639
640        // Only apply fixes for rules that have active warnings
641        let mut any_changes = false;
642
643        for rule in &filtered_rules {
644            // Skip rules that don't have any active warnings
645            if !rules_with_warnings.contains(rule.name()) {
646                continue;
647            }
648
649            let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
650            match rule.fix(&ctx) {
651                Ok(new_text) => {
652                    if new_text != fixed_text {
653                        fixed_text = new_text;
654                        any_changes = true;
655                    }
656                }
657                Err(e) => {
658                    // Only log if it's an actual error, not just "rule doesn't support auto-fix"
659                    let msg = e.to_string();
660                    if !msg.contains("does not support automatic fixing") {
661                        log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
662                    }
663                }
664            }
665        }
666
667        if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
668    }
669
670    /// Get the end position of a document
671    fn get_end_position(&self, text: &str) -> Position {
672        let mut line = 0u32;
673        let mut character = 0u32;
674
675        for ch in text.chars() {
676            if ch == '\n' {
677                line += 1;
678                character = 0;
679            } else {
680                character += 1;
681            }
682        }
683
684        Position { line, character }
685    }
686
687    /// Get code actions for diagnostics at a position
688    async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
689        let config_guard = self.config.read().await;
690        let lsp_config = config_guard.clone();
691        drop(config_guard);
692
693        // Resolve configuration for this specific file
694        let file_config = if let Ok(file_path) = uri.to_file_path() {
695            self.resolve_config_for_file(&file_path).await
696        } else {
697            // Fallback to global config for non-file URIs
698            (*self.rumdl_config.read().await).clone()
699        };
700
701        // Merge LSP settings with file config based on configuration_preference
702        let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
703
704        let all_rules = rules::all_rules(&rumdl_config);
705        let flavor = rumdl_config.markdown_flavor();
706
707        // Use the standard filter_rules function which respects config's disabled rules
708        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
709
710        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
711        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
712
713        match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
714            Ok(warnings) => {
715                let mut actions = Vec::new();
716                let mut fixable_count = 0;
717
718                for warning in &warnings {
719                    // Check if warning is within the requested range
720                    let warning_line = (warning.line.saturating_sub(1)) as u32;
721                    if warning_line >= range.start.line && warning_line <= range.end.line {
722                        // Get all code actions for this warning (fix + ignore actions)
723                        let mut warning_actions = warning_to_code_actions(warning, uri, text);
724                        actions.append(&mut warning_actions);
725
726                        if warning.fix.is_some() {
727                            fixable_count += 1;
728                        }
729                    }
730                }
731
732                // Add "Fix all" action if there are multiple fixable issues in range
733                if fixable_count > 1 {
734                    // Only apply fixes from fixable rules during "Fix all"
735                    // Unfixable rules provide warning-level fixes for individual Quick Fix actions
736                    let fixable_warnings: Vec<_> = warnings
737                        .iter()
738                        .filter(|w| {
739                            if let Some(rule_name) = &w.rule_name {
740                                filtered_rules
741                                    .iter()
742                                    .find(|r| r.name() == rule_name)
743                                    .map(|r| r.fix_capability() != FixCapability::Unfixable)
744                                    .unwrap_or(false)
745                            } else {
746                                false
747                            }
748                        })
749                        .cloned()
750                        .collect();
751
752                    // Count total fixable issues (excluding Unfixable rules)
753                    let total_fixable = fixable_warnings.len();
754
755                    if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
756                        && fixed_content != text
757                    {
758                        // Calculate proper end position
759                        let mut line = 0u32;
760                        let mut character = 0u32;
761                        for ch in text.chars() {
762                            if ch == '\n' {
763                                line += 1;
764                                character = 0;
765                            } else {
766                                character += 1;
767                            }
768                        }
769
770                        let fix_all_action = CodeAction {
771                            title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
772                            kind: Some(CodeActionKind::QUICKFIX),
773                            diagnostics: Some(Vec::new()),
774                            edit: Some(WorkspaceEdit {
775                                changes: Some(
776                                    [(
777                                        uri.clone(),
778                                        vec![TextEdit {
779                                            range: Range {
780                                                start: Position { line: 0, character: 0 },
781                                                end: Position { line, character },
782                                            },
783                                            new_text: fixed_content,
784                                        }],
785                                    )]
786                                    .into_iter()
787                                    .collect(),
788                                ),
789                                ..Default::default()
790                            }),
791                            command: None,
792                            is_preferred: Some(true),
793                            disabled: None,
794                            data: None,
795                        };
796
797                        // Insert at the beginning to make it prominent
798                        actions.insert(0, fix_all_action);
799                    }
800                }
801
802                Ok(actions)
803            }
804            Err(e) => {
805                log::error!("Failed to get code actions: {e}");
806                Ok(Vec::new())
807            }
808        }
809    }
810
811    /// Load or reload rumdl configuration from files
812    async fn load_configuration(&self, notify_client: bool) {
813        let config_guard = self.config.read().await;
814        let explicit_config_path = config_guard.config_path.clone();
815        drop(config_guard);
816
817        // Use the same discovery logic as CLI but with LSP-specific error handling
818        match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
819            Ok(sourced_config) => {
820                let loaded_files = sourced_config.loaded_files.clone();
821                // Use into_validated_unchecked since LSP doesn't need validation warnings
822                *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
823
824                if !loaded_files.is_empty() {
825                    let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
826                    log::info!("{message}");
827                    if notify_client {
828                        self.client.log_message(MessageType::INFO, &message).await;
829                    }
830                } else {
831                    log::info!("Using default rumdl configuration (no config files found)");
832                }
833            }
834            Err(e) => {
835                let message = format!("Failed to load rumdl config: {e}");
836                log::warn!("{message}");
837                if notify_client {
838                    self.client.log_message(MessageType::WARNING, &message).await;
839                }
840                // Use default configuration
841                *self.rumdl_config.write().await = crate::config::Config::default();
842            }
843        }
844    }
845
846    /// Reload rumdl configuration from files (with client notification)
847    async fn reload_configuration(&self) {
848        self.load_configuration(true).await;
849    }
850
851    /// Load configuration for LSP - similar to CLI loading but returns Result
852    fn load_config_for_lsp(
853        config_path: Option<&str>,
854    ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
855        // Use the same configuration loading as the CLI
856        crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
857    }
858
859    /// Resolve configuration for a specific file
860    ///
861    /// This method searches for a configuration file starting from the file's directory
862    /// and walking up the directory tree until a workspace root is hit or a config is found.
863    ///
864    /// Results are cached to avoid repeated filesystem access.
865    pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
866        // Get the directory to start searching from
867        let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
868
869        // Check cache first
870        {
871            let cache = self.config_cache.read().await;
872            if let Some(entry) = cache.get(&search_dir) {
873                let source_owned: String; // ensure owned storage for logging
874                let source: &str = if entry.from_global_fallback {
875                    "global/user fallback"
876                } else if let Some(path) = &entry.config_file {
877                    source_owned = path.to_string_lossy().to_string();
878                    &source_owned
879                } else {
880                    "<unknown>"
881                };
882                log::debug!(
883                    "Config cache hit for directory: {} (loaded from: {})",
884                    search_dir.display(),
885                    source
886                );
887                return entry.config.clone();
888            }
889        }
890
891        // Cache miss - need to search for config
892        log::debug!(
893            "Config cache miss for directory: {}, searching for config...",
894            search_dir.display()
895        );
896
897        // Try to find workspace root for this file
898        let workspace_root = {
899            let workspace_roots = self.workspace_roots.read().await;
900            workspace_roots
901                .iter()
902                .find(|root| search_dir.starts_with(root))
903                .map(|p| p.to_path_buf())
904        };
905
906        // Search upward from the file's directory
907        let mut current_dir = search_dir.clone();
908        let mut found_config: Option<(Config, Option<PathBuf>)> = None;
909
910        loop {
911            // Try to find a config file in the current directory
912            const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
913
914            for config_file_name in CONFIG_FILES {
915                let config_path = current_dir.join(config_file_name);
916                if config_path.exists() {
917                    // For pyproject.toml, verify it contains [tool.rumdl] section (same as CLI)
918                    if *config_file_name == "pyproject.toml" {
919                        if let Ok(content) = std::fs::read_to_string(&config_path) {
920                            if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
921                                log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
922                            } else {
923                                log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
924                                continue;
925                            }
926                        } else {
927                            log::warn!("Failed to read pyproject.toml: {}", config_path.display());
928                            continue;
929                        }
930                    } else {
931                        log::debug!("Found config file: {}", config_path.display());
932                    }
933
934                    // Load the config
935                    if let Some(config_path_str) = config_path.to_str() {
936                        if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
937                            found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
938                            break;
939                        }
940                    } else {
941                        log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
942                    }
943                }
944            }
945
946            if found_config.is_some() {
947                break;
948            }
949
950            // Check if we've hit a workspace root
951            if let Some(ref root) = workspace_root
952                && &current_dir == root
953            {
954                log::debug!("Hit workspace root without finding config: {}", root.display());
955                break;
956            }
957
958            // Move up to parent directory
959            if let Some(parent) = current_dir.parent() {
960                current_dir = parent.to_path_buf();
961            } else {
962                // Hit filesystem root
963                break;
964            }
965        }
966
967        // Use found config or fall back to global/user config loaded at initialization
968        let (config, config_file) = if let Some((cfg, path)) = found_config {
969            (cfg, path)
970        } else {
971            log::debug!("No project config found; using global/user fallback config");
972            let fallback = self.rumdl_config.read().await.clone();
973            (fallback, None)
974        };
975
976        // Cache the result
977        let from_global = config_file.is_none();
978        let entry = ConfigCacheEntry {
979            config: config.clone(),
980            config_file,
981            from_global_fallback: from_global,
982        };
983
984        self.config_cache.write().await.insert(search_dir, entry);
985
986        config
987    }
988}
989
990#[tower_lsp::async_trait]
991impl LanguageServer for RumdlLanguageServer {
992    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
993        log::info!("Initializing rumdl Language Server");
994
995        // Parse client capabilities and configuration
996        if let Some(options) = params.initialization_options
997            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
998        {
999            *self.config.write().await = config;
1000        }
1001
1002        // Detect if client supports pull diagnostics (textDocument/diagnostic)
1003        // When the client supports pull, we avoid pushing to prevent duplicate diagnostics
1004        let supports_pull = params
1005            .capabilities
1006            .text_document
1007            .as_ref()
1008            .and_then(|td| td.diagnostic.as_ref())
1009            .is_some();
1010
1011        if supports_pull {
1012            log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1013            *self.client_supports_pull_diagnostics.write().await = true;
1014        } else {
1015            log::info!("Client does not support pull diagnostics - using push model");
1016        }
1017
1018        // Extract and store workspace roots
1019        let mut roots = Vec::new();
1020        if let Some(workspace_folders) = params.workspace_folders {
1021            for folder in workspace_folders {
1022                if let Ok(path) = folder.uri.to_file_path() {
1023                    log::info!("Workspace root: {}", path.display());
1024                    roots.push(path);
1025                }
1026            }
1027        } else if let Some(root_uri) = params.root_uri
1028            && let Ok(path) = root_uri.to_file_path()
1029        {
1030            log::info!("Workspace root: {}", path.display());
1031            roots.push(path);
1032        }
1033        *self.workspace_roots.write().await = roots;
1034
1035        // Load rumdl configuration with auto-discovery (fallback/default)
1036        self.load_configuration(false).await;
1037
1038        Ok(InitializeResult {
1039            capabilities: ServerCapabilities {
1040                text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1041                    open_close: Some(true),
1042                    change: Some(TextDocumentSyncKind::FULL),
1043                    will_save: Some(false),
1044                    will_save_wait_until: Some(true),
1045                    save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1046                        include_text: Some(false),
1047                    })),
1048                })),
1049                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1050                document_formatting_provider: Some(OneOf::Left(true)),
1051                document_range_formatting_provider: Some(OneOf::Left(true)),
1052                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1053                    identifier: Some("rumdl".to_string()),
1054                    inter_file_dependencies: true,
1055                    workspace_diagnostics: true,
1056                    work_done_progress_options: WorkDoneProgressOptions::default(),
1057                })),
1058                workspace: Some(WorkspaceServerCapabilities {
1059                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1060                        supported: Some(true),
1061                        change_notifications: Some(OneOf::Left(true)),
1062                    }),
1063                    file_operations: None,
1064                }),
1065                ..Default::default()
1066            },
1067            server_info: Some(ServerInfo {
1068                name: "rumdl".to_string(),
1069                version: Some(env!("CARGO_PKG_VERSION").to_string()),
1070            }),
1071        })
1072    }
1073
1074    async fn initialized(&self, _: InitializedParams) {
1075        let version = env!("CARGO_PKG_VERSION");
1076
1077        // Get binary path and build time
1078        let (binary_path, build_time) = std::env::current_exe()
1079            .ok()
1080            .map(|path| {
1081                let path_str = path.to_str().unwrap_or("unknown").to_string();
1082                let build_time = std::fs::metadata(&path)
1083                    .ok()
1084                    .and_then(|metadata| metadata.modified().ok())
1085                    .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1086                    .and_then(|duration| {
1087                        let secs = duration.as_secs();
1088                        chrono::DateTime::from_timestamp(secs as i64, 0)
1089                            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1090                    })
1091                    .unwrap_or_else(|| "unknown".to_string());
1092                (path_str, build_time)
1093            })
1094            .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1095
1096        let working_dir = std::env::current_dir()
1097            .ok()
1098            .and_then(|p| p.to_str().map(|s| s.to_string()))
1099            .unwrap_or_else(|| "unknown".to_string());
1100
1101        log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1102        log::info!("Working directory: {working_dir}");
1103
1104        self.client
1105            .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1106            .await;
1107
1108        // Trigger initial workspace indexing for cross-file analysis
1109        if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1110            log::warn!("Failed to trigger initial workspace indexing");
1111        } else {
1112            log::info!("Triggered initial workspace indexing for cross-file analysis");
1113        }
1114
1115        // Register file watcher for markdown files to detect external changes
1116        // Watch all supported markdown extensions
1117        let markdown_patterns = [
1118            "**/*.md",
1119            "**/*.markdown",
1120            "**/*.mdx",
1121            "**/*.mkd",
1122            "**/*.mkdn",
1123            "**/*.mdown",
1124            "**/*.mdwn",
1125            "**/*.qmd",
1126            "**/*.rmd",
1127        ];
1128        let watchers: Vec<_> = markdown_patterns
1129            .iter()
1130            .map(|pattern| FileSystemWatcher {
1131                glob_pattern: GlobPattern::String((*pattern).to_string()),
1132                kind: Some(WatchKind::all()),
1133            })
1134            .collect();
1135
1136        let registration = Registration {
1137            id: "markdown-watcher".to_string(),
1138            method: "workspace/didChangeWatchedFiles".to_string(),
1139            register_options: Some(
1140                serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1141            ),
1142        };
1143
1144        if self.client.register_capability(vec![registration]).await.is_err() {
1145            log::debug!("Client does not support file watching capability");
1146        }
1147    }
1148
1149    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1150        // Update workspace roots
1151        let mut roots = self.workspace_roots.write().await;
1152
1153        // Remove deleted workspace folders
1154        for removed in &params.event.removed {
1155            if let Ok(path) = removed.uri.to_file_path() {
1156                roots.retain(|r| r != &path);
1157                log::info!("Removed workspace root: {}", path.display());
1158            }
1159        }
1160
1161        // Add new workspace folders
1162        for added in &params.event.added {
1163            if let Ok(path) = added.uri.to_file_path()
1164                && !roots.contains(&path)
1165            {
1166                log::info!("Added workspace root: {}", path.display());
1167                roots.push(path);
1168            }
1169        }
1170        drop(roots);
1171
1172        // Clear config cache as workspace structure changed
1173        self.config_cache.write().await.clear();
1174
1175        // Reload fallback configuration
1176        self.reload_configuration().await;
1177
1178        // Trigger full workspace rescan for cross-file index
1179        if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1180            log::warn!("Failed to trigger workspace rescan after folder change");
1181        }
1182    }
1183
1184    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1185        log::debug!("Configuration changed: {:?}", params.settings);
1186
1187        // Parse settings from the notification
1188        // Neovim sends: { "rumdl": { "MD013": {...}, ... } }
1189        // VSCode might send the full RumdlLspConfig or similar structure
1190        let settings_value = params.settings;
1191
1192        // Try to extract "rumdl" key from settings (Neovim style)
1193        let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1194            obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1195        } else {
1196            settings_value
1197        };
1198
1199        // Track if we successfully applied any configuration
1200        let mut config_applied = false;
1201        let mut warnings: Vec<String> = Vec::new();
1202
1203        // Try to parse as LspRuleSettings first (Neovim style with "disable", "enable", rule keys)
1204        // We check this first because RumdlLspConfig with #[serde(default)] will accept any JSON
1205        // and just ignore unknown fields, which would lose the Neovim-style settings
1206        if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1207            && (rule_settings.disable.is_some()
1208                || rule_settings.enable.is_some()
1209                || rule_settings.line_length.is_some()
1210                || !rule_settings.rules.is_empty())
1211        {
1212            // Validate rule names in disable/enable lists
1213            if let Some(ref disable) = rule_settings.disable {
1214                for rule in disable {
1215                    if !Self::is_valid_rule_name(rule) {
1216                        warnings.push(format!("Unknown rule in disable list: {rule}"));
1217                    }
1218                }
1219            }
1220            if let Some(ref enable) = rule_settings.enable {
1221                for rule in enable {
1222                    if !Self::is_valid_rule_name(rule) {
1223                        warnings.push(format!("Unknown rule in enable list: {rule}"));
1224                    }
1225                }
1226            }
1227            // Validate rule-specific settings
1228            for rule_name in rule_settings.rules.keys() {
1229                if !Self::is_valid_rule_name(rule_name) {
1230                    warnings.push(format!("Unknown rule in settings: {rule_name}"));
1231                }
1232            }
1233
1234            log::info!("Applied rule settings from configuration (Neovim style)");
1235            let mut config = self.config.write().await;
1236            config.settings = Some(rule_settings);
1237            drop(config);
1238            config_applied = true;
1239        } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1240            && (full_config.config_path.is_some()
1241                || full_config.enable_rules.is_some()
1242                || full_config.disable_rules.is_some()
1243                || full_config.settings.is_some()
1244                || !full_config.enable_linting
1245                || full_config.enable_auto_fix)
1246        {
1247            // Validate rule names
1248            if let Some(ref rules) = full_config.enable_rules {
1249                for rule in rules {
1250                    if !Self::is_valid_rule_name(rule) {
1251                        warnings.push(format!("Unknown rule in enableRules: {rule}"));
1252                    }
1253                }
1254            }
1255            if let Some(ref rules) = full_config.disable_rules {
1256                for rule in rules {
1257                    if !Self::is_valid_rule_name(rule) {
1258                        warnings.push(format!("Unknown rule in disableRules: {rule}"));
1259                    }
1260                }
1261            }
1262
1263            log::info!("Applied full LSP configuration from settings");
1264            *self.config.write().await = full_config;
1265            config_applied = true;
1266        } else if let serde_json::Value::Object(obj) = rumdl_settings {
1267            // Otherwise, treat as per-rule settings with manual parsing
1268            // Format: { "MD013": { "lineLength": 80 }, "disable": ["MD009"] }
1269            let mut config = self.config.write().await;
1270
1271            // Manual parsing for Neovim format
1272            let mut rules = std::collections::HashMap::new();
1273            let mut disable = Vec::new();
1274            let mut enable = Vec::new();
1275            let mut line_length = None;
1276
1277            for (key, value) in obj {
1278                match key.as_str() {
1279                    "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1280                        Ok(d) => {
1281                            if d.len() > MAX_RULE_LIST_SIZE {
1282                                warnings.push(format!(
1283                                    "Too many rules in 'disable' ({} > {}), truncating",
1284                                    d.len(),
1285                                    MAX_RULE_LIST_SIZE
1286                                ));
1287                            }
1288                            for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1289                                if !Self::is_valid_rule_name(rule) {
1290                                    warnings.push(format!("Unknown rule in disable: {rule}"));
1291                                }
1292                            }
1293                            disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1294                        }
1295                        Err(_) => {
1296                            warnings.push(format!(
1297                                "Invalid 'disable' value: expected array of strings, got {value}"
1298                            ));
1299                        }
1300                    },
1301                    "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1302                        Ok(e) => {
1303                            if e.len() > MAX_RULE_LIST_SIZE {
1304                                warnings.push(format!(
1305                                    "Too many rules in 'enable' ({} > {}), truncating",
1306                                    e.len(),
1307                                    MAX_RULE_LIST_SIZE
1308                                ));
1309                            }
1310                            for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1311                                if !Self::is_valid_rule_name(rule) {
1312                                    warnings.push(format!("Unknown rule in enable: {rule}"));
1313                                }
1314                            }
1315                            enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1316                        }
1317                        Err(_) => {
1318                            warnings.push(format!(
1319                                "Invalid 'enable' value: expected array of strings, got {value}"
1320                            ));
1321                        }
1322                    },
1323                    "lineLength" | "line_length" | "line-length" => {
1324                        if let Some(l) = value.as_u64() {
1325                            match usize::try_from(l) {
1326                                Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1327                                Ok(len) => warnings.push(format!(
1328                                    "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1329                                )),
1330                                Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1331                            }
1332                        } else {
1333                            warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1334                        }
1335                    }
1336                    // Rule-specific settings (e.g., "MD013": { "lineLength": 80 })
1337                    _ if key.starts_with("MD") || key.starts_with("md") => {
1338                        let normalized = key.to_uppercase();
1339                        if !Self::is_valid_rule_name(&normalized) {
1340                            warnings.push(format!("Unknown rule: {key}"));
1341                        }
1342                        rules.insert(normalized, value);
1343                    }
1344                    _ => {
1345                        // Unknown key - warn and ignore
1346                        warnings.push(format!("Unknown configuration key: {key}"));
1347                    }
1348                }
1349            }
1350
1351            let settings = LspRuleSettings {
1352                line_length,
1353                disable: if disable.is_empty() { None } else { Some(disable) },
1354                enable: if enable.is_empty() { None } else { Some(enable) },
1355                rules,
1356            };
1357
1358            log::info!("Applied Neovim-style rule settings (manual parse)");
1359            config.settings = Some(settings);
1360            drop(config);
1361            config_applied = true;
1362        } else {
1363            log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1364        }
1365
1366        // Log warnings for invalid configuration
1367        for warning in &warnings {
1368            log::warn!("{warning}");
1369        }
1370
1371        // Notify client of configuration warnings via window/logMessage
1372        if !warnings.is_empty() {
1373            let message = if warnings.len() == 1 {
1374                format!("rumdl: {}", warnings[0])
1375            } else {
1376                format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1377            };
1378            self.client.log_message(MessageType::WARNING, message).await;
1379        }
1380
1381        if !config_applied {
1382            log::debug!("No configuration changes applied");
1383        }
1384
1385        // Clear config cache to pick up new settings
1386        self.config_cache.write().await.clear();
1387
1388        // Collect all open documents first (to avoid holding lock during async operations)
1389        let doc_list: Vec<_> = {
1390            let documents = self.documents.read().await;
1391            documents
1392                .iter()
1393                .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1394                .collect()
1395        };
1396
1397        // Refresh diagnostics for all open documents concurrently
1398        let tasks = doc_list.into_iter().map(|(uri, text)| {
1399            let server = self.clone();
1400            tokio::spawn(async move {
1401                server.update_diagnostics(uri, text).await;
1402            })
1403        });
1404
1405        // Wait for all diagnostics to complete
1406        let _ = join_all(tasks).await;
1407    }
1408
1409    async fn shutdown(&self) -> JsonRpcResult<()> {
1410        log::info!("Shutting down rumdl Language Server");
1411
1412        // Signal the index worker to shut down
1413        let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1414
1415        Ok(())
1416    }
1417
1418    async fn did_open(&self, params: DidOpenTextDocumentParams) {
1419        let uri = params.text_document.uri;
1420        let text = params.text_document.text;
1421        let version = params.text_document.version;
1422
1423        let entry = DocumentEntry {
1424            content: text.clone(),
1425            version: Some(version),
1426            from_disk: false,
1427        };
1428        self.documents.write().await.insert(uri.clone(), entry);
1429
1430        // Send update to index worker for cross-file analysis
1431        if let Ok(path) = uri.to_file_path() {
1432            let _ = self
1433                .update_tx
1434                .send(IndexUpdate::FileChanged {
1435                    path,
1436                    content: text.clone(),
1437                })
1438                .await;
1439        }
1440
1441        self.update_diagnostics(uri, text).await;
1442    }
1443
1444    async fn did_change(&self, params: DidChangeTextDocumentParams) {
1445        let uri = params.text_document.uri;
1446        let version = params.text_document.version;
1447
1448        if let Some(change) = params.content_changes.into_iter().next() {
1449            let text = change.text;
1450
1451            let entry = DocumentEntry {
1452                content: text.clone(),
1453                version: Some(version),
1454                from_disk: false,
1455            };
1456            self.documents.write().await.insert(uri.clone(), entry);
1457
1458            // Send update to index worker for cross-file analysis
1459            if let Ok(path) = uri.to_file_path() {
1460                let _ = self
1461                    .update_tx
1462                    .send(IndexUpdate::FileChanged {
1463                        path,
1464                        content: text.clone(),
1465                    })
1466                    .await;
1467            }
1468
1469            self.update_diagnostics(uri, text).await;
1470        }
1471    }
1472
1473    async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1474        let config_guard = self.config.read().await;
1475        let enable_auto_fix = config_guard.enable_auto_fix;
1476        drop(config_guard);
1477
1478        if !enable_auto_fix {
1479            return Ok(None);
1480        }
1481
1482        // Get the current document content
1483        let Some(text) = self.get_document_content(&params.text_document.uri).await else {
1484            return Ok(None);
1485        };
1486
1487        // Apply all fixes
1488        match self.apply_all_fixes(&params.text_document.uri, &text).await {
1489            Ok(Some(fixed_text)) => {
1490                // Return a single edit that replaces the entire document
1491                Ok(Some(vec![TextEdit {
1492                    range: Range {
1493                        start: Position { line: 0, character: 0 },
1494                        end: self.get_end_position(&text),
1495                    },
1496                    new_text: fixed_text,
1497                }]))
1498            }
1499            Ok(None) => Ok(None),
1500            Err(e) => {
1501                log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1502                Ok(None)
1503            }
1504        }
1505    }
1506
1507    async fn did_save(&self, params: DidSaveTextDocumentParams) {
1508        // Re-lint the document after save
1509        // Note: Auto-fixing is now handled by will_save_wait_until which runs before the save
1510        if let Some(entry) = self.documents.read().await.get(&params.text_document.uri) {
1511            self.update_diagnostics(params.text_document.uri, entry.content.clone())
1512                .await;
1513        }
1514    }
1515
1516    async fn did_close(&self, params: DidCloseTextDocumentParams) {
1517        // Remove document from storage
1518        self.documents.write().await.remove(&params.text_document.uri);
1519
1520        // Always clear diagnostics on close to ensure cleanup
1521        // (Ruff does this unconditionally as a defensive measure)
1522        self.client
1523            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1524            .await;
1525    }
1526
1527    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1528        // Check if any of the changed files are config files
1529        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1530
1531        let mut config_changed = false;
1532
1533        for change in &params.changes {
1534            if let Ok(path) = change.uri.to_file_path() {
1535                let file_name = path.file_name().and_then(|f| f.to_str());
1536                let extension = path.extension().and_then(|e| e.to_str());
1537
1538                // Handle config file changes
1539                if let Some(name) = file_name
1540                    && CONFIG_FILES.contains(&name)
1541                    && !config_changed
1542                {
1543                    log::info!("Config file changed: {}, invalidating config cache", path.display());
1544
1545                    // Invalidate all cache entries that were loaded from this config file
1546                    let mut cache = self.config_cache.write().await;
1547                    cache.retain(|_, entry| {
1548                        if let Some(config_file) = &entry.config_file {
1549                            config_file != &path
1550                        } else {
1551                            true
1552                        }
1553                    });
1554
1555                    // Also reload the global fallback configuration
1556                    drop(cache);
1557                    self.reload_configuration().await;
1558                    config_changed = true;
1559                }
1560
1561                // Handle markdown file changes for workspace index
1562                if let Some(ext) = extension
1563                    && is_markdown_extension(ext)
1564                {
1565                    match change.typ {
1566                        FileChangeType::CREATED | FileChangeType::CHANGED => {
1567                            // Read file content and update index
1568                            if let Ok(content) = tokio::fs::read_to_string(&path).await {
1569                                let _ = self
1570                                    .update_tx
1571                                    .send(IndexUpdate::FileChanged {
1572                                        path: path.clone(),
1573                                        content,
1574                                    })
1575                                    .await;
1576                            }
1577                        }
1578                        FileChangeType::DELETED => {
1579                            let _ = self
1580                                .update_tx
1581                                .send(IndexUpdate::FileDeleted { path: path.clone() })
1582                                .await;
1583                        }
1584                        _ => {}
1585                    }
1586                }
1587            }
1588        }
1589
1590        // Re-lint all open documents if config changed
1591        if config_changed {
1592            let docs_to_update: Vec<(Url, String)> = {
1593                let docs = self.documents.read().await;
1594                docs.iter()
1595                    .filter(|(_, entry)| !entry.from_disk)
1596                    .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1597                    .collect()
1598            };
1599
1600            for (uri, text) in docs_to_update {
1601                self.update_diagnostics(uri, text).await;
1602            }
1603        }
1604    }
1605
1606    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1607        let uri = params.text_document.uri;
1608        let range = params.range;
1609
1610        if let Some(text) = self.get_document_content(&uri).await {
1611            match self.get_code_actions(&uri, &text, range).await {
1612                Ok(actions) => {
1613                    let response: Vec<CodeActionOrCommand> =
1614                        actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1615                    Ok(Some(response))
1616                }
1617                Err(e) => {
1618                    log::error!("Failed to get code actions: {e}");
1619                    Ok(None)
1620                }
1621            }
1622        } else {
1623            Ok(None)
1624        }
1625    }
1626
1627    async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1628        // For markdown linting, we format the entire document because:
1629        // 1. Many markdown rules have document-wide implications (e.g., heading hierarchy, list consistency)
1630        // 2. Fixes often need surrounding context to be applied correctly
1631        // 3. This approach is common among linters (ESLint, rustfmt, etc. do similar)
1632        log::debug!(
1633            "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1634            params.range
1635        );
1636
1637        let formatting_params = DocumentFormattingParams {
1638            text_document: params.text_document,
1639            options: params.options,
1640            work_done_progress_params: params.work_done_progress_params,
1641        };
1642
1643        self.formatting(formatting_params).await
1644    }
1645
1646    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1647        let uri = params.text_document.uri;
1648
1649        log::debug!("Formatting request for: {uri}");
1650
1651        if let Some(text) = self.get_document_content(&uri).await {
1652            // Get config with LSP overrides
1653            let config_guard = self.config.read().await;
1654            let lsp_config = config_guard.clone();
1655            drop(config_guard);
1656
1657            // Resolve configuration for this specific file
1658            let file_config = if let Ok(file_path) = uri.to_file_path() {
1659                self.resolve_config_for_file(&file_path).await
1660            } else {
1661                // Fallback to global config for non-file URIs
1662                self.rumdl_config.read().await.clone()
1663            };
1664
1665            // Merge LSP settings with file config based on configuration_preference
1666            let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1667
1668            let all_rules = rules::all_rules(&rumdl_config);
1669            let flavor = rumdl_config.markdown_flavor();
1670
1671            // Use the standard filter_rules function which respects config's disabled rules
1672            let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1673
1674            // Apply LSP config overrides
1675            filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1676
1677            // Use warning fixes for all rules
1678            match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1679                Ok(warnings) => {
1680                    log::debug!(
1681                        "Found {} warnings, {} with fixes",
1682                        warnings.len(),
1683                        warnings.iter().filter(|w| w.fix.is_some()).count()
1684                    );
1685
1686                    let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1687                    if has_fixes {
1688                        // Only apply fixes from fixable rules during formatting
1689                        // Unfixable rules provide warning-level fixes for Quick Fix actions,
1690                        // but should not be applied during bulk format operations
1691                        let fixable_warnings: Vec<_> = warnings
1692                            .iter()
1693                            .filter(|w| {
1694                                if let Some(rule_name) = &w.rule_name {
1695                                    filtered_rules
1696                                        .iter()
1697                                        .find(|r| r.name() == rule_name)
1698                                        .map(|r| r.fix_capability() != FixCapability::Unfixable)
1699                                        .unwrap_or(false)
1700                                } else {
1701                                    false
1702                                }
1703                            })
1704                            .cloned()
1705                            .collect();
1706
1707                        match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1708                            Ok(fixed_content) => {
1709                                if fixed_content != text {
1710                                    log::debug!("Returning formatting edits");
1711                                    let end_position = self.get_end_position(&text);
1712                                    let edit = TextEdit {
1713                                        range: Range {
1714                                            start: Position { line: 0, character: 0 },
1715                                            end: end_position,
1716                                        },
1717                                        new_text: fixed_content,
1718                                    };
1719                                    return Ok(Some(vec![edit]));
1720                                }
1721                            }
1722                            Err(e) => {
1723                                log::error!("Failed to apply fixes: {e}");
1724                            }
1725                        }
1726                    }
1727                    Ok(Some(Vec::new()))
1728                }
1729                Err(e) => {
1730                    log::error!("Failed to format document: {e}");
1731                    Ok(Some(Vec::new()))
1732                }
1733            }
1734        } else {
1735            log::warn!("Document not found: {uri}");
1736            Ok(None)
1737        }
1738    }
1739
1740    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1741        let uri = params.text_document.uri;
1742
1743        if let Some(text) = self.get_document_content(&uri).await {
1744            match self.lint_document(&uri, &text).await {
1745                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1746                    RelatedFullDocumentDiagnosticReport {
1747                        related_documents: None,
1748                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
1749                            result_id: None,
1750                            items: diagnostics,
1751                        },
1752                    },
1753                ))),
1754                Err(e) => {
1755                    log::error!("Failed to get diagnostics: {e}");
1756                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1757                        RelatedFullDocumentDiagnosticReport {
1758                            related_documents: None,
1759                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
1760                                result_id: None,
1761                                items: Vec::new(),
1762                            },
1763                        },
1764                    )))
1765                }
1766            }
1767        } else {
1768            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1769                RelatedFullDocumentDiagnosticReport {
1770                    related_documents: None,
1771                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
1772                        result_id: None,
1773                        items: Vec::new(),
1774                    },
1775                },
1776            )))
1777        }
1778    }
1779}
1780
1781#[cfg(test)]
1782mod tests {
1783    use super::*;
1784    use crate::rule::LintWarning;
1785    use tower_lsp::LspService;
1786
1787    fn create_test_server() -> RumdlLanguageServer {
1788        let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1789        service.inner().clone()
1790    }
1791
1792    #[test]
1793    fn test_is_valid_rule_name() {
1794        // Valid rule names - basic cases
1795        assert!(RumdlLanguageServer::is_valid_rule_name("MD001"));
1796        assert!(RumdlLanguageServer::is_valid_rule_name("md001")); // lowercase
1797        assert!(RumdlLanguageServer::is_valid_rule_name("Md001")); // mixed case
1798        assert!(RumdlLanguageServer::is_valid_rule_name("mD001")); // mixed case
1799        assert!(RumdlLanguageServer::is_valid_rule_name("all")); // special value
1800        assert!(RumdlLanguageServer::is_valid_rule_name("ALL")); // case insensitive
1801        assert!(RumdlLanguageServer::is_valid_rule_name("All")); // mixed case
1802
1803        // Valid rule names - boundary conditions for each range
1804        assert!(RumdlLanguageServer::is_valid_rule_name("MD003")); // start of 3..=5
1805        assert!(RumdlLanguageServer::is_valid_rule_name("MD005")); // end of 3..=5
1806        assert!(RumdlLanguageServer::is_valid_rule_name("MD007")); // single value
1807        assert!(RumdlLanguageServer::is_valid_rule_name("MD009")); // start of 9..=14
1808        assert!(RumdlLanguageServer::is_valid_rule_name("MD014")); // end of 9..=14
1809        assert!(RumdlLanguageServer::is_valid_rule_name("MD018")); // start of 18..=62
1810        assert!(RumdlLanguageServer::is_valid_rule_name("MD062")); // end of 18..=62
1811
1812        // Valid rule names - sample from middle of range
1813        assert!(RumdlLanguageServer::is_valid_rule_name("MD041")); // mid-range
1814        assert!(RumdlLanguageServer::is_valid_rule_name("MD060")); // near end
1815        assert!(RumdlLanguageServer::is_valid_rule_name("MD061")); // near end
1816
1817        // Invalid rule names - gaps in numbering
1818        assert!(!RumdlLanguageServer::is_valid_rule_name("MD002")); // doesn't exist
1819        assert!(!RumdlLanguageServer::is_valid_rule_name("MD006")); // doesn't exist
1820        assert!(!RumdlLanguageServer::is_valid_rule_name("MD008")); // doesn't exist
1821        assert!(!RumdlLanguageServer::is_valid_rule_name("MD015")); // doesn't exist
1822        assert!(!RumdlLanguageServer::is_valid_rule_name("MD016")); // doesn't exist
1823        assert!(!RumdlLanguageServer::is_valid_rule_name("MD017")); // doesn't exist
1824
1825        // Invalid rule names - out of range
1826        assert!(!RumdlLanguageServer::is_valid_rule_name("MD000")); // too low
1827        assert!(!RumdlLanguageServer::is_valid_rule_name("MD063")); // too high
1828        assert!(!RumdlLanguageServer::is_valid_rule_name("MD999")); // way too high
1829
1830        // Invalid format
1831        assert!(!RumdlLanguageServer::is_valid_rule_name("MD13")); // missing leading zero
1832        assert!(!RumdlLanguageServer::is_valid_rule_name("INVALID"));
1833        assert!(!RumdlLanguageServer::is_valid_rule_name(""));
1834        assert!(!RumdlLanguageServer::is_valid_rule_name("MD"));
1835        assert!(!RumdlLanguageServer::is_valid_rule_name("MD0001")); // too many digits
1836        assert!(!RumdlLanguageServer::is_valid_rule_name("MD1")); // not enough digits
1837    }
1838
1839    #[tokio::test]
1840    async fn test_server_creation() {
1841        let server = create_test_server();
1842
1843        // Verify default configuration
1844        let config = server.config.read().await;
1845        assert!(config.enable_linting);
1846        assert!(!config.enable_auto_fix);
1847    }
1848
1849    #[tokio::test]
1850    async fn test_lint_document() {
1851        let server = create_test_server();
1852
1853        // Test linting with a simple markdown document
1854        let uri = Url::parse("file:///test.md").unwrap();
1855        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1856
1857        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1858
1859        // Should find trailing spaces violations
1860        assert!(!diagnostics.is_empty());
1861        assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1862    }
1863
1864    #[tokio::test]
1865    async fn test_lint_document_disabled() {
1866        let server = create_test_server();
1867
1868        // Disable linting
1869        server.config.write().await.enable_linting = false;
1870
1871        let uri = Url::parse("file:///test.md").unwrap();
1872        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1873
1874        let diagnostics = server.lint_document(&uri, text).await.unwrap();
1875
1876        // Should return empty diagnostics when disabled
1877        assert!(diagnostics.is_empty());
1878    }
1879
1880    #[tokio::test]
1881    async fn test_get_code_actions() {
1882        let server = create_test_server();
1883
1884        let uri = Url::parse("file:///test.md").unwrap();
1885        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1886
1887        // Create a range covering the whole document
1888        let range = Range {
1889            start: Position { line: 0, character: 0 },
1890            end: Position { line: 3, character: 21 },
1891        };
1892
1893        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1894
1895        // Should have code actions for fixing trailing spaces
1896        assert!(!actions.is_empty());
1897        assert!(actions.iter().any(|a| a.title.contains("trailing")));
1898    }
1899
1900    #[tokio::test]
1901    async fn test_get_code_actions_outside_range() {
1902        let server = create_test_server();
1903
1904        let uri = Url::parse("file:///test.md").unwrap();
1905        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
1906
1907        // Create a range that doesn't cover the violations
1908        let range = Range {
1909            start: Position { line: 0, character: 0 },
1910            end: Position { line: 0, character: 6 },
1911        };
1912
1913        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1914
1915        // Should have no code actions for this range
1916        assert!(actions.is_empty());
1917    }
1918
1919    #[tokio::test]
1920    async fn test_document_storage() {
1921        let server = create_test_server();
1922
1923        let uri = Url::parse("file:///test.md").unwrap();
1924        let text = "# Test Document";
1925
1926        // Store document
1927        let entry = DocumentEntry {
1928            content: text.to_string(),
1929            version: Some(1),
1930            from_disk: false,
1931        };
1932        server.documents.write().await.insert(uri.clone(), entry);
1933
1934        // Verify storage
1935        let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1936        assert_eq!(stored, Some(text.to_string()));
1937
1938        // Remove document
1939        server.documents.write().await.remove(&uri);
1940
1941        // Verify removal
1942        let stored = server.documents.read().await.get(&uri).cloned();
1943        assert_eq!(stored, None);
1944    }
1945
1946    #[tokio::test]
1947    async fn test_configuration_loading() {
1948        let server = create_test_server();
1949
1950        // Load configuration with auto-discovery
1951        server.load_configuration(false).await;
1952
1953        // Verify configuration was loaded successfully
1954        // The config could be from: .rumdl.toml, pyproject.toml, .markdownlint.json, or default
1955        let rumdl_config = server.rumdl_config.read().await;
1956        // The loaded config is valid regardless of source
1957        drop(rumdl_config); // Just verify we can access it without panic
1958    }
1959
1960    #[tokio::test]
1961    async fn test_load_config_for_lsp() {
1962        // Test with no config file
1963        let result = RumdlLanguageServer::load_config_for_lsp(None);
1964        assert!(result.is_ok());
1965
1966        // Test with non-existent config file
1967        let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1968        assert!(result.is_err());
1969    }
1970
1971    #[tokio::test]
1972    async fn test_warning_conversion() {
1973        let warning = LintWarning {
1974            message: "Test warning".to_string(),
1975            line: 1,
1976            column: 1,
1977            end_line: 1,
1978            end_column: 10,
1979            severity: crate::rule::Severity::Warning,
1980            fix: None,
1981            rule_name: Some("MD001".to_string()),
1982        };
1983
1984        // Test diagnostic conversion
1985        let diagnostic = warning_to_diagnostic(&warning);
1986        assert_eq!(diagnostic.message, "Test warning");
1987        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1988        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1989
1990        // Test code action conversion (no fix, but should have ignore action)
1991        let uri = Url::parse("file:///test.md").unwrap();
1992        let actions = warning_to_code_actions(&warning, &uri, "Test content");
1993        // Should have 1 action: ignore-line (no fix available)
1994        assert_eq!(actions.len(), 1);
1995        assert_eq!(actions[0].title, "Ignore MD001 for this line");
1996    }
1997
1998    #[tokio::test]
1999    async fn test_multiple_documents() {
2000        let server = create_test_server();
2001
2002        let uri1 = Url::parse("file:///test1.md").unwrap();
2003        let uri2 = Url::parse("file:///test2.md").unwrap();
2004        let text1 = "# Document 1";
2005        let text2 = "# Document 2";
2006
2007        // Store multiple documents
2008        {
2009            let mut docs = server.documents.write().await;
2010            let entry1 = DocumentEntry {
2011                content: text1.to_string(),
2012                version: Some(1),
2013                from_disk: false,
2014            };
2015            let entry2 = DocumentEntry {
2016                content: text2.to_string(),
2017                version: Some(1),
2018                from_disk: false,
2019            };
2020            docs.insert(uri1.clone(), entry1);
2021            docs.insert(uri2.clone(), entry2);
2022        }
2023
2024        // Verify both are stored
2025        let docs = server.documents.read().await;
2026        assert_eq!(docs.len(), 2);
2027        assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2028        assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2029    }
2030
2031    #[tokio::test]
2032    async fn test_auto_fix_on_save() {
2033        let server = create_test_server();
2034
2035        // Enable auto-fix
2036        {
2037            let mut config = server.config.write().await;
2038            config.enable_auto_fix = true;
2039        }
2040
2041        let uri = Url::parse("file:///test.md").unwrap();
2042        let text = "#Heading without space"; // MD018 violation
2043
2044        // Store document
2045        let entry = DocumentEntry {
2046            content: text.to_string(),
2047            version: Some(1),
2048            from_disk: false,
2049        };
2050        server.documents.write().await.insert(uri.clone(), entry);
2051
2052        // Test apply_all_fixes
2053        let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2054        assert!(fixed.is_some());
2055        // MD018 adds space, MD047 adds trailing newline
2056        assert_eq!(fixed.unwrap(), "# Heading without space\n");
2057    }
2058
2059    #[tokio::test]
2060    async fn test_get_end_position() {
2061        let server = create_test_server();
2062
2063        // Single line
2064        let pos = server.get_end_position("Hello");
2065        assert_eq!(pos.line, 0);
2066        assert_eq!(pos.character, 5);
2067
2068        // Multiple lines
2069        let pos = server.get_end_position("Hello\nWorld\nTest");
2070        assert_eq!(pos.line, 2);
2071        assert_eq!(pos.character, 4);
2072
2073        // Empty string
2074        let pos = server.get_end_position("");
2075        assert_eq!(pos.line, 0);
2076        assert_eq!(pos.character, 0);
2077
2078        // Ends with newline - position should be at start of next line
2079        let pos = server.get_end_position("Hello\n");
2080        assert_eq!(pos.line, 1);
2081        assert_eq!(pos.character, 0);
2082    }
2083
2084    #[tokio::test]
2085    async fn test_empty_document_handling() {
2086        let server = create_test_server();
2087
2088        let uri = Url::parse("file:///empty.md").unwrap();
2089        let text = "";
2090
2091        // Test linting empty document
2092        let diagnostics = server.lint_document(&uri, text).await.unwrap();
2093        assert!(diagnostics.is_empty());
2094
2095        // Test code actions on empty document
2096        let range = Range {
2097            start: Position { line: 0, character: 0 },
2098            end: Position { line: 0, character: 0 },
2099        };
2100        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2101        assert!(actions.is_empty());
2102    }
2103
2104    #[tokio::test]
2105    async fn test_config_update() {
2106        let server = create_test_server();
2107
2108        // Update config
2109        {
2110            let mut config = server.config.write().await;
2111            config.enable_auto_fix = true;
2112            config.config_path = Some("/custom/path.toml".to_string());
2113        }
2114
2115        // Verify update
2116        let config = server.config.read().await;
2117        assert!(config.enable_auto_fix);
2118        assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2119    }
2120
2121    #[tokio::test]
2122    async fn test_document_formatting() {
2123        let server = create_test_server();
2124        let uri = Url::parse("file:///test.md").unwrap();
2125        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
2126
2127        // Store document
2128        let entry = DocumentEntry {
2129            content: text.to_string(),
2130            version: Some(1),
2131            from_disk: false,
2132        };
2133        server.documents.write().await.insert(uri.clone(), entry);
2134
2135        // Create formatting params
2136        let params = DocumentFormattingParams {
2137            text_document: TextDocumentIdentifier { uri: uri.clone() },
2138            options: FormattingOptions {
2139                tab_size: 4,
2140                insert_spaces: true,
2141                properties: HashMap::new(),
2142                trim_trailing_whitespace: Some(true),
2143                insert_final_newline: Some(true),
2144                trim_final_newlines: Some(true),
2145            },
2146            work_done_progress_params: WorkDoneProgressParams::default(),
2147        };
2148
2149        // Call formatting
2150        let result = server.formatting(params).await.unwrap();
2151
2152        // Should return text edits that fix the trailing spaces
2153        assert!(result.is_some());
2154        let edits = result.unwrap();
2155        assert!(!edits.is_empty());
2156
2157        // The new text should have trailing spaces removed
2158        let edit = &edits[0];
2159        // The formatted text should have the trailing spaces removed from the middle line
2160        // and a final newline added
2161        let expected = "# Test\n\nThis is a test  \nWith trailing spaces\n";
2162        assert_eq!(edit.new_text, expected);
2163    }
2164
2165    /// Test that Unfixable rules are excluded from formatting/Fix All but available for Quick Fix
2166    /// Regression test for issue #158: formatting deleted HTML img tags
2167    #[tokio::test]
2168    async fn test_unfixable_rules_excluded_from_formatting() {
2169        let server = create_test_server();
2170        let uri = Url::parse("file:///test.md").unwrap();
2171
2172        // Content with both fixable (trailing spaces) and unfixable (HTML) issues
2173        let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces  ";
2174
2175        // Store document
2176        let entry = DocumentEntry {
2177            content: text.to_string(),
2178            version: Some(1),
2179            from_disk: false,
2180        };
2181        server.documents.write().await.insert(uri.clone(), entry);
2182
2183        // Test 1: Formatting should preserve HTML (Unfixable) but fix trailing spaces (fixable)
2184        let format_params = DocumentFormattingParams {
2185            text_document: TextDocumentIdentifier { uri: uri.clone() },
2186            options: FormattingOptions {
2187                tab_size: 4,
2188                insert_spaces: true,
2189                properties: HashMap::new(),
2190                trim_trailing_whitespace: Some(true),
2191                insert_final_newline: Some(true),
2192                trim_final_newlines: Some(true),
2193            },
2194            work_done_progress_params: WorkDoneProgressParams::default(),
2195        };
2196
2197        let format_result = server.formatting(format_params).await.unwrap();
2198        assert!(format_result.is_some(), "Should return formatting edits");
2199
2200        let edits = format_result.unwrap();
2201        assert!(!edits.is_empty(), "Should have formatting edits");
2202
2203        let formatted = &edits[0].new_text;
2204        assert!(
2205            formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2206            "HTML should be preserved during formatting (Unfixable rule)"
2207        );
2208        assert!(
2209            !formatted.contains("spaces  "),
2210            "Trailing spaces should be removed (fixable rule)"
2211        );
2212
2213        // Test 2: Quick Fix actions should still be available for Unfixable rules
2214        let range = Range {
2215            start: Position { line: 0, character: 0 },
2216            end: Position { line: 10, character: 0 },
2217        };
2218
2219        let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2220
2221        // Should have individual Quick Fix actions for each warning
2222        let html_fix_actions: Vec<_> = code_actions
2223            .iter()
2224            .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2225            .collect();
2226
2227        assert!(
2228            !html_fix_actions.is_empty(),
2229            "Quick Fix actions should be available for HTML (Unfixable rules)"
2230        );
2231
2232        // Test 3: "Fix All" action should exclude Unfixable rules
2233        let fix_all_actions: Vec<_> = code_actions
2234            .iter()
2235            .filter(|action| action.title.contains("Fix all"))
2236            .collect();
2237
2238        if let Some(fix_all_action) = fix_all_actions.first()
2239            && let Some(ref edit) = fix_all_action.edit
2240            && let Some(ref changes) = edit.changes
2241            && let Some(text_edits) = changes.get(&uri)
2242            && let Some(text_edit) = text_edits.first()
2243        {
2244            let fixed_all = &text_edit.new_text;
2245            assert!(
2246                fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2247                "Fix All should preserve HTML (Unfixable rules)"
2248            );
2249            assert!(
2250                !fixed_all.contains("spaces  "),
2251                "Fix All should remove trailing spaces (fixable rules)"
2252            );
2253        }
2254    }
2255
2256    /// Test that resolve_config_for_file() finds the correct config in multi-root workspace
2257    #[tokio::test]
2258    async fn test_resolve_config_for_file_multi_root() {
2259        use std::fs;
2260        use tempfile::tempdir;
2261
2262        let temp_dir = tempdir().unwrap();
2263        let temp_path = temp_dir.path();
2264
2265        // Setup project A with line_length=60
2266        let project_a = temp_path.join("project_a");
2267        let project_a_docs = project_a.join("docs");
2268        fs::create_dir_all(&project_a_docs).unwrap();
2269
2270        let config_a = project_a.join(".rumdl.toml");
2271        fs::write(
2272            &config_a,
2273            r#"
2274[global]
2275
2276[MD013]
2277line_length = 60
2278"#,
2279        )
2280        .unwrap();
2281
2282        // Setup project B with line_length=120
2283        let project_b = temp_path.join("project_b");
2284        fs::create_dir(&project_b).unwrap();
2285
2286        let config_b = project_b.join(".rumdl.toml");
2287        fs::write(
2288            &config_b,
2289            r#"
2290[global]
2291
2292[MD013]
2293line_length = 120
2294"#,
2295        )
2296        .unwrap();
2297
2298        // Create LSP server and initialize with workspace roots
2299        let server = create_test_server();
2300
2301        // Set workspace roots
2302        {
2303            let mut roots = server.workspace_roots.write().await;
2304            roots.push(project_a.clone());
2305            roots.push(project_b.clone());
2306        }
2307
2308        // Test file in project A
2309        let file_a = project_a_docs.join("test.md");
2310        fs::write(&file_a, "# Test A\n").unwrap();
2311
2312        let config_for_a = server.resolve_config_for_file(&file_a).await;
2313        let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2314        assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2315
2316        // Test file in project B
2317        let file_b = project_b.join("test.md");
2318        fs::write(&file_b, "# Test B\n").unwrap();
2319
2320        let config_for_b = server.resolve_config_for_file(&file_b).await;
2321        let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2322        assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2323    }
2324
2325    /// Test that config resolution respects workspace root boundaries
2326    #[tokio::test]
2327    async fn test_config_resolution_respects_workspace_boundaries() {
2328        use std::fs;
2329        use tempfile::tempdir;
2330
2331        let temp_dir = tempdir().unwrap();
2332        let temp_path = temp_dir.path();
2333
2334        // Create parent config that should NOT be used
2335        let parent_config = temp_path.join(".rumdl.toml");
2336        fs::write(
2337            &parent_config,
2338            r#"
2339[global]
2340
2341[MD013]
2342line_length = 80
2343"#,
2344        )
2345        .unwrap();
2346
2347        // Create workspace root with its own config
2348        let workspace_root = temp_path.join("workspace");
2349        let workspace_subdir = workspace_root.join("subdir");
2350        fs::create_dir_all(&workspace_subdir).unwrap();
2351
2352        let workspace_config = workspace_root.join(".rumdl.toml");
2353        fs::write(
2354            &workspace_config,
2355            r#"
2356[global]
2357
2358[MD013]
2359line_length = 100
2360"#,
2361        )
2362        .unwrap();
2363
2364        let server = create_test_server();
2365
2366        // Register workspace_root as a workspace root
2367        {
2368            let mut roots = server.workspace_roots.write().await;
2369            roots.push(workspace_root.clone());
2370        }
2371
2372        // Test file deep in subdirectory
2373        let test_file = workspace_subdir.join("deep").join("test.md");
2374        fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2375        fs::write(&test_file, "# Test\n").unwrap();
2376
2377        let config = server.resolve_config_for_file(&test_file).await;
2378        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2379
2380        // Should find workspace_root/.rumdl.toml (100), NOT parent config (80)
2381        assert_eq!(
2382            line_length,
2383            Some(100),
2384            "Should find workspace config, not parent config outside workspace"
2385        );
2386    }
2387
2388    /// Test that config cache works (cache hit scenario)
2389    #[tokio::test]
2390    async fn test_config_cache_hit() {
2391        use std::fs;
2392        use tempfile::tempdir;
2393
2394        let temp_dir = tempdir().unwrap();
2395        let temp_path = temp_dir.path();
2396
2397        let project = temp_path.join("project");
2398        fs::create_dir(&project).unwrap();
2399
2400        let config_file = project.join(".rumdl.toml");
2401        fs::write(
2402            &config_file,
2403            r#"
2404[global]
2405
2406[MD013]
2407line_length = 75
2408"#,
2409        )
2410        .unwrap();
2411
2412        let server = create_test_server();
2413        {
2414            let mut roots = server.workspace_roots.write().await;
2415            roots.push(project.clone());
2416        }
2417
2418        let test_file = project.join("test.md");
2419        fs::write(&test_file, "# Test\n").unwrap();
2420
2421        // First call - cache miss
2422        let config1 = server.resolve_config_for_file(&test_file).await;
2423        let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2424        assert_eq!(line_length1, Some(75));
2425
2426        // Verify cache was populated
2427        {
2428            let cache = server.config_cache.read().await;
2429            let search_dir = test_file.parent().unwrap();
2430            assert!(
2431                cache.contains_key(search_dir),
2432                "Cache should be populated after first call"
2433            );
2434        }
2435
2436        // Second call - cache hit (should return same config without filesystem access)
2437        let config2 = server.resolve_config_for_file(&test_file).await;
2438        let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2439        assert_eq!(line_length2, Some(75));
2440    }
2441
2442    /// Test nested directory config search (file searches upward)
2443    #[tokio::test]
2444    async fn test_nested_directory_config_search() {
2445        use std::fs;
2446        use tempfile::tempdir;
2447
2448        let temp_dir = tempdir().unwrap();
2449        let temp_path = temp_dir.path();
2450
2451        let project = temp_path.join("project");
2452        fs::create_dir(&project).unwrap();
2453
2454        // Config at project root
2455        let config = project.join(".rumdl.toml");
2456        fs::write(
2457            &config,
2458            r#"
2459[global]
2460
2461[MD013]
2462line_length = 110
2463"#,
2464        )
2465        .unwrap();
2466
2467        // File deep in nested structure
2468        let deep_dir = project.join("src").join("docs").join("guides");
2469        fs::create_dir_all(&deep_dir).unwrap();
2470        let deep_file = deep_dir.join("test.md");
2471        fs::write(&deep_file, "# Test\n").unwrap();
2472
2473        let server = create_test_server();
2474        {
2475            let mut roots = server.workspace_roots.write().await;
2476            roots.push(project.clone());
2477        }
2478
2479        let resolved_config = server.resolve_config_for_file(&deep_file).await;
2480        let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2481
2482        assert_eq!(
2483            line_length,
2484            Some(110),
2485            "Should find config by searching upward from deep directory"
2486        );
2487    }
2488
2489    /// Test fallback to default config when no config file found
2490    #[tokio::test]
2491    async fn test_fallback_to_default_config() {
2492        use std::fs;
2493        use tempfile::tempdir;
2494
2495        let temp_dir = tempdir().unwrap();
2496        let temp_path = temp_dir.path();
2497
2498        let project = temp_path.join("project");
2499        fs::create_dir(&project).unwrap();
2500
2501        // No config file created!
2502
2503        let test_file = project.join("test.md");
2504        fs::write(&test_file, "# Test\n").unwrap();
2505
2506        let server = create_test_server();
2507        {
2508            let mut roots = server.workspace_roots.write().await;
2509            roots.push(project.clone());
2510        }
2511
2512        let config = server.resolve_config_for_file(&test_file).await;
2513
2514        // Default global line_length is 80
2515        assert_eq!(
2516            config.global.line_length.get(),
2517            80,
2518            "Should fall back to default config when no config file found"
2519        );
2520    }
2521
2522    /// Test config priority: closer config wins over parent config
2523    #[tokio::test]
2524    async fn test_config_priority_closer_wins() {
2525        use std::fs;
2526        use tempfile::tempdir;
2527
2528        let temp_dir = tempdir().unwrap();
2529        let temp_path = temp_dir.path();
2530
2531        let project = temp_path.join("project");
2532        fs::create_dir(&project).unwrap();
2533
2534        // Parent config
2535        let parent_config = project.join(".rumdl.toml");
2536        fs::write(
2537            &parent_config,
2538            r#"
2539[global]
2540
2541[MD013]
2542line_length = 100
2543"#,
2544        )
2545        .unwrap();
2546
2547        // Subdirectory with its own config (should override parent)
2548        let subdir = project.join("subdir");
2549        fs::create_dir(&subdir).unwrap();
2550
2551        let subdir_config = subdir.join(".rumdl.toml");
2552        fs::write(
2553            &subdir_config,
2554            r#"
2555[global]
2556
2557[MD013]
2558line_length = 50
2559"#,
2560        )
2561        .unwrap();
2562
2563        let server = create_test_server();
2564        {
2565            let mut roots = server.workspace_roots.write().await;
2566            roots.push(project.clone());
2567        }
2568
2569        // File in subdirectory
2570        let test_file = subdir.join("test.md");
2571        fs::write(&test_file, "# Test\n").unwrap();
2572
2573        let config = server.resolve_config_for_file(&test_file).await;
2574        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2575
2576        assert_eq!(
2577            line_length,
2578            Some(50),
2579            "Closer config (subdir) should override parent config"
2580        );
2581    }
2582
2583    /// Test for issue #131: LSP should skip pyproject.toml without [tool.rumdl] section
2584    ///
2585    /// This test verifies the fix in resolve_config_for_file() at lines 574-585 that checks
2586    /// for [tool.rumdl] presence before loading pyproject.toml. The fix ensures LSP behavior
2587    /// matches CLI behavior.
2588    #[tokio::test]
2589    async fn test_issue_131_pyproject_without_rumdl_section() {
2590        use std::fs;
2591        use tempfile::tempdir;
2592
2593        // Create a parent temp dir that we control
2594        let parent_dir = tempdir().unwrap();
2595
2596        // Create a child subdirectory for the project
2597        let project_dir = parent_dir.path().join("project");
2598        fs::create_dir(&project_dir).unwrap();
2599
2600        // Create pyproject.toml WITHOUT [tool.rumdl] section in project dir
2601        fs::write(
2602            project_dir.join("pyproject.toml"),
2603            r#"
2604[project]
2605name = "test-project"
2606version = "0.1.0"
2607"#,
2608        )
2609        .unwrap();
2610
2611        // Create .rumdl.toml in PARENT that SHOULD be found
2612        // because pyproject.toml without [tool.rumdl] should be skipped
2613        fs::write(
2614            parent_dir.path().join(".rumdl.toml"),
2615            r#"
2616[global]
2617disable = ["MD013"]
2618"#,
2619        )
2620        .unwrap();
2621
2622        let test_file = project_dir.join("test.md");
2623        fs::write(&test_file, "# Test\n").unwrap();
2624
2625        let server = create_test_server();
2626
2627        // Set workspace root to parent so upward search doesn't stop at project_dir
2628        {
2629            let mut roots = server.workspace_roots.write().await;
2630            roots.push(parent_dir.path().to_path_buf());
2631        }
2632
2633        // Resolve config for file in project_dir
2634        let config = server.resolve_config_for_file(&test_file).await;
2635
2636        // CRITICAL TEST: The pyproject.toml in project_dir should be SKIPPED because it lacks
2637        // [tool.rumdl], and the search should continue upward to find parent .rumdl.toml
2638        assert!(
2639            config.global.disable.contains(&"MD013".to_string()),
2640            "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2641             and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2642        );
2643
2644        // Verify the config came from the parent directory, not project_dir
2645        // (we can check this by looking at the cache)
2646        let cache = server.config_cache.read().await;
2647        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2648
2649        assert!(
2650            cache_entry.config_file.is_some(),
2651            "Should have found a config file (parent .rumdl.toml)"
2652        );
2653
2654        let found_config_path = cache_entry.config_file.as_ref().unwrap();
2655        assert!(
2656            found_config_path.ends_with(".rumdl.toml"),
2657            "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2658        );
2659        assert!(
2660            found_config_path.parent().unwrap() == parent_dir.path(),
2661            "Should have loaded config from parent directory, not project_dir"
2662        );
2663    }
2664
2665    /// Test for issue #131: LSP should detect and load pyproject.toml WITH [tool.rumdl] section
2666    ///
2667    /// This test verifies that when pyproject.toml contains [tool.rumdl], the fix at lines 574-585
2668    /// correctly allows it through and loads the configuration.
2669    #[tokio::test]
2670    async fn test_issue_131_pyproject_with_rumdl_section() {
2671        use std::fs;
2672        use tempfile::tempdir;
2673
2674        // Create a parent temp dir that we control
2675        let parent_dir = tempdir().unwrap();
2676
2677        // Create a child subdirectory for the project
2678        let project_dir = parent_dir.path().join("project");
2679        fs::create_dir(&project_dir).unwrap();
2680
2681        // Create pyproject.toml WITH [tool.rumdl] section in project dir
2682        fs::write(
2683            project_dir.join("pyproject.toml"),
2684            r#"
2685[project]
2686name = "test-project"
2687
2688[tool.rumdl.global]
2689disable = ["MD033"]
2690"#,
2691        )
2692        .unwrap();
2693
2694        // Create a parent directory with different config that should NOT be used
2695        fs::write(
2696            parent_dir.path().join(".rumdl.toml"),
2697            r#"
2698[global]
2699disable = ["MD041"]
2700"#,
2701        )
2702        .unwrap();
2703
2704        let test_file = project_dir.join("test.md");
2705        fs::write(&test_file, "# Test\n").unwrap();
2706
2707        let server = create_test_server();
2708
2709        // Set workspace root to parent
2710        {
2711            let mut roots = server.workspace_roots.write().await;
2712            roots.push(parent_dir.path().to_path_buf());
2713        }
2714
2715        // Resolve config for file
2716        let config = server.resolve_config_for_file(&test_file).await;
2717
2718        // CRITICAL TEST: The pyproject.toml should be LOADED (not skipped) because it has [tool.rumdl]
2719        assert!(
2720            config.global.disable.contains(&"MD033".to_string()),
2721            "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2722             Expected MD033 from project_dir pyproject.toml to be disabled."
2723        );
2724
2725        // Verify we did NOT get the parent config
2726        assert!(
2727            !config.global.disable.contains(&"MD041".to_string()),
2728            "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2729        );
2730
2731        // Verify the config came from pyproject.toml specifically
2732        let cache = server.config_cache.read().await;
2733        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2734
2735        assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2736
2737        let found_config_path = cache_entry.config_file.as_ref().unwrap();
2738        assert!(
2739            found_config_path.ends_with("pyproject.toml"),
2740            "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2741        );
2742        assert!(
2743            found_config_path.parent().unwrap() == project_dir,
2744            "Should have loaded pyproject.toml from project_dir, not parent"
2745        );
2746    }
2747
2748    /// Test for issue #131: Verify pyproject.toml with only "tool.rumdl" (no brackets) is detected
2749    ///
2750    /// The fix checks for both "[tool.rumdl]" and "tool.rumdl" (line 576), ensuring it catches
2751    /// any valid TOML structure like [tool.rumdl.global] or [[tool.rumdl.something]].
2752    #[tokio::test]
2753    async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2754        use std::fs;
2755        use tempfile::tempdir;
2756
2757        let temp_dir = tempdir().unwrap();
2758
2759        // Create pyproject.toml with [tool.rumdl.global] but not [tool.rumdl] directly
2760        fs::write(
2761            temp_dir.path().join("pyproject.toml"),
2762            r#"
2763[project]
2764name = "test-project"
2765
2766[tool.rumdl.global]
2767disable = ["MD022"]
2768"#,
2769        )
2770        .unwrap();
2771
2772        let test_file = temp_dir.path().join("test.md");
2773        fs::write(&test_file, "# Test\n").unwrap();
2774
2775        let server = create_test_server();
2776
2777        // Set workspace root
2778        {
2779            let mut roots = server.workspace_roots.write().await;
2780            roots.push(temp_dir.path().to_path_buf());
2781        }
2782
2783        // Resolve config for file
2784        let config = server.resolve_config_for_file(&test_file).await;
2785
2786        // Should detect "tool.rumdl" substring and load the config
2787        assert!(
2788            config.global.disable.contains(&"MD022".to_string()),
2789            "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2790        );
2791
2792        // Verify it loaded pyproject.toml
2793        let cache = server.config_cache.read().await;
2794        let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2795        assert!(
2796            cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2797            "Should have loaded pyproject.toml"
2798        );
2799    }
2800
2801    /// Test for issue #182: Client pull diagnostics capability detection
2802    ///
2803    /// When a client supports pull diagnostics (textDocument/diagnostic), the server
2804    /// should skip pushing diagnostics via publishDiagnostics to avoid duplicates.
2805    #[tokio::test]
2806    async fn test_issue_182_pull_diagnostics_capability_default() {
2807        let server = create_test_server();
2808
2809        // By default, client_supports_pull_diagnostics should be false
2810        assert!(
2811            !*server.client_supports_pull_diagnostics.read().await,
2812            "Default should be false - push diagnostics by default"
2813        );
2814    }
2815
2816    /// Test that we can set the pull diagnostics flag
2817    #[tokio::test]
2818    async fn test_issue_182_pull_diagnostics_flag_update() {
2819        let server = create_test_server();
2820
2821        // Simulate detecting pull capability
2822        *server.client_supports_pull_diagnostics.write().await = true;
2823
2824        assert!(
2825            *server.client_supports_pull_diagnostics.read().await,
2826            "Flag should be settable to true"
2827        );
2828    }
2829
2830    /// Test issue #182: Verify capability detection logic matches Ruff's pattern
2831    ///
2832    /// The detection should check: params.capabilities.text_document.diagnostic.is_some()
2833    #[tokio::test]
2834    async fn test_issue_182_capability_detection_with_diagnostic_support() {
2835        use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2836
2837        // Create client capabilities WITH diagnostic support
2838        let caps_with_diagnostic = ClientCapabilities {
2839            text_document: Some(TextDocumentClientCapabilities {
2840                diagnostic: Some(DiagnosticClientCapabilities {
2841                    dynamic_registration: Some(true),
2842                    related_document_support: Some(false),
2843                }),
2844                ..Default::default()
2845            }),
2846            ..Default::default()
2847        };
2848
2849        // Verify the detection logic (same as in initialize)
2850        let supports_pull = caps_with_diagnostic
2851            .text_document
2852            .as_ref()
2853            .and_then(|td| td.diagnostic.as_ref())
2854            .is_some();
2855
2856        assert!(supports_pull, "Should detect pull diagnostic support");
2857    }
2858
2859    /// Test issue #182: Verify capability detection when diagnostic is NOT supported
2860    #[tokio::test]
2861    async fn test_issue_182_capability_detection_without_diagnostic_support() {
2862        use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2863
2864        // Create client capabilities WITHOUT diagnostic support
2865        let caps_without_diagnostic = ClientCapabilities {
2866            text_document: Some(TextDocumentClientCapabilities {
2867                diagnostic: None, // No diagnostic support
2868                ..Default::default()
2869            }),
2870            ..Default::default()
2871        };
2872
2873        // Verify the detection logic
2874        let supports_pull = caps_without_diagnostic
2875            .text_document
2876            .as_ref()
2877            .and_then(|td| td.diagnostic.as_ref())
2878            .is_some();
2879
2880        assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2881    }
2882
2883    /// Test issue #182: Verify capability detection with empty text_document
2884    #[tokio::test]
2885    async fn test_issue_182_capability_detection_no_text_document() {
2886        use tower_lsp::lsp_types::ClientCapabilities;
2887
2888        // Create client capabilities with no text_document at all
2889        let caps_no_text_doc = ClientCapabilities {
2890            text_document: None,
2891            ..Default::default()
2892        };
2893
2894        // Verify the detection logic
2895        let supports_pull = caps_no_text_doc
2896            .text_document
2897            .as_ref()
2898            .and_then(|td| td.diagnostic.as_ref())
2899            .is_some();
2900
2901        assert!(
2902            !supports_pull,
2903            "Should NOT detect pull diagnostic support when text_document is None"
2904        );
2905    }
2906
2907    #[test]
2908    fn test_resource_limit_constants() {
2909        // Verify resource limit constants have expected values
2910        assert_eq!(MAX_RULE_LIST_SIZE, 100);
2911        assert_eq!(MAX_LINE_LENGTH, 10_000);
2912    }
2913
2914    #[test]
2915    fn test_is_valid_rule_name_zero_alloc() {
2916        // These tests verify the zero-allocation implementation works correctly
2917        // by testing edge cases that might trip up byte-level parsing
2918
2919        // Test ASCII boundary conditions
2920        assert!(!RumdlLanguageServer::is_valid_rule_name("MD/01")); // '/' is before '0'
2921        assert!(!RumdlLanguageServer::is_valid_rule_name("MD:01")); // ':' is after '9'
2922        assert!(!RumdlLanguageServer::is_valid_rule_name("ND001")); // 'N' instead of 'M'
2923        assert!(!RumdlLanguageServer::is_valid_rule_name("ME001")); // 'E' instead of 'D'
2924
2925        // Test non-ASCII characters (should fail gracefully)
2926        assert!(!RumdlLanguageServer::is_valid_rule_name("MD0①1")); // Unicode digit
2927        assert!(!RumdlLanguageServer::is_valid_rule_name("MD001")); // Fullwidth M
2928
2929        // Test wrapping_sub edge cases
2930        assert!(!RumdlLanguageServer::is_valid_rule_name("MD\x00\x00\x00")); // null bytes
2931    }
2932}