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