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