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