Skip to main content

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