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