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