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::linguist_data::{CANONICAL_TO_ALIASES, default_alias};
19use crate::lint;
20use crate::lsp::index_worker::IndexWorker;
21use crate::lsp::types::{
22    ConfigurationPreference, IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig, warning_to_code_actions,
23    warning_to_diagnostic,
24};
25use crate::rule::{FixCapability, Rule};
26use crate::rule_config_serde::load_rule_config;
27use crate::rules;
28use crate::rules::md040_fenced_code_language::md040_config::MD040Config;
29use crate::workspace_index::WorkspaceIndex;
30
31/// Supported markdown file extensions (without leading dot)
32const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
33
34/// Maximum number of rules in enable/disable lists (DoS protection)
35const MAX_RULE_LIST_SIZE: usize = 100;
36
37/// Maximum allowed line length value (DoS protection)
38const MAX_LINE_LENGTH: usize = 10_000;
39
40/// Check if a file extension is a markdown extension
41#[inline]
42fn is_markdown_extension(ext: &str) -> bool {
43    MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
44}
45
46/// Represents a document in the LSP server's cache
47#[derive(Clone, Debug, PartialEq)]
48struct DocumentEntry {
49    /// The document content
50    content: String,
51    /// Version number from the editor (None for disk-loaded documents)
52    version: Option<i32>,
53    /// Whether the document was loaded from disk (true) or opened in editor (false)
54    from_disk: bool,
55}
56
57/// Cache entry for resolved configuration
58#[derive(Clone, Debug)]
59pub(crate) struct ConfigCacheEntry {
60    /// The resolved configuration
61    pub(crate) config: Config,
62    /// Config file path that was loaded (for invalidation)
63    pub(crate) config_file: Option<PathBuf>,
64    /// True if this entry came from the global/user fallback (no project config)
65    pub(crate) from_global_fallback: bool,
66}
67
68/// Main LSP server for rumdl
69///
70/// Following Ruff's pattern, this server provides:
71/// - Real-time diagnostics as users type
72/// - Code actions for automatic fixes
73/// - Configuration management
74/// - Multi-file support
75/// - Multi-root workspace support with per-file config resolution
76/// - Cross-file analysis with workspace indexing
77#[derive(Clone)]
78pub struct RumdlLanguageServer {
79    client: Client,
80    /// Configuration for the LSP server
81    config: Arc<RwLock<RumdlLspConfig>>,
82    /// Rumdl core configuration (fallback/default)
83    #[cfg_attr(test, allow(dead_code))]
84    pub(crate) rumdl_config: Arc<RwLock<Config>>,
85    /// Document store for open files and cached disk files
86    documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
87    /// Workspace root folders from the client
88    #[cfg_attr(test, allow(dead_code))]
89    pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
90    /// Configuration cache: maps directory path to resolved config
91    /// Key is the directory where config search started (file's parent dir)
92    #[cfg_attr(test, allow(dead_code))]
93    pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
94    /// Workspace index for cross-file analysis (MD051)
95    workspace_index: Arc<RwLock<WorkspaceIndex>>,
96    /// Current state of the workspace index (building/ready/error)
97    index_state: Arc<RwLock<IndexState>>,
98    /// Channel to send updates to the background index worker
99    update_tx: mpsc::Sender<IndexUpdate>,
100    /// Whether the client supports pull diagnostics (textDocument/diagnostic)
101    /// When true, we skip pushing diagnostics to avoid duplicates
102    client_supports_pull_diagnostics: Arc<RwLock<bool>>,
103}
104
105impl RumdlLanguageServer {
106    pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
107        // Initialize with CLI config path if provided (for `rumdl server --config` convenience)
108        let mut initial_config = RumdlLspConfig::default();
109        if let Some(path) = cli_config_path {
110            initial_config.config_path = Some(path.to_string());
111        }
112
113        // Create shared state for workspace indexing
114        let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
115        let index_state = Arc::new(RwLock::new(IndexState::default()));
116        let workspace_roots = Arc::new(RwLock::new(Vec::new()));
117
118        // Create channels for index worker communication
119        let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
120        let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
121
122        // Spawn the background index worker
123        let worker = IndexWorker::new(
124            update_rx,
125            workspace_index.clone(),
126            index_state.clone(),
127            client.clone(),
128            workspace_roots.clone(),
129            relint_tx,
130        );
131        tokio::spawn(worker.run());
132
133        Self {
134            client,
135            config: Arc::new(RwLock::new(initial_config)),
136            rumdl_config: Arc::new(RwLock::new(Config::default())),
137            documents: Arc::new(RwLock::new(HashMap::new())),
138            workspace_roots,
139            config_cache: Arc::new(RwLock::new(HashMap::new())),
140            workspace_index,
141            index_state,
142            update_tx,
143            client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
144        }
145    }
146
147    /// Get document content, either from cache or by reading from disk
148    ///
149    /// This method first checks if the document is in the cache (opened in editor).
150    /// If not found, it attempts to read the file from disk and caches it for
151    /// future requests.
152    async fn get_document_content(&self, uri: &Url) -> Option<String> {
153        // First check the cache
154        {
155            let docs = self.documents.read().await;
156            if let Some(entry) = docs.get(uri) {
157                return Some(entry.content.clone());
158            }
159        }
160
161        // If not in cache and it's a file URI, try to read from disk
162        if let Ok(path) = uri.to_file_path() {
163            if let Ok(content) = tokio::fs::read_to_string(&path).await {
164                // Cache the document for future requests
165                let entry = DocumentEntry {
166                    content: content.clone(),
167                    version: None,
168                    from_disk: true,
169                };
170
171                let mut docs = self.documents.write().await;
172                docs.insert(uri.clone(), entry);
173
174                log::debug!("Loaded document from disk and cached: {uri}");
175                return Some(content);
176            } else {
177                log::debug!("Failed to read file from disk: {uri}");
178            }
179        }
180
181        None
182    }
183
184    /// Get document content only if the document is currently open in the editor.
185    ///
186    /// We intentionally do not read from disk here because diagnostics should be
187    /// scoped to open documents. This avoids lingering diagnostics after a file
188    /// is closed when clients use pull diagnostics.
189    async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
190        let docs = self.documents.read().await;
191        docs.get(uri)
192            .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
193    }
194
195    /// Apply LSP config overrides to the filtered rules
196    fn apply_lsp_config_overrides(
197        &self,
198        mut filtered_rules: Vec<Box<dyn Rule>>,
199        lsp_config: &RumdlLspConfig,
200    ) -> Vec<Box<dyn Rule>> {
201        // Collect enable rules from both top-level and settings
202        let mut enable_rules: Vec<String> = Vec::new();
203        if let Some(enable) = &lsp_config.enable_rules {
204            enable_rules.extend(enable.iter().cloned());
205        }
206        if let Some(settings) = &lsp_config.settings
207            && let Some(enable) = &settings.enable
208        {
209            enable_rules.extend(enable.iter().cloned());
210        }
211
212        // Apply enable_rules override (if specified, only these rules are active)
213        if !enable_rules.is_empty() {
214            let enable_set: std::collections::HashSet<String> = enable_rules.into_iter().collect();
215            filtered_rules.retain(|rule| enable_set.contains(rule.name()));
216        }
217
218        // Collect disable rules from both top-level and settings
219        let mut disable_rules: Vec<String> = Vec::new();
220        if let Some(disable) = &lsp_config.disable_rules {
221            disable_rules.extend(disable.iter().cloned());
222        }
223        if let Some(settings) = &lsp_config.settings
224            && let Some(disable) = &settings.disable
225        {
226            disable_rules.extend(disable.iter().cloned());
227        }
228
229        // Apply disable_rules override
230        if !disable_rules.is_empty() {
231            let disable_set: std::collections::HashSet<String> = disable_rules.into_iter().collect();
232            filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
233        }
234
235        filtered_rules
236    }
237
238    /// Merge LSP settings into a Config based on configuration preference
239    ///
240    /// This follows Ruff's pattern where editors can pass per-rule configuration
241    /// via LSP initialization options. The `configuration_preference` controls
242    /// whether editor settings override filesystem configs or vice versa.
243    fn merge_lsp_settings(&self, mut file_config: Config, lsp_config: &RumdlLspConfig) -> Config {
244        let Some(settings) = &lsp_config.settings else {
245            return file_config;
246        };
247
248        match lsp_config.configuration_preference {
249            ConfigurationPreference::EditorFirst => {
250                // Editor settings take priority - apply them on top of file config
251                self.apply_lsp_settings_to_config(&mut file_config, settings);
252            }
253            ConfigurationPreference::FilesystemFirst => {
254                // File config takes priority - only apply settings for values not in file config
255                self.apply_lsp_settings_if_absent(&mut file_config, settings);
256            }
257            ConfigurationPreference::EditorOnly => {
258                // Ignore file config completely - start from default and apply editor settings
259                let mut default_config = Config::default();
260                self.apply_lsp_settings_to_config(&mut default_config, settings);
261                return default_config;
262            }
263        }
264
265        file_config
266    }
267
268    /// Apply all LSP settings to config, overriding existing values
269    fn apply_lsp_settings_to_config(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
270        // Apply global line length
271        if let Some(line_length) = settings.line_length {
272            config.global.line_length = crate::types::LineLength::new(line_length);
273        }
274
275        // Apply disable list
276        if let Some(disable) = &settings.disable {
277            config.global.disable.extend(disable.iter().cloned());
278        }
279
280        // Apply enable list
281        if let Some(enable) = &settings.enable {
282            config.global.enable.extend(enable.iter().cloned());
283        }
284
285        // Apply per-rule settings (e.g., "MD013": { "lineLength": 120 })
286        for (rule_name, rule_config) in &settings.rules {
287            self.apply_rule_config(config, rule_name, rule_config);
288        }
289    }
290
291    /// Apply LSP settings to config only where file config doesn't specify values
292    fn apply_lsp_settings_if_absent(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
293        // Apply global line length only if using default value
294        // LineLength default is 80, so we can check if it's still the default
295        if config.global.line_length.get() == 80
296            && let Some(line_length) = settings.line_length
297        {
298            config.global.line_length = crate::types::LineLength::new(line_length);
299        }
300
301        // For disable/enable lists, we merge them (filesystem values are already there)
302        if let Some(disable) = &settings.disable {
303            config.global.disable.extend(disable.iter().cloned());
304        }
305
306        if let Some(enable) = &settings.enable {
307            config.global.enable.extend(enable.iter().cloned());
308        }
309
310        // Apply per-rule settings only if not already configured in file
311        for (rule_name, rule_config) in &settings.rules {
312            self.apply_rule_config_if_absent(config, rule_name, rule_config);
313        }
314    }
315
316    /// Apply per-rule configuration from LSP settings
317    ///
318    /// Converts JSON values from LSP settings to TOML values and merges them
319    /// into the config's rule-specific BTreeMap.
320    fn apply_rule_config(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
321        let rule_key = rule_name.to_uppercase();
322
323        // Get or create the rule config entry
324        let rule_entry = config.rules.entry(rule_key.clone()).or_default();
325
326        // Convert JSON object to TOML values and merge
327        if let Some(obj) = rule_config.as_object() {
328            for (key, value) in obj {
329                // Convert camelCase to snake_case for config compatibility
330                let config_key = Self::camel_to_snake(key);
331
332                // Handle severity specially - it's a first-class field on RuleConfig
333                if config_key == "severity" {
334                    if let Some(severity_str) = value.as_str() {
335                        match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
336                            severity_str.to_string(),
337                        )) {
338                            Ok(severity) => {
339                                rule_entry.severity = Some(severity);
340                            }
341                            Err(_) => {
342                                log::warn!(
343                                    "Invalid severity '{severity_str}' for rule {rule_key}. \
344                                     Valid values: error, warning, info"
345                                );
346                            }
347                        }
348                    }
349                    continue;
350                }
351
352                // Convert JSON value to TOML value
353                if let Some(toml_value) = Self::json_to_toml(value) {
354                    rule_entry.values.insert(config_key, toml_value);
355                }
356            }
357        }
358    }
359
360    /// Apply per-rule configuration only if not already set in file config
361    ///
362    /// For FilesystemFirst mode: file config takes precedence for each setting.
363    /// This means:
364    /// - If file has severity set, don't override it with LSP severity
365    /// - If file has values set, don't override them with LSP values
366    /// - Handle severity and values independently
367    fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
368        let rule_key = rule_name.to_uppercase();
369
370        // Check existing config state
371        let existing_rule = config.rules.get(&rule_key);
372        let has_existing_values = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
373        let has_existing_severity = existing_rule.and_then(|r| r.severity).is_some();
374
375        // Apply LSP settings, respecting file config
376        if let Some(obj) = rule_config.as_object() {
377            let rule_entry = config.rules.entry(rule_key.clone()).or_default();
378
379            for (key, value) in obj {
380                let config_key = Self::camel_to_snake(key);
381
382                // Handle severity independently
383                if config_key == "severity" {
384                    if !has_existing_severity && let Some(severity_str) = value.as_str() {
385                        match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
386                            severity_str.to_string(),
387                        )) {
388                            Ok(severity) => {
389                                rule_entry.severity = Some(severity);
390                            }
391                            Err(_) => {
392                                log::warn!(
393                                    "Invalid severity '{severity_str}' for rule {rule_key}. \
394                                     Valid values: error, warning, info"
395                                );
396                            }
397                        }
398                    }
399                    continue;
400                }
401
402                // Handle other values only if file config doesn't have any values for this rule
403                if !has_existing_values && let Some(toml_value) = Self::json_to_toml(value) {
404                    rule_entry.values.insert(config_key, toml_value);
405                }
406            }
407        }
408    }
409
410    /// Convert camelCase to snake_case
411    fn camel_to_snake(s: &str) -> String {
412        let mut result = String::new();
413        for (i, c) in s.chars().enumerate() {
414            if c.is_uppercase() && i > 0 {
415                result.push('_');
416            }
417            result.push(c.to_lowercase().next().unwrap_or(c));
418        }
419        result
420    }
421
422    /// Convert a JSON value to a TOML value
423    fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
424        match json {
425            serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
426            serde_json::Value::Number(n) => {
427                if let Some(i) = n.as_i64() {
428                    Some(toml::Value::Integer(i))
429                } else {
430                    n.as_f64().map(toml::Value::Float)
431                }
432            }
433            serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
434            serde_json::Value::Array(arr) => {
435                let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
436                Some(toml::Value::Array(toml_arr))
437            }
438            serde_json::Value::Object(obj) => {
439                let mut table = toml::map::Map::new();
440                for (k, v) in obj {
441                    if let Some(toml_v) = Self::json_to_toml(v) {
442                        table.insert(Self::camel_to_snake(k), toml_v);
443                    }
444                }
445                Some(toml::Value::Table(table))
446            }
447            serde_json::Value::Null => None,
448        }
449    }
450
451    /// Check if a file URI should be excluded based on exclude patterns
452    async fn should_exclude_uri(&self, uri: &Url) -> bool {
453        // Try to convert URI to file path
454        let file_path = match uri.to_file_path() {
455            Ok(path) => path,
456            Err(_) => return false, // If we can't get a path, don't exclude
457        };
458
459        // Resolve configuration for this specific file to get its exclude patterns
460        let rumdl_config = self.resolve_config_for_file(&file_path).await;
461        let exclude_patterns = &rumdl_config.global.exclude;
462
463        // If no exclude patterns, don't exclude
464        if exclude_patterns.is_empty() {
465            return false;
466        }
467
468        // Convert path to relative path for pattern matching
469        // This matches the CLI behavior in find_markdown_files
470        let path_to_check = if file_path.is_absolute() {
471            // Try to make it relative to the current directory
472            if let Ok(cwd) = std::env::current_dir() {
473                // Canonicalize both paths to handle symlinks
474                if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
475                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
476                        relative.to_string_lossy().to_string()
477                    } else {
478                        // Path is absolute but not under cwd
479                        file_path.to_string_lossy().to_string()
480                    }
481                } else {
482                    // Canonicalization failed
483                    file_path.to_string_lossy().to_string()
484                }
485            } else {
486                file_path.to_string_lossy().to_string()
487            }
488        } else {
489            // Already relative
490            file_path.to_string_lossy().to_string()
491        };
492
493        // Check if path matches any exclude pattern
494        for pattern in exclude_patterns {
495            if let Ok(glob) = globset::Glob::new(pattern) {
496                let matcher = glob.compile_matcher();
497                if matcher.is_match(&path_to_check) {
498                    log::debug!("Excluding file from LSP linting: {path_to_check}");
499                    return true;
500                }
501            }
502        }
503
504        false
505    }
506
507    /// Lint a document and return diagnostics
508    pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
509        let config_guard = self.config.read().await;
510
511        // Skip linting if disabled
512        if !config_guard.enable_linting {
513            return Ok(Vec::new());
514        }
515
516        let lsp_config = config_guard.clone();
517        drop(config_guard); // Release config lock early
518
519        // Check if file should be excluded based on exclude patterns
520        if self.should_exclude_uri(uri).await {
521            return Ok(Vec::new());
522        }
523
524        // Resolve configuration for this specific file
525        let file_path = uri.to_file_path().ok();
526        let file_config = if let Some(ref path) = file_path {
527            self.resolve_config_for_file(path).await
528        } else {
529            // Fallback to global config for non-file URIs
530            (*self.rumdl_config.read().await).clone()
531        };
532
533        // Merge LSP settings with file config based on configuration_preference
534        let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
535
536        let all_rules = rules::all_rules(&rumdl_config);
537        let flavor = if let Some(ref path) = file_path {
538            rumdl_config.get_flavor_for_file(path)
539        } else {
540            rumdl_config.markdown_flavor()
541        };
542
543        // Use the standard filter_rules function which respects config's disabled rules
544        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
545
546        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
547        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
548
549        // Run rumdl linting with the configured flavor
550        let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
551            Ok(warnings) => warnings,
552            Err(e) => {
553                log::error!("Failed to lint document {uri}: {e}");
554                return Ok(Vec::new());
555            }
556        };
557
558        // Run cross-file checks if workspace index is ready
559        if let Some(ref path) = file_path {
560            let index_state = self.index_state.read().await.clone();
561            if matches!(index_state, IndexState::Ready) {
562                let workspace_index = self.workspace_index.read().await;
563                if let Some(file_index) = workspace_index.get_file(path) {
564                    match crate::run_cross_file_checks(
565                        path,
566                        file_index,
567                        &filtered_rules,
568                        &workspace_index,
569                        Some(&rumdl_config),
570                    ) {
571                        Ok(cross_file_warnings) => {
572                            all_warnings.extend(cross_file_warnings);
573                        }
574                        Err(e) => {
575                            log::warn!("Failed to run cross-file checks for {uri}: {e}");
576                        }
577                    }
578                }
579            }
580        }
581
582        let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
583        Ok(diagnostics)
584    }
585
586    /// Update diagnostics for a document
587    ///
588    /// This method pushes diagnostics to the client via publishDiagnostics.
589    /// When the client supports pull diagnostics (textDocument/diagnostic),
590    /// we skip pushing to avoid duplicate diagnostics.
591    async fn update_diagnostics(&self, uri: Url, text: String) {
592        // Skip pushing if client supports pull diagnostics to avoid duplicates
593        if *self.client_supports_pull_diagnostics.read().await {
594            log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
595            return;
596        }
597
598        // Get the document version if available
599        let version = {
600            let docs = self.documents.read().await;
601            docs.get(&uri).and_then(|entry| entry.version)
602        };
603
604        match self.lint_document(&uri, &text).await {
605            Ok(diagnostics) => {
606                self.client.publish_diagnostics(uri, diagnostics, version).await;
607            }
608            Err(e) => {
609                log::error!("Failed to update diagnostics: {e}");
610            }
611        }
612    }
613
614    /// Apply all available fixes to a document
615    async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
616        // Check if file should be excluded based on exclude patterns
617        if self.should_exclude_uri(uri).await {
618            return Ok(None);
619        }
620
621        let config_guard = self.config.read().await;
622        let lsp_config = config_guard.clone();
623        drop(config_guard);
624
625        // Resolve configuration for this specific file
626        let file_path = uri.to_file_path().ok();
627        let file_config = if let Some(ref path) = file_path {
628            self.resolve_config_for_file(path).await
629        } else {
630            // Fallback to global config for non-file URIs
631            (*self.rumdl_config.read().await).clone()
632        };
633
634        // Merge LSP settings with file config based on configuration_preference
635        let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
636
637        let all_rules = rules::all_rules(&rumdl_config);
638        let flavor = if let Some(ref path) = file_path {
639            rumdl_config.get_flavor_for_file(path)
640        } else {
641            rumdl_config.markdown_flavor()
642        };
643
644        // Use the standard filter_rules function which respects config's disabled rules
645        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
646
647        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
648        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
649
650        // First, run lint to get active warnings (respecting ignore comments)
651        // This tells us which rules actually have unfixed issues
652        let mut rules_with_warnings = std::collections::HashSet::new();
653        let mut fixed_text = text.to_string();
654
655        match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
656            Ok(warnings) => {
657                for warning in warnings {
658                    if let Some(rule_name) = &warning.rule_name {
659                        rules_with_warnings.insert(rule_name.clone());
660                    }
661                }
662            }
663            Err(e) => {
664                log::warn!("Failed to lint document for auto-fix: {e}");
665                return Ok(None);
666            }
667        }
668
669        // Early return if no warnings to fix
670        if rules_with_warnings.is_empty() {
671            return Ok(None);
672        }
673
674        // Only apply fixes for rules that have active warnings
675        let mut any_changes = false;
676
677        for rule in &filtered_rules {
678            // Skip rules that don't have any active warnings
679            if !rules_with_warnings.contains(rule.name()) {
680                continue;
681            }
682
683            let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
684            match rule.fix(&ctx) {
685                Ok(new_text) => {
686                    if new_text != fixed_text {
687                        fixed_text = new_text;
688                        any_changes = true;
689                    }
690                }
691                Err(e) => {
692                    // Only log if it's an actual error, not just "rule doesn't support auto-fix"
693                    let msg = e.to_string();
694                    if !msg.contains("does not support automatic fixing") {
695                        log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
696                    }
697                }
698            }
699        }
700
701        if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
702    }
703
704    /// Get the end position of a document
705    fn get_end_position(&self, text: &str) -> Position {
706        let mut line = 0u32;
707        let mut character = 0u32;
708
709        for ch in text.chars() {
710            if ch == '\n' {
711                line += 1;
712                character = 0;
713            } else {
714                character += 1;
715            }
716        }
717
718        Position { line, character }
719    }
720
721    /// Apply LSP FormattingOptions to content
722    ///
723    /// This implements the standard LSP formatting options that editors send:
724    /// - `trim_trailing_whitespace`: Remove trailing whitespace from each line
725    /// - `insert_final_newline`: Ensure file ends with a newline
726    /// - `trim_final_newlines`: Remove extra blank lines at end of file
727    ///
728    /// This is applied AFTER lint fixes to ensure we respect editor preferences
729    /// even when the editor's buffer content differs from the file on disk
730    /// (e.g., nvim may strip trailing newlines from its buffer representation).
731    fn apply_formatting_options(content: String, options: &FormattingOptions) -> String {
732        // If the original content is empty, keep it empty regardless of options
733        // This prevents marking empty documents as needing formatting
734        if content.is_empty() {
735            return content;
736        }
737
738        let mut result = content.clone();
739        let original_ended_with_newline = content.ends_with('\n');
740
741        // 1. Trim trailing whitespace from each line (if requested)
742        if options.trim_trailing_whitespace.unwrap_or(false) {
743            result = result
744                .lines()
745                .map(|line| line.trim_end())
746                .collect::<Vec<_>>()
747                .join("\n");
748            // Preserve final newline status for next steps
749            if original_ended_with_newline && !result.ends_with('\n') {
750                result.push('\n');
751            }
752        }
753
754        // 2. Trim final newlines (remove extra blank lines at EOF)
755        // This runs BEFORE insert_final_newline to handle the case where
756        // we have multiple trailing newlines and want exactly one
757        if options.trim_final_newlines.unwrap_or(false) {
758            // Remove all trailing newlines
759            while result.ends_with('\n') {
760                result.pop();
761            }
762            // We'll add back exactly one in the next step if insert_final_newline is true
763        }
764
765        // 3. Insert final newline (ensure file ends with exactly one newline)
766        if options.insert_final_newline.unwrap_or(false) && !result.ends_with('\n') {
767            result.push('\n');
768        }
769
770        result
771    }
772
773    /// Get code actions for diagnostics at a position
774    async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
775        let config_guard = self.config.read().await;
776        let lsp_config = config_guard.clone();
777        drop(config_guard);
778
779        // Resolve configuration for this specific file
780        let file_path = uri.to_file_path().ok();
781        let file_config = if let Some(ref path) = file_path {
782            self.resolve_config_for_file(path).await
783        } else {
784            // Fallback to global config for non-file URIs
785            (*self.rumdl_config.read().await).clone()
786        };
787
788        // Merge LSP settings with file config based on configuration_preference
789        let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
790
791        let all_rules = rules::all_rules(&rumdl_config);
792        let flavor = if let Some(ref path) = file_path {
793            rumdl_config.get_flavor_for_file(path)
794        } else {
795            rumdl_config.markdown_flavor()
796        };
797
798        // Use the standard filter_rules function which respects config's disabled rules
799        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
800
801        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
802        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
803
804        match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
805            Ok(warnings) => {
806                let mut actions = Vec::new();
807                let mut fixable_count = 0;
808
809                for warning in &warnings {
810                    // Check if warning is within the requested range
811                    let warning_line = (warning.line.saturating_sub(1)) as u32;
812                    if warning_line >= range.start.line && warning_line <= range.end.line {
813                        // Get all code actions for this warning (fix + ignore actions)
814                        let mut warning_actions = warning_to_code_actions(warning, uri, text);
815                        actions.append(&mut warning_actions);
816
817                        if warning.fix.is_some() {
818                            fixable_count += 1;
819                        }
820                    }
821                }
822
823                // Add "Fix all" action if there are multiple fixable issues in range
824                if fixable_count > 1 {
825                    // Only apply fixes from fixable rules during "Fix all"
826                    // Unfixable rules provide warning-level fixes for individual Quick Fix actions
827                    let fixable_warnings: Vec<_> = warnings
828                        .iter()
829                        .filter(|w| {
830                            if let Some(rule_name) = &w.rule_name {
831                                filtered_rules
832                                    .iter()
833                                    .find(|r| r.name() == rule_name)
834                                    .map(|r| r.fix_capability() != FixCapability::Unfixable)
835                                    .unwrap_or(false)
836                            } else {
837                                false
838                            }
839                        })
840                        .cloned()
841                        .collect();
842
843                    // Count total fixable issues (excluding Unfixable rules)
844                    let total_fixable = fixable_warnings.len();
845
846                    if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
847                        && fixed_content != text
848                    {
849                        // Calculate proper end position
850                        let mut line = 0u32;
851                        let mut character = 0u32;
852                        for ch in text.chars() {
853                            if ch == '\n' {
854                                line += 1;
855                                character = 0;
856                            } else {
857                                character += 1;
858                            }
859                        }
860
861                        let fix_all_action = CodeAction {
862                            title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
863                            kind: Some(CodeActionKind::new("source.fixAll.rumdl")),
864                            diagnostics: Some(Vec::new()),
865                            edit: Some(WorkspaceEdit {
866                                changes: Some(
867                                    [(
868                                        uri.clone(),
869                                        vec![TextEdit {
870                                            range: Range {
871                                                start: Position { line: 0, character: 0 },
872                                                end: Position { line, character },
873                                            },
874                                            new_text: fixed_content,
875                                        }],
876                                    )]
877                                    .into_iter()
878                                    .collect(),
879                                ),
880                                ..Default::default()
881                            }),
882                            command: None,
883                            is_preferred: Some(true),
884                            disabled: None,
885                            data: None,
886                        };
887
888                        // Insert at the beginning to make it prominent
889                        actions.insert(0, fix_all_action);
890                    }
891                }
892
893                Ok(actions)
894            }
895            Err(e) => {
896                log::error!("Failed to get code actions: {e}");
897                Ok(Vec::new())
898            }
899        }
900    }
901
902    /// Load or reload rumdl configuration from files
903    async fn load_configuration(&self, notify_client: bool) {
904        let config_guard = self.config.read().await;
905        let explicit_config_path = config_guard.config_path.clone();
906        drop(config_guard);
907
908        // Use the same discovery logic as CLI but with LSP-specific error handling
909        match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
910            Ok(sourced_config) => {
911                let loaded_files = sourced_config.loaded_files.clone();
912                // Use into_validated_unchecked since LSP doesn't need validation warnings
913                *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
914
915                if !loaded_files.is_empty() {
916                    let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
917                    log::info!("{message}");
918                    if notify_client {
919                        self.client.log_message(MessageType::INFO, &message).await;
920                    }
921                } else {
922                    log::info!("Using default rumdl configuration (no config files found)");
923                }
924            }
925            Err(e) => {
926                let message = format!("Failed to load rumdl config: {e}");
927                log::warn!("{message}");
928                if notify_client {
929                    self.client.log_message(MessageType::WARNING, &message).await;
930                }
931                // Use default configuration
932                *self.rumdl_config.write().await = crate::config::Config::default();
933            }
934        }
935    }
936
937    /// Reload rumdl configuration from files (with client notification)
938    async fn reload_configuration(&self) {
939        self.load_configuration(true).await;
940    }
941
942    /// Load configuration for LSP - similar to CLI loading but returns Result
943    fn load_config_for_lsp(
944        config_path: Option<&str>,
945    ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
946        // Use the same configuration loading as the CLI
947        crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
948    }
949
950    /// Resolve configuration for a specific file
951    ///
952    /// This method searches for a configuration file starting from the file's directory
953    /// and walking up the directory tree until a workspace root is hit or a config is found.
954    ///
955    /// Results are cached to avoid repeated filesystem access.
956    pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
957        // Get the directory to start searching from
958        let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
959
960        // Check cache first
961        {
962            let cache = self.config_cache.read().await;
963            if let Some(entry) = cache.get(&search_dir) {
964                let source_owned: String; // ensure owned storage for logging
965                let source: &str = if entry.from_global_fallback {
966                    "global/user fallback"
967                } else if let Some(path) = &entry.config_file {
968                    source_owned = path.to_string_lossy().to_string();
969                    &source_owned
970                } else {
971                    "<unknown>"
972                };
973                log::debug!(
974                    "Config cache hit for directory: {} (loaded from: {})",
975                    search_dir.display(),
976                    source
977                );
978                return entry.config.clone();
979            }
980        }
981
982        // Cache miss - need to search for config
983        log::debug!(
984            "Config cache miss for directory: {}, searching for config...",
985            search_dir.display()
986        );
987
988        // Try to find workspace root for this file
989        let workspace_root = {
990            let workspace_roots = self.workspace_roots.read().await;
991            workspace_roots
992                .iter()
993                .find(|root| search_dir.starts_with(root))
994                .map(|p| p.to_path_buf())
995        };
996
997        // Search upward from the file's directory
998        let mut current_dir = search_dir.clone();
999        let mut found_config: Option<(Config, Option<PathBuf>)> = None;
1000
1001        loop {
1002            // Try to find a config file in the current directory
1003            const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1004
1005            for config_file_name in CONFIG_FILES {
1006                let config_path = current_dir.join(config_file_name);
1007                if config_path.exists() {
1008                    // For pyproject.toml, verify it contains [tool.rumdl] section (same as CLI)
1009                    if *config_file_name == "pyproject.toml" {
1010                        if let Ok(content) = std::fs::read_to_string(&config_path) {
1011                            if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1012                                log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
1013                            } else {
1014                                log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
1015                                continue;
1016                            }
1017                        } else {
1018                            log::warn!("Failed to read pyproject.toml: {}", config_path.display());
1019                            continue;
1020                        }
1021                    } else {
1022                        log::debug!("Found config file: {}", config_path.display());
1023                    }
1024
1025                    // Load the config
1026                    if let Some(config_path_str) = config_path.to_str() {
1027                        if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
1028                            found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
1029                            break;
1030                        }
1031                    } else {
1032                        log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
1033                    }
1034                }
1035            }
1036
1037            if found_config.is_some() {
1038                break;
1039            }
1040
1041            // Check if we've hit a workspace root
1042            if let Some(ref root) = workspace_root
1043                && &current_dir == root
1044            {
1045                log::debug!("Hit workspace root without finding config: {}", root.display());
1046                break;
1047            }
1048
1049            // Move up to parent directory
1050            if let Some(parent) = current_dir.parent() {
1051                current_dir = parent.to_path_buf();
1052            } else {
1053                // Hit filesystem root
1054                break;
1055            }
1056        }
1057
1058        // Use found config or fall back to global/user config loaded at initialization
1059        let (config, config_file) = if let Some((cfg, path)) = found_config {
1060            (cfg, path)
1061        } else {
1062            log::debug!("No project config found; using global/user fallback config");
1063            let fallback = self.rumdl_config.read().await.clone();
1064            (fallback, None)
1065        };
1066
1067        // Cache the result
1068        let from_global = config_file.is_none();
1069        let entry = ConfigCacheEntry {
1070            config: config.clone(),
1071            config_file,
1072            from_global_fallback: from_global,
1073        };
1074
1075        self.config_cache.write().await.insert(search_dir, entry);
1076
1077        config
1078    }
1079
1080    /// Detect if the cursor is at a fenced code block language position
1081    ///
1082    /// Returns Some((start_column, current_text)) if the cursor is after ``` or ~~~
1083    /// where language completion should be provided.
1084    ///
1085    /// Handles:
1086    /// - Standard fences (``` and ~~~)
1087    /// - Extended fences (4+ backticks/tildes for nested code blocks)
1088    /// - Indented fences
1089    /// - Distinguishes opening vs closing fences
1090    fn detect_code_fence_language_position(text: &str, position: Position) -> Option<(u32, String)> {
1091        let line_num = position.line as usize;
1092        let char_pos = position.character as usize;
1093
1094        // Get the line content
1095        let lines: Vec<&str> = text.lines().collect();
1096        if line_num >= lines.len() {
1097            return None;
1098        }
1099        let line = lines[line_num];
1100        let trimmed = line.trim_start();
1101        let indent = line.len() - trimmed.len();
1102
1103        // Detect fence character and count consecutive fence chars
1104        let (fence_char, fence_len) = if trimmed.starts_with('`') {
1105            let count = trimmed.chars().take_while(|&c| c == '`').count();
1106            if count >= 3 {
1107                ('`', count)
1108            } else {
1109                return None;
1110            }
1111        } else if trimmed.starts_with('~') {
1112            let count = trimmed.chars().take_while(|&c| c == '~').count();
1113            if count >= 3 {
1114                ('~', count)
1115            } else {
1116                return None;
1117            }
1118        } else {
1119            return None;
1120        };
1121
1122        let fence_start = indent;
1123        let fence_end = fence_start + fence_len;
1124
1125        // The cursor must be after the fence
1126        if char_pos < fence_end {
1127            return None;
1128        }
1129
1130        // Check if this is an opening or closing fence by scanning previous lines
1131        // A closing fence has no content after it and matches an unclosed opening fence
1132        let is_closing_fence = Self::is_closing_fence(&lines[..line_num], fence_char, fence_len);
1133        if is_closing_fence {
1134            return None;
1135        }
1136
1137        // Extract the current language text (from fence end to cursor position)
1138        let current_text = if char_pos <= line.len() {
1139            &line[fence_end..char_pos]
1140        } else {
1141            &line[fence_end..]
1142        };
1143
1144        // Don't complete if there's a space (info string contains more than just language)
1145        if current_text.contains(' ') {
1146            return None;
1147        }
1148
1149        Some((fence_end as u32, current_text.to_string()))
1150    }
1151
1152    /// Check if we're inside an unclosed code block (meaning current fence is closing)
1153    fn is_closing_fence(previous_lines: &[&str], fence_char: char, fence_len: usize) -> bool {
1154        let mut open_fences: Vec<(char, usize)> = Vec::new();
1155
1156        for line in previous_lines {
1157            let trimmed = line.trim_start();
1158
1159            // Check for fence
1160            let (line_fence_char, line_fence_len) = if trimmed.starts_with('`') {
1161                let count = trimmed.chars().take_while(|&c| c == '`').count();
1162                if count >= 3 { ('`', count) } else { continue }
1163            } else if trimmed.starts_with('~') {
1164                let count = trimmed.chars().take_while(|&c| c == '~').count();
1165                if count >= 3 { ('~', count) } else { continue }
1166            } else {
1167                continue;
1168            };
1169
1170            // Check if this closes an existing fence
1171            if let Some(pos) = open_fences
1172                .iter()
1173                .rposition(|(c, len)| *c == line_fence_char && line_fence_len >= *len)
1174            {
1175                // Check if this is a closing fence (no content after fence chars)
1176                let after_fence = &trimmed[line_fence_len..].trim();
1177                if after_fence.is_empty() {
1178                    open_fences.truncate(pos);
1179                    continue;
1180                }
1181            }
1182
1183            // This is an opening fence
1184            open_fences.push((line_fence_char, line_fence_len));
1185        }
1186
1187        // Check if current fence would close any open fence
1188        open_fences.iter().any(|(c, len)| *c == fence_char && fence_len >= *len)
1189    }
1190
1191    /// Get language completion items for fenced code blocks
1192    ///
1193    /// Uses GitHub Linguist data and respects MD040 config for filtering
1194    async fn get_language_completions(
1195        &self,
1196        uri: &Url,
1197        current_text: &str,
1198        start_col: u32,
1199        position: Position,
1200    ) -> Vec<CompletionItem> {
1201        // Resolve config for this file to get MD040 settings
1202        let file_path = uri.to_file_path().ok();
1203        let config = if let Some(ref path) = file_path {
1204            self.resolve_config_for_file(path).await
1205        } else {
1206            self.rumdl_config.read().await.clone()
1207        };
1208
1209        // Load MD040 config
1210        let md040_config: MD040Config = load_rule_config(&config);
1211
1212        let mut items = Vec::new();
1213        let current_lower = current_text.to_lowercase();
1214
1215        // Collect all canonical languages and their aliases
1216        let mut language_entries: Vec<(String, String, bool)> = Vec::new(); // (canonical, alias, is_default)
1217
1218        for (canonical, aliases) in CANONICAL_TO_ALIASES.iter() {
1219            // Check if language is allowed
1220            if !md040_config.allowed_languages.is_empty()
1221                && !md040_config
1222                    .allowed_languages
1223                    .iter()
1224                    .any(|a| a.eq_ignore_ascii_case(canonical))
1225            {
1226                continue;
1227            }
1228
1229            // Check if language is disallowed
1230            if md040_config
1231                .disallowed_languages
1232                .iter()
1233                .any(|d| d.eq_ignore_ascii_case(canonical))
1234            {
1235                continue;
1236            }
1237
1238            // Get preferred alias from config, or use default
1239            let preferred = md040_config
1240                .preferred_aliases
1241                .iter()
1242                .find(|(k, _)| k.eq_ignore_ascii_case(canonical))
1243                .map(|(_, v)| v.clone())
1244                .or_else(|| default_alias(canonical).map(|s| s.to_string()))
1245                .unwrap_or_else(|| (*canonical).to_string());
1246
1247            // Add the preferred alias as primary completion
1248            language_entries.push(((*canonical).to_string(), preferred.clone(), true));
1249
1250            // Add other aliases as secondary completions
1251            for &alias in aliases.iter() {
1252                if alias != preferred {
1253                    language_entries.push(((*canonical).to_string(), alias.to_string(), false));
1254                }
1255            }
1256        }
1257
1258        // Filter by current text prefix
1259        for (canonical, alias, is_default) in language_entries {
1260            if !current_text.is_empty() && !alias.to_lowercase().starts_with(&current_lower) {
1261                continue;
1262            }
1263
1264            let sort_priority = if is_default { "0" } else { "1" };
1265
1266            let item = CompletionItem {
1267                label: alias.clone(),
1268                kind: Some(CompletionItemKind::VALUE),
1269                detail: Some(format!("{canonical} (GitHub Linguist)")),
1270                documentation: None,
1271                sort_text: Some(format!("{sort_priority}{alias}")),
1272                filter_text: Some(alias.clone()),
1273                insert_text: Some(alias.clone()),
1274                text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1275                    range: Range {
1276                        start: Position {
1277                            line: position.line,
1278                            character: start_col,
1279                        },
1280                        end: position,
1281                    },
1282                    new_text: alias,
1283                })),
1284                ..Default::default()
1285            };
1286            items.push(item);
1287        }
1288
1289        // Limit results to prevent overwhelming the editor
1290        items.truncate(100);
1291        items
1292    }
1293}
1294
1295#[tower_lsp::async_trait]
1296impl LanguageServer for RumdlLanguageServer {
1297    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1298        log::info!("Initializing rumdl Language Server");
1299
1300        // Parse client capabilities and configuration
1301        if let Some(options) = params.initialization_options
1302            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
1303        {
1304            *self.config.write().await = config;
1305        }
1306
1307        // Detect if client supports pull diagnostics (textDocument/diagnostic)
1308        // When the client supports pull, we avoid pushing to prevent duplicate diagnostics
1309        let supports_pull = params
1310            .capabilities
1311            .text_document
1312            .as_ref()
1313            .and_then(|td| td.diagnostic.as_ref())
1314            .is_some();
1315
1316        if supports_pull {
1317            log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1318            *self.client_supports_pull_diagnostics.write().await = true;
1319        } else {
1320            log::info!("Client does not support pull diagnostics - using push model");
1321        }
1322
1323        // Extract and store workspace roots
1324        let mut roots = Vec::new();
1325        if let Some(workspace_folders) = params.workspace_folders {
1326            for folder in workspace_folders {
1327                if let Ok(path) = folder.uri.to_file_path() {
1328                    log::info!("Workspace root: {}", path.display());
1329                    roots.push(path);
1330                }
1331            }
1332        } else if let Some(root_uri) = params.root_uri
1333            && let Ok(path) = root_uri.to_file_path()
1334        {
1335            log::info!("Workspace root: {}", path.display());
1336            roots.push(path);
1337        }
1338        *self.workspace_roots.write().await = roots;
1339
1340        // Load rumdl configuration with auto-discovery (fallback/default)
1341        self.load_configuration(false).await;
1342
1343        Ok(InitializeResult {
1344            capabilities: ServerCapabilities {
1345                text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1346                    open_close: Some(true),
1347                    change: Some(TextDocumentSyncKind::FULL),
1348                    will_save: Some(false),
1349                    will_save_wait_until: Some(true),
1350                    save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1351                        include_text: Some(false),
1352                    })),
1353                })),
1354                code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
1355                    code_action_kinds: Some(vec![
1356                        CodeActionKind::QUICKFIX,
1357                        CodeActionKind::SOURCE_FIX_ALL,
1358                        CodeActionKind::new("source.fixAll.rumdl"),
1359                    ]),
1360                    work_done_progress_options: WorkDoneProgressOptions::default(),
1361                    resolve_provider: None,
1362                })),
1363                document_formatting_provider: Some(OneOf::Left(true)),
1364                document_range_formatting_provider: Some(OneOf::Left(true)),
1365                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1366                    identifier: Some("rumdl".to_string()),
1367                    inter_file_dependencies: true,
1368                    workspace_diagnostics: false,
1369                    work_done_progress_options: WorkDoneProgressOptions::default(),
1370                })),
1371                completion_provider: Some(CompletionOptions {
1372                    trigger_characters: Some(vec!["`".to_string()]),
1373                    resolve_provider: Some(false),
1374                    work_done_progress_options: WorkDoneProgressOptions::default(),
1375                    all_commit_characters: None,
1376                    completion_item: None,
1377                }),
1378                workspace: Some(WorkspaceServerCapabilities {
1379                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1380                        supported: Some(true),
1381                        change_notifications: Some(OneOf::Left(true)),
1382                    }),
1383                    file_operations: None,
1384                }),
1385                ..Default::default()
1386            },
1387            server_info: Some(ServerInfo {
1388                name: "rumdl".to_string(),
1389                version: Some(env!("CARGO_PKG_VERSION").to_string()),
1390            }),
1391        })
1392    }
1393
1394    async fn initialized(&self, _: InitializedParams) {
1395        let version = env!("CARGO_PKG_VERSION");
1396
1397        // Get binary path and build time
1398        let (binary_path, build_time) = std::env::current_exe()
1399            .ok()
1400            .map(|path| {
1401                let path_str = path.to_str().unwrap_or("unknown").to_string();
1402                let build_time = std::fs::metadata(&path)
1403                    .ok()
1404                    .and_then(|metadata| metadata.modified().ok())
1405                    .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1406                    .and_then(|duration| {
1407                        let secs = duration.as_secs();
1408                        chrono::DateTime::from_timestamp(secs as i64, 0)
1409                            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1410                    })
1411                    .unwrap_or_else(|| "unknown".to_string());
1412                (path_str, build_time)
1413            })
1414            .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1415
1416        let working_dir = std::env::current_dir()
1417            .ok()
1418            .and_then(|p| p.to_str().map(|s| s.to_string()))
1419            .unwrap_or_else(|| "unknown".to_string());
1420
1421        log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1422        log::info!("Working directory: {working_dir}");
1423
1424        self.client
1425            .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1426            .await;
1427
1428        // Trigger initial workspace indexing for cross-file analysis
1429        if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1430            log::warn!("Failed to trigger initial workspace indexing");
1431        } else {
1432            log::info!("Triggered initial workspace indexing for cross-file analysis");
1433        }
1434
1435        // Register file watcher for markdown files to detect external changes
1436        // Watch all supported markdown extensions
1437        let markdown_patterns = [
1438            "**/*.md",
1439            "**/*.markdown",
1440            "**/*.mdx",
1441            "**/*.mkd",
1442            "**/*.mkdn",
1443            "**/*.mdown",
1444            "**/*.mdwn",
1445            "**/*.qmd",
1446            "**/*.rmd",
1447        ];
1448        let watchers: Vec<_> = markdown_patterns
1449            .iter()
1450            .map(|pattern| FileSystemWatcher {
1451                glob_pattern: GlobPattern::String((*pattern).to_string()),
1452                kind: Some(WatchKind::all()),
1453            })
1454            .collect();
1455
1456        let registration = Registration {
1457            id: "markdown-watcher".to_string(),
1458            method: "workspace/didChangeWatchedFiles".to_string(),
1459            register_options: Some(
1460                serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1461            ),
1462        };
1463
1464        if self.client.register_capability(vec![registration]).await.is_err() {
1465            log::debug!("Client does not support file watching capability");
1466        }
1467    }
1468
1469    async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
1470        let uri = params.text_document_position.text_document.uri;
1471        let position = params.text_document_position.position;
1472
1473        // Get document content
1474        let Some(text) = self.get_document_content(&uri).await else {
1475            return Ok(None);
1476        };
1477
1478        // Check if we're at a fenced code block language position
1479        let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) else {
1480            return Ok(None);
1481        };
1482
1483        log::debug!(
1484            "Code fence completion triggered at {}:{}, current text: '{}'",
1485            position.line,
1486            position.character,
1487            current_text
1488        );
1489
1490        // Get completion items
1491        let items = self
1492            .get_language_completions(&uri, &current_text, start_col, position)
1493            .await;
1494
1495        if items.is_empty() {
1496            Ok(None)
1497        } else {
1498            Ok(Some(CompletionResponse::Array(items)))
1499        }
1500    }
1501
1502    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1503        // Update workspace roots
1504        let mut roots = self.workspace_roots.write().await;
1505
1506        // Remove deleted workspace folders
1507        for removed in &params.event.removed {
1508            if let Ok(path) = removed.uri.to_file_path() {
1509                roots.retain(|r| r != &path);
1510                log::info!("Removed workspace root: {}", path.display());
1511            }
1512        }
1513
1514        // Add new workspace folders
1515        for added in &params.event.added {
1516            if let Ok(path) = added.uri.to_file_path()
1517                && !roots.contains(&path)
1518            {
1519                log::info!("Added workspace root: {}", path.display());
1520                roots.push(path);
1521            }
1522        }
1523        drop(roots);
1524
1525        // Clear config cache as workspace structure changed
1526        self.config_cache.write().await.clear();
1527
1528        // Reload fallback configuration
1529        self.reload_configuration().await;
1530
1531        // Trigger full workspace rescan for cross-file index
1532        if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1533            log::warn!("Failed to trigger workspace rescan after folder change");
1534        }
1535    }
1536
1537    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1538        log::debug!("Configuration changed: {:?}", params.settings);
1539
1540        // Parse settings from the notification
1541        // Neovim sends: { "rumdl": { "MD013": {...}, ... } }
1542        // VSCode might send the full RumdlLspConfig or similar structure
1543        let settings_value = params.settings;
1544
1545        // Try to extract "rumdl" key from settings (Neovim style)
1546        let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1547            obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1548        } else {
1549            settings_value
1550        };
1551
1552        // Track if we successfully applied any configuration
1553        let mut config_applied = false;
1554        let mut warnings: Vec<String> = Vec::new();
1555
1556        // Try to parse as LspRuleSettings first (Neovim style with "disable", "enable", rule keys)
1557        // We check this first because RumdlLspConfig with #[serde(default)] will accept any JSON
1558        // and just ignore unknown fields, which would lose the Neovim-style settings
1559        if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1560            && (rule_settings.disable.is_some()
1561                || rule_settings.enable.is_some()
1562                || rule_settings.line_length.is_some()
1563                || !rule_settings.rules.is_empty())
1564        {
1565            // Validate rule names in disable/enable lists
1566            if let Some(ref disable) = rule_settings.disable {
1567                for rule in disable {
1568                    if !is_valid_rule_name(rule) {
1569                        warnings.push(format!("Unknown rule in disable list: {rule}"));
1570                    }
1571                }
1572            }
1573            if let Some(ref enable) = rule_settings.enable {
1574                for rule in enable {
1575                    if !is_valid_rule_name(rule) {
1576                        warnings.push(format!("Unknown rule in enable list: {rule}"));
1577                    }
1578                }
1579            }
1580            // Validate rule-specific settings
1581            for rule_name in rule_settings.rules.keys() {
1582                if !is_valid_rule_name(rule_name) {
1583                    warnings.push(format!("Unknown rule in settings: {rule_name}"));
1584                }
1585            }
1586
1587            log::info!("Applied rule settings from configuration (Neovim style)");
1588            let mut config = self.config.write().await;
1589            config.settings = Some(rule_settings);
1590            drop(config);
1591            config_applied = true;
1592        } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1593            && (full_config.config_path.is_some()
1594                || full_config.enable_rules.is_some()
1595                || full_config.disable_rules.is_some()
1596                || full_config.settings.is_some()
1597                || !full_config.enable_linting
1598                || full_config.enable_auto_fix)
1599        {
1600            // Validate rule names
1601            if let Some(ref rules) = full_config.enable_rules {
1602                for rule in rules {
1603                    if !is_valid_rule_name(rule) {
1604                        warnings.push(format!("Unknown rule in enableRules: {rule}"));
1605                    }
1606                }
1607            }
1608            if let Some(ref rules) = full_config.disable_rules {
1609                for rule in rules {
1610                    if !is_valid_rule_name(rule) {
1611                        warnings.push(format!("Unknown rule in disableRules: {rule}"));
1612                    }
1613                }
1614            }
1615
1616            log::info!("Applied full LSP configuration from settings");
1617            *self.config.write().await = full_config;
1618            config_applied = true;
1619        } else if let serde_json::Value::Object(obj) = rumdl_settings {
1620            // Otherwise, treat as per-rule settings with manual parsing
1621            // Format: { "MD013": { "lineLength": 80 }, "disable": ["MD009"] }
1622            let mut config = self.config.write().await;
1623
1624            // Manual parsing for Neovim format
1625            let mut rules = std::collections::HashMap::new();
1626            let mut disable = Vec::new();
1627            let mut enable = Vec::new();
1628            let mut line_length = None;
1629
1630            for (key, value) in obj {
1631                match key.as_str() {
1632                    "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1633                        Ok(d) => {
1634                            if d.len() > MAX_RULE_LIST_SIZE {
1635                                warnings.push(format!(
1636                                    "Too many rules in 'disable' ({} > {}), truncating",
1637                                    d.len(),
1638                                    MAX_RULE_LIST_SIZE
1639                                ));
1640                            }
1641                            for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1642                                if !is_valid_rule_name(rule) {
1643                                    warnings.push(format!("Unknown rule in disable: {rule}"));
1644                                }
1645                            }
1646                            disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1647                        }
1648                        Err(_) => {
1649                            warnings.push(format!(
1650                                "Invalid 'disable' value: expected array of strings, got {value}"
1651                            ));
1652                        }
1653                    },
1654                    "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1655                        Ok(e) => {
1656                            if e.len() > MAX_RULE_LIST_SIZE {
1657                                warnings.push(format!(
1658                                    "Too many rules in 'enable' ({} > {}), truncating",
1659                                    e.len(),
1660                                    MAX_RULE_LIST_SIZE
1661                                ));
1662                            }
1663                            for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1664                                if !is_valid_rule_name(rule) {
1665                                    warnings.push(format!("Unknown rule in enable: {rule}"));
1666                                }
1667                            }
1668                            enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1669                        }
1670                        Err(_) => {
1671                            warnings.push(format!(
1672                                "Invalid 'enable' value: expected array of strings, got {value}"
1673                            ));
1674                        }
1675                    },
1676                    "lineLength" | "line_length" | "line-length" => {
1677                        if let Some(l) = value.as_u64() {
1678                            match usize::try_from(l) {
1679                                Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1680                                Ok(len) => warnings.push(format!(
1681                                    "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1682                                )),
1683                                Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1684                            }
1685                        } else {
1686                            warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1687                        }
1688                    }
1689                    // Rule-specific settings (e.g., "MD013": { "lineLength": 80 })
1690                    _ if key.starts_with("MD") || key.starts_with("md") => {
1691                        let normalized = key.to_uppercase();
1692                        if !is_valid_rule_name(&normalized) {
1693                            warnings.push(format!("Unknown rule: {key}"));
1694                        }
1695                        rules.insert(normalized, value);
1696                    }
1697                    _ => {
1698                        // Unknown key - warn and ignore
1699                        warnings.push(format!("Unknown configuration key: {key}"));
1700                    }
1701                }
1702            }
1703
1704            let settings = LspRuleSettings {
1705                line_length,
1706                disable: if disable.is_empty() { None } else { Some(disable) },
1707                enable: if enable.is_empty() { None } else { Some(enable) },
1708                rules,
1709            };
1710
1711            log::info!("Applied Neovim-style rule settings (manual parse)");
1712            config.settings = Some(settings);
1713            drop(config);
1714            config_applied = true;
1715        } else {
1716            log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1717        }
1718
1719        // Log warnings for invalid configuration
1720        for warning in &warnings {
1721            log::warn!("{warning}");
1722        }
1723
1724        // Notify client of configuration warnings via window/logMessage
1725        if !warnings.is_empty() {
1726            let message = if warnings.len() == 1 {
1727                format!("rumdl: {}", warnings[0])
1728            } else {
1729                format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1730            };
1731            self.client.log_message(MessageType::WARNING, message).await;
1732        }
1733
1734        if !config_applied {
1735            log::debug!("No configuration changes applied");
1736        }
1737
1738        // Clear config cache to pick up new settings
1739        self.config_cache.write().await.clear();
1740
1741        // Collect all open documents first (to avoid holding lock during async operations)
1742        let doc_list: Vec<_> = {
1743            let documents = self.documents.read().await;
1744            documents
1745                .iter()
1746                .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1747                .collect()
1748        };
1749
1750        // Refresh diagnostics for all open documents concurrently
1751        let tasks = doc_list.into_iter().map(|(uri, text)| {
1752            let server = self.clone();
1753            tokio::spawn(async move {
1754                server.update_diagnostics(uri, text).await;
1755            })
1756        });
1757
1758        // Wait for all diagnostics to complete
1759        let _ = join_all(tasks).await;
1760    }
1761
1762    async fn shutdown(&self) -> JsonRpcResult<()> {
1763        log::info!("Shutting down rumdl Language Server");
1764
1765        // Signal the index worker to shut down
1766        let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1767
1768        Ok(())
1769    }
1770
1771    async fn did_open(&self, params: DidOpenTextDocumentParams) {
1772        let uri = params.text_document.uri;
1773        let text = params.text_document.text;
1774        let version = params.text_document.version;
1775
1776        let entry = DocumentEntry {
1777            content: text.clone(),
1778            version: Some(version),
1779            from_disk: false,
1780        };
1781        self.documents.write().await.insert(uri.clone(), entry);
1782
1783        // Send update to index worker for cross-file analysis
1784        if let Ok(path) = uri.to_file_path() {
1785            let _ = self
1786                .update_tx
1787                .send(IndexUpdate::FileChanged {
1788                    path,
1789                    content: text.clone(),
1790                })
1791                .await;
1792        }
1793
1794        self.update_diagnostics(uri, text).await;
1795    }
1796
1797    async fn did_change(&self, params: DidChangeTextDocumentParams) {
1798        let uri = params.text_document.uri;
1799        let version = params.text_document.version;
1800
1801        if let Some(change) = params.content_changes.into_iter().next() {
1802            let text = change.text;
1803
1804            let entry = DocumentEntry {
1805                content: text.clone(),
1806                version: Some(version),
1807                from_disk: false,
1808            };
1809            self.documents.write().await.insert(uri.clone(), entry);
1810
1811            // Send update to index worker for cross-file analysis
1812            if let Ok(path) = uri.to_file_path() {
1813                let _ = self
1814                    .update_tx
1815                    .send(IndexUpdate::FileChanged {
1816                        path,
1817                        content: text.clone(),
1818                    })
1819                    .await;
1820            }
1821
1822            self.update_diagnostics(uri, text).await;
1823        }
1824    }
1825
1826    async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1827        // Only apply fixes on manual saves (Cmd+S / Ctrl+S), not on autosave
1828        // This respects VSCode's editor.formatOnSave: "explicit" setting
1829        if params.reason != TextDocumentSaveReason::MANUAL {
1830            return Ok(None);
1831        }
1832
1833        let config_guard = self.config.read().await;
1834        let enable_auto_fix = config_guard.enable_auto_fix;
1835        drop(config_guard);
1836
1837        if !enable_auto_fix {
1838            return Ok(None);
1839        }
1840
1841        // Get the current document content
1842        let Some(text) = self.get_document_content(&params.text_document.uri).await else {
1843            return Ok(None);
1844        };
1845
1846        // Apply all fixes
1847        match self.apply_all_fixes(&params.text_document.uri, &text).await {
1848            Ok(Some(fixed_text)) => {
1849                // Return a single edit that replaces the entire document
1850                Ok(Some(vec![TextEdit {
1851                    range: Range {
1852                        start: Position { line: 0, character: 0 },
1853                        end: self.get_end_position(&text),
1854                    },
1855                    new_text: fixed_text,
1856                }]))
1857            }
1858            Ok(None) => Ok(None),
1859            Err(e) => {
1860                log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1861                Ok(None)
1862            }
1863        }
1864    }
1865
1866    async fn did_save(&self, params: DidSaveTextDocumentParams) {
1867        // Re-lint the document after save
1868        // Note: Auto-fixing is now handled by will_save_wait_until which runs before the save
1869        if let Some(entry) = self.documents.read().await.get(&params.text_document.uri) {
1870            self.update_diagnostics(params.text_document.uri, entry.content.clone())
1871                .await;
1872        }
1873    }
1874
1875    async fn did_close(&self, params: DidCloseTextDocumentParams) {
1876        // Remove document from storage
1877        self.documents.write().await.remove(&params.text_document.uri);
1878
1879        // Always clear diagnostics on close to ensure cleanup
1880        // (Ruff does this unconditionally as a defensive measure)
1881        self.client
1882            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1883            .await;
1884    }
1885
1886    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1887        // Check if any of the changed files are config files
1888        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1889
1890        let mut config_changed = false;
1891
1892        for change in &params.changes {
1893            if let Ok(path) = change.uri.to_file_path() {
1894                let file_name = path.file_name().and_then(|f| f.to_str());
1895                let extension = path.extension().and_then(|e| e.to_str());
1896
1897                // Handle config file changes
1898                if let Some(name) = file_name
1899                    && CONFIG_FILES.contains(&name)
1900                    && !config_changed
1901                {
1902                    log::info!("Config file changed: {}, invalidating config cache", path.display());
1903
1904                    // Invalidate all cache entries that were loaded from this config file
1905                    let mut cache = self.config_cache.write().await;
1906                    cache.retain(|_, entry| {
1907                        if let Some(config_file) = &entry.config_file {
1908                            config_file != &path
1909                        } else {
1910                            true
1911                        }
1912                    });
1913
1914                    // Also reload the global fallback configuration
1915                    drop(cache);
1916                    self.reload_configuration().await;
1917                    config_changed = true;
1918                }
1919
1920                // Handle markdown file changes for workspace index
1921                if let Some(ext) = extension
1922                    && is_markdown_extension(ext)
1923                {
1924                    match change.typ {
1925                        FileChangeType::CREATED | FileChangeType::CHANGED => {
1926                            // Read file content and update index
1927                            if let Ok(content) = tokio::fs::read_to_string(&path).await {
1928                                let _ = self
1929                                    .update_tx
1930                                    .send(IndexUpdate::FileChanged {
1931                                        path: path.clone(),
1932                                        content,
1933                                    })
1934                                    .await;
1935                            }
1936                        }
1937                        FileChangeType::DELETED => {
1938                            let _ = self
1939                                .update_tx
1940                                .send(IndexUpdate::FileDeleted { path: path.clone() })
1941                                .await;
1942                        }
1943                        _ => {}
1944                    }
1945                }
1946            }
1947        }
1948
1949        // Re-lint all open documents if config changed
1950        if config_changed {
1951            let docs_to_update: Vec<(Url, String)> = {
1952                let docs = self.documents.read().await;
1953                docs.iter()
1954                    .filter(|(_, entry)| !entry.from_disk)
1955                    .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1956                    .collect()
1957            };
1958
1959            for (uri, text) in docs_to_update {
1960                self.update_diagnostics(uri, text).await;
1961            }
1962        }
1963    }
1964
1965    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1966        let uri = params.text_document.uri;
1967        let range = params.range;
1968        let requested_kinds = params.context.only;
1969
1970        if let Some(text) = self.get_document_content(&uri).await {
1971            match self.get_code_actions(&uri, &text, range).await {
1972                Ok(actions) => {
1973                    // Filter actions by requested kinds (if specified and non-empty)
1974                    // LSP spec: "If provided with no kinds, all supported kinds are returned"
1975                    // LSP code action kinds are hierarchical: source.fixAll.rumdl matches source.fixAll
1976                    let filtered_actions = if let Some(ref kinds) = requested_kinds
1977                        && !kinds.is_empty()
1978                    {
1979                        actions
1980                            .into_iter()
1981                            .filter(|action| {
1982                                action.kind.as_ref().is_some_and(|action_kind| {
1983                                    let action_kind_str = action_kind.as_str();
1984                                    kinds.iter().any(|requested| {
1985                                        let requested_str = requested.as_str();
1986                                        // Match if action kind starts with requested kind
1987                                        // e.g., "source.fixAll.rumdl" matches "source.fixAll"
1988                                        action_kind_str.starts_with(requested_str)
1989                                    })
1990                                })
1991                            })
1992                            .collect()
1993                    } else {
1994                        actions
1995                    };
1996
1997                    let response: Vec<CodeActionOrCommand> = filtered_actions
1998                        .into_iter()
1999                        .map(CodeActionOrCommand::CodeAction)
2000                        .collect();
2001                    Ok(Some(response))
2002                }
2003                Err(e) => {
2004                    log::error!("Failed to get code actions: {e}");
2005                    Ok(None)
2006                }
2007            }
2008        } else {
2009            Ok(None)
2010        }
2011    }
2012
2013    async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2014        // For markdown linting, we format the entire document because:
2015        // 1. Many markdown rules have document-wide implications (e.g., heading hierarchy, list consistency)
2016        // 2. Fixes often need surrounding context to be applied correctly
2017        // 3. This approach is common among linters (ESLint, rustfmt, etc. do similar)
2018        log::debug!(
2019            "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
2020            params.range
2021        );
2022
2023        let formatting_params = DocumentFormattingParams {
2024            text_document: params.text_document,
2025            options: params.options,
2026            work_done_progress_params: params.work_done_progress_params,
2027        };
2028
2029        self.formatting(formatting_params).await
2030    }
2031
2032    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2033        let uri = params.text_document.uri;
2034        let options = params.options;
2035
2036        log::debug!("Formatting request for: {uri}");
2037        log::debug!(
2038            "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
2039            options.insert_final_newline,
2040            options.trim_final_newlines,
2041            options.trim_trailing_whitespace
2042        );
2043
2044        if let Some(text) = self.get_document_content(&uri).await {
2045            // Get config with LSP overrides
2046            let config_guard = self.config.read().await;
2047            let lsp_config = config_guard.clone();
2048            drop(config_guard);
2049
2050            // Resolve configuration for this specific file
2051            let file_path = uri.to_file_path().ok();
2052            let file_config = if let Some(ref path) = file_path {
2053                self.resolve_config_for_file(path).await
2054            } else {
2055                // Fallback to global config for non-file URIs
2056                self.rumdl_config.read().await.clone()
2057            };
2058
2059            // Merge LSP settings with file config based on configuration_preference
2060            let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
2061
2062            let all_rules = rules::all_rules(&rumdl_config);
2063            let flavor = if let Some(ref path) = file_path {
2064                rumdl_config.get_flavor_for_file(path)
2065            } else {
2066                rumdl_config.markdown_flavor()
2067            };
2068
2069            // Use the standard filter_rules function which respects config's disabled rules
2070            let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
2071
2072            // Apply LSP config overrides
2073            filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
2074
2075            // Phase 1: Apply lint rule fixes
2076            let mut result = text.clone();
2077            match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
2078                Ok(warnings) => {
2079                    log::debug!(
2080                        "Found {} warnings, {} with fixes",
2081                        warnings.len(),
2082                        warnings.iter().filter(|w| w.fix.is_some()).count()
2083                    );
2084
2085                    let has_fixes = warnings.iter().any(|w| w.fix.is_some());
2086                    if has_fixes {
2087                        // Only apply fixes from fixable rules during formatting
2088                        let fixable_warnings: Vec<_> = warnings
2089                            .iter()
2090                            .filter(|w| {
2091                                if let Some(rule_name) = &w.rule_name {
2092                                    filtered_rules
2093                                        .iter()
2094                                        .find(|r| r.name() == rule_name)
2095                                        .map(|r| r.fix_capability() != FixCapability::Unfixable)
2096                                        .unwrap_or(false)
2097                                } else {
2098                                    false
2099                                }
2100                            })
2101                            .cloned()
2102                            .collect();
2103
2104                        match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
2105                            Ok(fixed_content) => {
2106                                result = fixed_content;
2107                            }
2108                            Err(e) => {
2109                                log::error!("Failed to apply fixes: {e}");
2110                            }
2111                        }
2112                    }
2113                }
2114                Err(e) => {
2115                    log::error!("Failed to lint document: {e}");
2116                }
2117            }
2118
2119            // Phase 2: Apply FormattingOptions (standard LSP behavior)
2120            // This ensures we respect editor preferences even if lint rules don't catch everything
2121            result = Self::apply_formatting_options(result, &options);
2122
2123            // Return edit if content changed
2124            if result != text {
2125                log::debug!("Returning formatting edits");
2126                let end_position = self.get_end_position(&text);
2127                let edit = TextEdit {
2128                    range: Range {
2129                        start: Position { line: 0, character: 0 },
2130                        end: end_position,
2131                    },
2132                    new_text: result,
2133                };
2134                return Ok(Some(vec![edit]));
2135            }
2136
2137            Ok(Some(Vec::new()))
2138        } else {
2139            log::warn!("Document not found: {uri}");
2140            Ok(None)
2141        }
2142    }
2143
2144    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
2145        let uri = params.text_document.uri;
2146
2147        if let Some(text) = self.get_open_document_content(&uri).await {
2148            match self.lint_document(&uri, &text).await {
2149                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
2150                    RelatedFullDocumentDiagnosticReport {
2151                        related_documents: None,
2152                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
2153                            result_id: None,
2154                            items: diagnostics,
2155                        },
2156                    },
2157                ))),
2158                Err(e) => {
2159                    log::error!("Failed to get diagnostics: {e}");
2160                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
2161                        RelatedFullDocumentDiagnosticReport {
2162                            related_documents: None,
2163                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
2164                                result_id: None,
2165                                items: Vec::new(),
2166                            },
2167                        },
2168                    )))
2169                }
2170            }
2171        } else {
2172            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
2173                RelatedFullDocumentDiagnosticReport {
2174                    related_documents: None,
2175                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
2176                        result_id: None,
2177                        items: Vec::new(),
2178                    },
2179                },
2180            )))
2181        }
2182    }
2183}
2184
2185#[cfg(test)]
2186mod tests {
2187    use super::*;
2188    use crate::rule::LintWarning;
2189    use tower_lsp::LspService;
2190
2191    fn create_test_server() -> RumdlLanguageServer {
2192        let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
2193        service.inner().clone()
2194    }
2195
2196    #[test]
2197    fn test_is_valid_rule_name() {
2198        // Valid rule names - canonical MDxxx format
2199        assert!(is_valid_rule_name("MD001"));
2200        assert!(is_valid_rule_name("md001")); // lowercase
2201        assert!(is_valid_rule_name("Md001")); // mixed case
2202        assert!(is_valid_rule_name("mD001")); // mixed case
2203        assert!(is_valid_rule_name("MD003"));
2204        assert!(is_valid_rule_name("MD005"));
2205        assert!(is_valid_rule_name("MD007"));
2206        assert!(is_valid_rule_name("MD009"));
2207        assert!(is_valid_rule_name("MD041"));
2208        assert!(is_valid_rule_name("MD060"));
2209        assert!(is_valid_rule_name("MD061"));
2210
2211        // Valid rule names - special "all" value
2212        assert!(is_valid_rule_name("all"));
2213        assert!(is_valid_rule_name("ALL"));
2214        assert!(is_valid_rule_name("All"));
2215
2216        // Valid rule names - aliases (new in shared implementation)
2217        assert!(is_valid_rule_name("line-length")); // alias for MD013
2218        assert!(is_valid_rule_name("LINE-LENGTH")); // case insensitive
2219        assert!(is_valid_rule_name("heading-increment")); // alias for MD001
2220        assert!(is_valid_rule_name("no-bare-urls")); // alias for MD034
2221        assert!(is_valid_rule_name("ul-style")); // alias for MD004
2222        assert!(is_valid_rule_name("ul_style")); // underscore variant
2223
2224        // Invalid rule names - not in alias map
2225        assert!(!is_valid_rule_name("MD000")); // doesn't exist
2226        assert!(!is_valid_rule_name("MD999")); // doesn't exist
2227        assert!(!is_valid_rule_name("MD100")); // doesn't exist
2228        assert!(!is_valid_rule_name("INVALID"));
2229        assert!(!is_valid_rule_name("not-a-rule"));
2230        assert!(!is_valid_rule_name(""));
2231        assert!(!is_valid_rule_name("random-text"));
2232    }
2233
2234    #[tokio::test]
2235    async fn test_server_creation() {
2236        let server = create_test_server();
2237
2238        // Verify default configuration
2239        let config = server.config.read().await;
2240        assert!(config.enable_linting);
2241        assert!(!config.enable_auto_fix);
2242    }
2243
2244    #[tokio::test]
2245    async fn test_lint_document() {
2246        let server = create_test_server();
2247
2248        // Test linting with a simple markdown document
2249        let uri = Url::parse("file:///test.md").unwrap();
2250        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
2251
2252        let diagnostics = server.lint_document(&uri, text).await.unwrap();
2253
2254        // Should find trailing spaces violations
2255        assert!(!diagnostics.is_empty());
2256        assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
2257    }
2258
2259    #[tokio::test]
2260    async fn test_lint_document_disabled() {
2261        let server = create_test_server();
2262
2263        // Disable linting
2264        server.config.write().await.enable_linting = false;
2265
2266        let uri = Url::parse("file:///test.md").unwrap();
2267        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
2268
2269        let diagnostics = server.lint_document(&uri, text).await.unwrap();
2270
2271        // Should return empty diagnostics when disabled
2272        assert!(diagnostics.is_empty());
2273    }
2274
2275    #[tokio::test]
2276    async fn test_get_code_actions() {
2277        let server = create_test_server();
2278
2279        let uri = Url::parse("file:///test.md").unwrap();
2280        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
2281
2282        // Create a range covering the whole document
2283        let range = Range {
2284            start: Position { line: 0, character: 0 },
2285            end: Position { line: 3, character: 21 },
2286        };
2287
2288        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2289
2290        // Should have code actions for fixing trailing spaces
2291        assert!(!actions.is_empty());
2292        assert!(actions.iter().any(|a| a.title.contains("trailing")));
2293    }
2294
2295    #[tokio::test]
2296    async fn test_get_code_actions_outside_range() {
2297        let server = create_test_server();
2298
2299        let uri = Url::parse("file:///test.md").unwrap();
2300        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
2301
2302        // Create a range that doesn't cover the violations
2303        let range = Range {
2304            start: Position { line: 0, character: 0 },
2305            end: Position { line: 0, character: 6 },
2306        };
2307
2308        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2309
2310        // Should have no code actions for this range
2311        assert!(actions.is_empty());
2312    }
2313
2314    #[tokio::test]
2315    async fn test_document_storage() {
2316        let server = create_test_server();
2317
2318        let uri = Url::parse("file:///test.md").unwrap();
2319        let text = "# Test Document";
2320
2321        // Store document
2322        let entry = DocumentEntry {
2323            content: text.to_string(),
2324            version: Some(1),
2325            from_disk: false,
2326        };
2327        server.documents.write().await.insert(uri.clone(), entry);
2328
2329        // Verify storage
2330        let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
2331        assert_eq!(stored, Some(text.to_string()));
2332
2333        // Remove document
2334        server.documents.write().await.remove(&uri);
2335
2336        // Verify removal
2337        let stored = server.documents.read().await.get(&uri).cloned();
2338        assert_eq!(stored, None);
2339    }
2340
2341    #[tokio::test]
2342    async fn test_configuration_loading() {
2343        let server = create_test_server();
2344
2345        // Load configuration with auto-discovery
2346        server.load_configuration(false).await;
2347
2348        // Verify configuration was loaded successfully
2349        // The config could be from: .rumdl.toml, pyproject.toml, .markdownlint.json, or default
2350        let rumdl_config = server.rumdl_config.read().await;
2351        // The loaded config is valid regardless of source
2352        drop(rumdl_config); // Just verify we can access it without panic
2353    }
2354
2355    #[tokio::test]
2356    async fn test_load_config_for_lsp() {
2357        // Test with no config file
2358        let result = RumdlLanguageServer::load_config_for_lsp(None);
2359        assert!(result.is_ok());
2360
2361        // Test with non-existent config file
2362        let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2363        assert!(result.is_err());
2364    }
2365
2366    #[tokio::test]
2367    async fn test_warning_conversion() {
2368        let warning = LintWarning {
2369            message: "Test warning".to_string(),
2370            line: 1,
2371            column: 1,
2372            end_line: 1,
2373            end_column: 10,
2374            severity: crate::rule::Severity::Warning,
2375            fix: None,
2376            rule_name: Some("MD001".to_string()),
2377        };
2378
2379        // Test diagnostic conversion
2380        let diagnostic = warning_to_diagnostic(&warning);
2381        assert_eq!(diagnostic.message, "Test warning");
2382        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2383        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2384
2385        // Test code action conversion (no fix, but should have ignore action)
2386        let uri = Url::parse("file:///test.md").unwrap();
2387        let actions = warning_to_code_actions(&warning, &uri, "Test content");
2388        // Should have 1 action: ignore-line (no fix available)
2389        assert_eq!(actions.len(), 1);
2390        assert_eq!(actions[0].title, "Ignore MD001 for this line");
2391    }
2392
2393    #[tokio::test]
2394    async fn test_multiple_documents() {
2395        let server = create_test_server();
2396
2397        let uri1 = Url::parse("file:///test1.md").unwrap();
2398        let uri2 = Url::parse("file:///test2.md").unwrap();
2399        let text1 = "# Document 1";
2400        let text2 = "# Document 2";
2401
2402        // Store multiple documents
2403        {
2404            let mut docs = server.documents.write().await;
2405            let entry1 = DocumentEntry {
2406                content: text1.to_string(),
2407                version: Some(1),
2408                from_disk: false,
2409            };
2410            let entry2 = DocumentEntry {
2411                content: text2.to_string(),
2412                version: Some(1),
2413                from_disk: false,
2414            };
2415            docs.insert(uri1.clone(), entry1);
2416            docs.insert(uri2.clone(), entry2);
2417        }
2418
2419        // Verify both are stored
2420        let docs = server.documents.read().await;
2421        assert_eq!(docs.len(), 2);
2422        assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2423        assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2424    }
2425
2426    #[tokio::test]
2427    async fn test_auto_fix_on_save() {
2428        let server = create_test_server();
2429
2430        // Enable auto-fix
2431        {
2432            let mut config = server.config.write().await;
2433            config.enable_auto_fix = true;
2434        }
2435
2436        let uri = Url::parse("file:///test.md").unwrap();
2437        let text = "#Heading without space"; // MD018 violation
2438
2439        // Store document
2440        let entry = DocumentEntry {
2441            content: text.to_string(),
2442            version: Some(1),
2443            from_disk: false,
2444        };
2445        server.documents.write().await.insert(uri.clone(), entry);
2446
2447        // Test apply_all_fixes
2448        let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2449        assert!(fixed.is_some());
2450        // MD018 adds space, MD047 adds trailing newline
2451        assert_eq!(fixed.unwrap(), "# Heading without space\n");
2452    }
2453
2454    #[tokio::test]
2455    async fn test_get_end_position() {
2456        let server = create_test_server();
2457
2458        // Single line
2459        let pos = server.get_end_position("Hello");
2460        assert_eq!(pos.line, 0);
2461        assert_eq!(pos.character, 5);
2462
2463        // Multiple lines
2464        let pos = server.get_end_position("Hello\nWorld\nTest");
2465        assert_eq!(pos.line, 2);
2466        assert_eq!(pos.character, 4);
2467
2468        // Empty string
2469        let pos = server.get_end_position("");
2470        assert_eq!(pos.line, 0);
2471        assert_eq!(pos.character, 0);
2472
2473        // Ends with newline - position should be at start of next line
2474        let pos = server.get_end_position("Hello\n");
2475        assert_eq!(pos.line, 1);
2476        assert_eq!(pos.character, 0);
2477    }
2478
2479    #[tokio::test]
2480    async fn test_empty_document_handling() {
2481        let server = create_test_server();
2482
2483        let uri = Url::parse("file:///empty.md").unwrap();
2484        let text = "";
2485
2486        // Test linting empty document
2487        let diagnostics = server.lint_document(&uri, text).await.unwrap();
2488        assert!(diagnostics.is_empty());
2489
2490        // Test code actions on empty document
2491        let range = Range {
2492            start: Position { line: 0, character: 0 },
2493            end: Position { line: 0, character: 0 },
2494        };
2495        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2496        assert!(actions.is_empty());
2497    }
2498
2499    #[tokio::test]
2500    async fn test_config_update() {
2501        let server = create_test_server();
2502
2503        // Update config
2504        {
2505            let mut config = server.config.write().await;
2506            config.enable_auto_fix = true;
2507            config.config_path = Some("/custom/path.toml".to_string());
2508        }
2509
2510        // Verify update
2511        let config = server.config.read().await;
2512        assert!(config.enable_auto_fix);
2513        assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2514    }
2515
2516    #[tokio::test]
2517    async fn test_document_formatting() {
2518        let server = create_test_server();
2519        let uri = Url::parse("file:///test.md").unwrap();
2520        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
2521
2522        // Store document
2523        let entry = DocumentEntry {
2524            content: text.to_string(),
2525            version: Some(1),
2526            from_disk: false,
2527        };
2528        server.documents.write().await.insert(uri.clone(), entry);
2529
2530        // Create formatting params
2531        let params = DocumentFormattingParams {
2532            text_document: TextDocumentIdentifier { uri: uri.clone() },
2533            options: FormattingOptions {
2534                tab_size: 4,
2535                insert_spaces: true,
2536                properties: HashMap::new(),
2537                trim_trailing_whitespace: Some(true),
2538                insert_final_newline: Some(true),
2539                trim_final_newlines: Some(true),
2540            },
2541            work_done_progress_params: WorkDoneProgressParams::default(),
2542        };
2543
2544        // Call formatting
2545        let result = server.formatting(params).await.unwrap();
2546
2547        // Should return text edits that fix the trailing spaces
2548        assert!(result.is_some());
2549        let edits = result.unwrap();
2550        assert!(!edits.is_empty());
2551
2552        // The new text should have trailing spaces removed from ALL lines
2553        // because trim_trailing_whitespace: Some(true) is set
2554        let edit = &edits[0];
2555        // The formatted text should have:
2556        // - Trailing spaces removed from ALL lines (trim_trailing_whitespace)
2557        // - Exactly one final newline (trim_final_newlines + insert_final_newline)
2558        let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
2559        assert_eq!(edit.new_text, expected);
2560    }
2561
2562    /// Test that Unfixable rules are excluded from formatting/Fix All but available for Quick Fix
2563    /// Regression test for issue #158: formatting deleted HTML img tags
2564    #[tokio::test]
2565    async fn test_unfixable_rules_excluded_from_formatting() {
2566        let server = create_test_server();
2567        let uri = Url::parse("file:///test.md").unwrap();
2568
2569        // Content with both fixable (trailing spaces) and unfixable (HTML) issues
2570        let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces  ";
2571
2572        // Store document
2573        let entry = DocumentEntry {
2574            content: text.to_string(),
2575            version: Some(1),
2576            from_disk: false,
2577        };
2578        server.documents.write().await.insert(uri.clone(), entry);
2579
2580        // Test 1: Formatting should preserve HTML (Unfixable) but fix trailing spaces (fixable)
2581        let format_params = DocumentFormattingParams {
2582            text_document: TextDocumentIdentifier { uri: uri.clone() },
2583            options: FormattingOptions {
2584                tab_size: 4,
2585                insert_spaces: true,
2586                properties: HashMap::new(),
2587                trim_trailing_whitespace: Some(true),
2588                insert_final_newline: Some(true),
2589                trim_final_newlines: Some(true),
2590            },
2591            work_done_progress_params: WorkDoneProgressParams::default(),
2592        };
2593
2594        let format_result = server.formatting(format_params).await.unwrap();
2595        assert!(format_result.is_some(), "Should return formatting edits");
2596
2597        let edits = format_result.unwrap();
2598        assert!(!edits.is_empty(), "Should have formatting edits");
2599
2600        let formatted = &edits[0].new_text;
2601        assert!(
2602            formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2603            "HTML should be preserved during formatting (Unfixable rule)"
2604        );
2605        assert!(
2606            !formatted.contains("spaces  "),
2607            "Trailing spaces should be removed (fixable rule)"
2608        );
2609
2610        // Test 2: Quick Fix actions should still be available for Unfixable rules
2611        let range = Range {
2612            start: Position { line: 0, character: 0 },
2613            end: Position { line: 10, character: 0 },
2614        };
2615
2616        let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2617
2618        // Should have individual Quick Fix actions for each warning
2619        let html_fix_actions: Vec<_> = code_actions
2620            .iter()
2621            .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2622            .collect();
2623
2624        assert!(
2625            !html_fix_actions.is_empty(),
2626            "Quick Fix actions should be available for HTML (Unfixable rules)"
2627        );
2628
2629        // Test 3: "Fix All" action should exclude Unfixable rules
2630        let fix_all_actions: Vec<_> = code_actions
2631            .iter()
2632            .filter(|action| action.title.contains("Fix all"))
2633            .collect();
2634
2635        if let Some(fix_all_action) = fix_all_actions.first()
2636            && let Some(ref edit) = fix_all_action.edit
2637            && let Some(ref changes) = edit.changes
2638            && let Some(text_edits) = changes.get(&uri)
2639            && let Some(text_edit) = text_edits.first()
2640        {
2641            let fixed_all = &text_edit.new_text;
2642            assert!(
2643                fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2644                "Fix All should preserve HTML (Unfixable rules)"
2645            );
2646            assert!(
2647                !fixed_all.contains("spaces  "),
2648                "Fix All should remove trailing spaces (fixable rules)"
2649            );
2650        }
2651    }
2652
2653    /// Test that resolve_config_for_file() finds the correct config in multi-root workspace
2654    #[tokio::test]
2655    async fn test_resolve_config_for_file_multi_root() {
2656        use std::fs;
2657        use tempfile::tempdir;
2658
2659        let temp_dir = tempdir().unwrap();
2660        let temp_path = temp_dir.path();
2661
2662        // Setup project A with line_length=60
2663        let project_a = temp_path.join("project_a");
2664        let project_a_docs = project_a.join("docs");
2665        fs::create_dir_all(&project_a_docs).unwrap();
2666
2667        let config_a = project_a.join(".rumdl.toml");
2668        fs::write(
2669            &config_a,
2670            r#"
2671[global]
2672
2673[MD013]
2674line_length = 60
2675"#,
2676        )
2677        .unwrap();
2678
2679        // Setup project B with line_length=120
2680        let project_b = temp_path.join("project_b");
2681        fs::create_dir(&project_b).unwrap();
2682
2683        let config_b = project_b.join(".rumdl.toml");
2684        fs::write(
2685            &config_b,
2686            r#"
2687[global]
2688
2689[MD013]
2690line_length = 120
2691"#,
2692        )
2693        .unwrap();
2694
2695        // Create LSP server and initialize with workspace roots
2696        let server = create_test_server();
2697
2698        // Set workspace roots
2699        {
2700            let mut roots = server.workspace_roots.write().await;
2701            roots.push(project_a.clone());
2702            roots.push(project_b.clone());
2703        }
2704
2705        // Test file in project A
2706        let file_a = project_a_docs.join("test.md");
2707        fs::write(&file_a, "# Test A\n").unwrap();
2708
2709        let config_for_a = server.resolve_config_for_file(&file_a).await;
2710        let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2711        assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2712
2713        // Test file in project B
2714        let file_b = project_b.join("test.md");
2715        fs::write(&file_b, "# Test B\n").unwrap();
2716
2717        let config_for_b = server.resolve_config_for_file(&file_b).await;
2718        let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2719        assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2720    }
2721
2722    /// Test that config resolution respects workspace root boundaries
2723    #[tokio::test]
2724    async fn test_config_resolution_respects_workspace_boundaries() {
2725        use std::fs;
2726        use tempfile::tempdir;
2727
2728        let temp_dir = tempdir().unwrap();
2729        let temp_path = temp_dir.path();
2730
2731        // Create parent config that should NOT be used
2732        let parent_config = temp_path.join(".rumdl.toml");
2733        fs::write(
2734            &parent_config,
2735            r#"
2736[global]
2737
2738[MD013]
2739line_length = 80
2740"#,
2741        )
2742        .unwrap();
2743
2744        // Create workspace root with its own config
2745        let workspace_root = temp_path.join("workspace");
2746        let workspace_subdir = workspace_root.join("subdir");
2747        fs::create_dir_all(&workspace_subdir).unwrap();
2748
2749        let workspace_config = workspace_root.join(".rumdl.toml");
2750        fs::write(
2751            &workspace_config,
2752            r#"
2753[global]
2754
2755[MD013]
2756line_length = 100
2757"#,
2758        )
2759        .unwrap();
2760
2761        let server = create_test_server();
2762
2763        // Register workspace_root as a workspace root
2764        {
2765            let mut roots = server.workspace_roots.write().await;
2766            roots.push(workspace_root.clone());
2767        }
2768
2769        // Test file deep in subdirectory
2770        let test_file = workspace_subdir.join("deep").join("test.md");
2771        fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2772        fs::write(&test_file, "# Test\n").unwrap();
2773
2774        let config = server.resolve_config_for_file(&test_file).await;
2775        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2776
2777        // Should find workspace_root/.rumdl.toml (100), NOT parent config (80)
2778        assert_eq!(
2779            line_length,
2780            Some(100),
2781            "Should find workspace config, not parent config outside workspace"
2782        );
2783    }
2784
2785    /// Test that config cache works (cache hit scenario)
2786    #[tokio::test]
2787    async fn test_config_cache_hit() {
2788        use std::fs;
2789        use tempfile::tempdir;
2790
2791        let temp_dir = tempdir().unwrap();
2792        let temp_path = temp_dir.path();
2793
2794        let project = temp_path.join("project");
2795        fs::create_dir(&project).unwrap();
2796
2797        let config_file = project.join(".rumdl.toml");
2798        fs::write(
2799            &config_file,
2800            r#"
2801[global]
2802
2803[MD013]
2804line_length = 75
2805"#,
2806        )
2807        .unwrap();
2808
2809        let server = create_test_server();
2810        {
2811            let mut roots = server.workspace_roots.write().await;
2812            roots.push(project.clone());
2813        }
2814
2815        let test_file = project.join("test.md");
2816        fs::write(&test_file, "# Test\n").unwrap();
2817
2818        // First call - cache miss
2819        let config1 = server.resolve_config_for_file(&test_file).await;
2820        let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2821        assert_eq!(line_length1, Some(75));
2822
2823        // Verify cache was populated
2824        {
2825            let cache = server.config_cache.read().await;
2826            let search_dir = test_file.parent().unwrap();
2827            assert!(
2828                cache.contains_key(search_dir),
2829                "Cache should be populated after first call"
2830            );
2831        }
2832
2833        // Second call - cache hit (should return same config without filesystem access)
2834        let config2 = server.resolve_config_for_file(&test_file).await;
2835        let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2836        assert_eq!(line_length2, Some(75));
2837    }
2838
2839    /// Test nested directory config search (file searches upward)
2840    #[tokio::test]
2841    async fn test_nested_directory_config_search() {
2842        use std::fs;
2843        use tempfile::tempdir;
2844
2845        let temp_dir = tempdir().unwrap();
2846        let temp_path = temp_dir.path();
2847
2848        let project = temp_path.join("project");
2849        fs::create_dir(&project).unwrap();
2850
2851        // Config at project root
2852        let config = project.join(".rumdl.toml");
2853        fs::write(
2854            &config,
2855            r#"
2856[global]
2857
2858[MD013]
2859line_length = 110
2860"#,
2861        )
2862        .unwrap();
2863
2864        // File deep in nested structure
2865        let deep_dir = project.join("src").join("docs").join("guides");
2866        fs::create_dir_all(&deep_dir).unwrap();
2867        let deep_file = deep_dir.join("test.md");
2868        fs::write(&deep_file, "# Test\n").unwrap();
2869
2870        let server = create_test_server();
2871        {
2872            let mut roots = server.workspace_roots.write().await;
2873            roots.push(project.clone());
2874        }
2875
2876        let resolved_config = server.resolve_config_for_file(&deep_file).await;
2877        let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2878
2879        assert_eq!(
2880            line_length,
2881            Some(110),
2882            "Should find config by searching upward from deep directory"
2883        );
2884    }
2885
2886    /// Test fallback to default config when no config file found
2887    #[tokio::test]
2888    async fn test_fallback_to_default_config() {
2889        use std::fs;
2890        use tempfile::tempdir;
2891
2892        let temp_dir = tempdir().unwrap();
2893        let temp_path = temp_dir.path();
2894
2895        let project = temp_path.join("project");
2896        fs::create_dir(&project).unwrap();
2897
2898        // No config file created!
2899
2900        let test_file = project.join("test.md");
2901        fs::write(&test_file, "# Test\n").unwrap();
2902
2903        let server = create_test_server();
2904        {
2905            let mut roots = server.workspace_roots.write().await;
2906            roots.push(project.clone());
2907        }
2908
2909        let config = server.resolve_config_for_file(&test_file).await;
2910
2911        // Default global line_length is 80
2912        assert_eq!(
2913            config.global.line_length.get(),
2914            80,
2915            "Should fall back to default config when no config file found"
2916        );
2917    }
2918
2919    /// Test config priority: closer config wins over parent config
2920    #[tokio::test]
2921    async fn test_config_priority_closer_wins() {
2922        use std::fs;
2923        use tempfile::tempdir;
2924
2925        let temp_dir = tempdir().unwrap();
2926        let temp_path = temp_dir.path();
2927
2928        let project = temp_path.join("project");
2929        fs::create_dir(&project).unwrap();
2930
2931        // Parent config
2932        let parent_config = project.join(".rumdl.toml");
2933        fs::write(
2934            &parent_config,
2935            r#"
2936[global]
2937
2938[MD013]
2939line_length = 100
2940"#,
2941        )
2942        .unwrap();
2943
2944        // Subdirectory with its own config (should override parent)
2945        let subdir = project.join("subdir");
2946        fs::create_dir(&subdir).unwrap();
2947
2948        let subdir_config = subdir.join(".rumdl.toml");
2949        fs::write(
2950            &subdir_config,
2951            r#"
2952[global]
2953
2954[MD013]
2955line_length = 50
2956"#,
2957        )
2958        .unwrap();
2959
2960        let server = create_test_server();
2961        {
2962            let mut roots = server.workspace_roots.write().await;
2963            roots.push(project.clone());
2964        }
2965
2966        // File in subdirectory
2967        let test_file = subdir.join("test.md");
2968        fs::write(&test_file, "# Test\n").unwrap();
2969
2970        let config = server.resolve_config_for_file(&test_file).await;
2971        let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2972
2973        assert_eq!(
2974            line_length,
2975            Some(50),
2976            "Closer config (subdir) should override parent config"
2977        );
2978    }
2979
2980    /// Test for issue #131: LSP should skip pyproject.toml without [tool.rumdl] section
2981    ///
2982    /// This test verifies the fix in resolve_config_for_file() at lines 574-585 that checks
2983    /// for [tool.rumdl] presence before loading pyproject.toml. The fix ensures LSP behavior
2984    /// matches CLI behavior.
2985    #[tokio::test]
2986    async fn test_issue_131_pyproject_without_rumdl_section() {
2987        use std::fs;
2988        use tempfile::tempdir;
2989
2990        // Create a parent temp dir that we control
2991        let parent_dir = tempdir().unwrap();
2992
2993        // Create a child subdirectory for the project
2994        let project_dir = parent_dir.path().join("project");
2995        fs::create_dir(&project_dir).unwrap();
2996
2997        // Create pyproject.toml WITHOUT [tool.rumdl] section in project dir
2998        fs::write(
2999            project_dir.join("pyproject.toml"),
3000            r#"
3001[project]
3002name = "test-project"
3003version = "0.1.0"
3004"#,
3005        )
3006        .unwrap();
3007
3008        // Create .rumdl.toml in PARENT that SHOULD be found
3009        // because pyproject.toml without [tool.rumdl] should be skipped
3010        fs::write(
3011            parent_dir.path().join(".rumdl.toml"),
3012            r#"
3013[global]
3014disable = ["MD013"]
3015"#,
3016        )
3017        .unwrap();
3018
3019        let test_file = project_dir.join("test.md");
3020        fs::write(&test_file, "# Test\n").unwrap();
3021
3022        let server = create_test_server();
3023
3024        // Set workspace root to parent so upward search doesn't stop at project_dir
3025        {
3026            let mut roots = server.workspace_roots.write().await;
3027            roots.push(parent_dir.path().to_path_buf());
3028        }
3029
3030        // Resolve config for file in project_dir
3031        let config = server.resolve_config_for_file(&test_file).await;
3032
3033        // CRITICAL TEST: The pyproject.toml in project_dir should be SKIPPED because it lacks
3034        // [tool.rumdl], and the search should continue upward to find parent .rumdl.toml
3035        assert!(
3036            config.global.disable.contains(&"MD013".to_string()),
3037            "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
3038             and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
3039        );
3040
3041        // Verify the config came from the parent directory, not project_dir
3042        // (we can check this by looking at the cache)
3043        let cache = server.config_cache.read().await;
3044        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
3045
3046        assert!(
3047            cache_entry.config_file.is_some(),
3048            "Should have found a config file (parent .rumdl.toml)"
3049        );
3050
3051        let found_config_path = cache_entry.config_file.as_ref().unwrap();
3052        assert!(
3053            found_config_path.ends_with(".rumdl.toml"),
3054            "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
3055        );
3056        assert!(
3057            found_config_path.parent().unwrap() == parent_dir.path(),
3058            "Should have loaded config from parent directory, not project_dir"
3059        );
3060    }
3061
3062    /// Test for issue #131: LSP should detect and load pyproject.toml WITH [tool.rumdl] section
3063    ///
3064    /// This test verifies that when pyproject.toml contains [tool.rumdl], the fix at lines 574-585
3065    /// correctly allows it through and loads the configuration.
3066    #[tokio::test]
3067    async fn test_issue_131_pyproject_with_rumdl_section() {
3068        use std::fs;
3069        use tempfile::tempdir;
3070
3071        // Create a parent temp dir that we control
3072        let parent_dir = tempdir().unwrap();
3073
3074        // Create a child subdirectory for the project
3075        let project_dir = parent_dir.path().join("project");
3076        fs::create_dir(&project_dir).unwrap();
3077
3078        // Create pyproject.toml WITH [tool.rumdl] section in project dir
3079        fs::write(
3080            project_dir.join("pyproject.toml"),
3081            r#"
3082[project]
3083name = "test-project"
3084
3085[tool.rumdl.global]
3086disable = ["MD033"]
3087"#,
3088        )
3089        .unwrap();
3090
3091        // Create a parent directory with different config that should NOT be used
3092        fs::write(
3093            parent_dir.path().join(".rumdl.toml"),
3094            r#"
3095[global]
3096disable = ["MD041"]
3097"#,
3098        )
3099        .unwrap();
3100
3101        let test_file = project_dir.join("test.md");
3102        fs::write(&test_file, "# Test\n").unwrap();
3103
3104        let server = create_test_server();
3105
3106        // Set workspace root to parent
3107        {
3108            let mut roots = server.workspace_roots.write().await;
3109            roots.push(parent_dir.path().to_path_buf());
3110        }
3111
3112        // Resolve config for file
3113        let config = server.resolve_config_for_file(&test_file).await;
3114
3115        // CRITICAL TEST: The pyproject.toml should be LOADED (not skipped) because it has [tool.rumdl]
3116        assert!(
3117            config.global.disable.contains(&"MD033".to_string()),
3118            "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
3119             Expected MD033 from project_dir pyproject.toml to be disabled."
3120        );
3121
3122        // Verify we did NOT get the parent config
3123        assert!(
3124            !config.global.disable.contains(&"MD041".to_string()),
3125            "Should use project_dir pyproject.toml, not parent .rumdl.toml"
3126        );
3127
3128        // Verify the config came from pyproject.toml specifically
3129        let cache = server.config_cache.read().await;
3130        let cache_entry = cache.get(&project_dir).expect("Config should be cached");
3131
3132        assert!(cache_entry.config_file.is_some(), "Should have found a config file");
3133
3134        let found_config_path = cache_entry.config_file.as_ref().unwrap();
3135        assert!(
3136            found_config_path.ends_with("pyproject.toml"),
3137            "Should have loaded pyproject.toml. Found: {found_config_path:?}"
3138        );
3139        assert!(
3140            found_config_path.parent().unwrap() == project_dir,
3141            "Should have loaded pyproject.toml from project_dir, not parent"
3142        );
3143    }
3144
3145    /// Test for issue #131: Verify pyproject.toml with only "tool.rumdl" (no brackets) is detected
3146    ///
3147    /// The fix checks for both "[tool.rumdl]" and "tool.rumdl" (line 576), ensuring it catches
3148    /// any valid TOML structure like [tool.rumdl.global] or [[tool.rumdl.something]].
3149    #[tokio::test]
3150    async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
3151        use std::fs;
3152        use tempfile::tempdir;
3153
3154        let temp_dir = tempdir().unwrap();
3155
3156        // Create pyproject.toml with [tool.rumdl.global] but not [tool.rumdl] directly
3157        fs::write(
3158            temp_dir.path().join("pyproject.toml"),
3159            r#"
3160[project]
3161name = "test-project"
3162
3163[tool.rumdl.global]
3164disable = ["MD022"]
3165"#,
3166        )
3167        .unwrap();
3168
3169        let test_file = temp_dir.path().join("test.md");
3170        fs::write(&test_file, "# Test\n").unwrap();
3171
3172        let server = create_test_server();
3173
3174        // Set workspace root
3175        {
3176            let mut roots = server.workspace_roots.write().await;
3177            roots.push(temp_dir.path().to_path_buf());
3178        }
3179
3180        // Resolve config for file
3181        let config = server.resolve_config_for_file(&test_file).await;
3182
3183        // Should detect "tool.rumdl" substring and load the config
3184        assert!(
3185            config.global.disable.contains(&"MD022".to_string()),
3186            "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
3187        );
3188
3189        // Verify it loaded pyproject.toml
3190        let cache = server.config_cache.read().await;
3191        let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
3192        assert!(
3193            cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
3194            "Should have loaded pyproject.toml"
3195        );
3196    }
3197
3198    /// Test for issue #182: Client pull diagnostics capability detection
3199    ///
3200    /// When a client supports pull diagnostics (textDocument/diagnostic), the server
3201    /// should skip pushing diagnostics via publishDiagnostics to avoid duplicates.
3202    #[tokio::test]
3203    async fn test_issue_182_pull_diagnostics_capability_default() {
3204        let server = create_test_server();
3205
3206        // By default, client_supports_pull_diagnostics should be false
3207        assert!(
3208            !*server.client_supports_pull_diagnostics.read().await,
3209            "Default should be false - push diagnostics by default"
3210        );
3211    }
3212
3213    /// Test that we can set the pull diagnostics flag
3214    #[tokio::test]
3215    async fn test_issue_182_pull_diagnostics_flag_update() {
3216        let server = create_test_server();
3217
3218        // Simulate detecting pull capability
3219        *server.client_supports_pull_diagnostics.write().await = true;
3220
3221        assert!(
3222            *server.client_supports_pull_diagnostics.read().await,
3223            "Flag should be settable to true"
3224        );
3225    }
3226
3227    /// Test issue #182: Verify capability detection logic matches Ruff's pattern
3228    ///
3229    /// The detection should check: params.capabilities.text_document.diagnostic.is_some()
3230    #[tokio::test]
3231    async fn test_issue_182_capability_detection_with_diagnostic_support() {
3232        use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
3233
3234        // Create client capabilities WITH diagnostic support
3235        let caps_with_diagnostic = ClientCapabilities {
3236            text_document: Some(TextDocumentClientCapabilities {
3237                diagnostic: Some(DiagnosticClientCapabilities {
3238                    dynamic_registration: Some(true),
3239                    related_document_support: Some(false),
3240                }),
3241                ..Default::default()
3242            }),
3243            ..Default::default()
3244        };
3245
3246        // Verify the detection logic (same as in initialize)
3247        let supports_pull = caps_with_diagnostic
3248            .text_document
3249            .as_ref()
3250            .and_then(|td| td.diagnostic.as_ref())
3251            .is_some();
3252
3253        assert!(supports_pull, "Should detect pull diagnostic support");
3254    }
3255
3256    /// Test issue #182: Verify capability detection when diagnostic is NOT supported
3257    #[tokio::test]
3258    async fn test_issue_182_capability_detection_without_diagnostic_support() {
3259        use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
3260
3261        // Create client capabilities WITHOUT diagnostic support
3262        let caps_without_diagnostic = ClientCapabilities {
3263            text_document: Some(TextDocumentClientCapabilities {
3264                diagnostic: None, // No diagnostic support
3265                ..Default::default()
3266            }),
3267            ..Default::default()
3268        };
3269
3270        // Verify the detection logic
3271        let supports_pull = caps_without_diagnostic
3272            .text_document
3273            .as_ref()
3274            .and_then(|td| td.diagnostic.as_ref())
3275            .is_some();
3276
3277        assert!(!supports_pull, "Should NOT detect pull diagnostic support");
3278    }
3279
3280    /// Test issue #182: Verify capability detection with empty text_document
3281    #[tokio::test]
3282    async fn test_issue_182_capability_detection_no_text_document() {
3283        use tower_lsp::lsp_types::ClientCapabilities;
3284
3285        // Create client capabilities with no text_document at all
3286        let caps_no_text_doc = ClientCapabilities {
3287            text_document: None,
3288            ..Default::default()
3289        };
3290
3291        // Verify the detection logic
3292        let supports_pull = caps_no_text_doc
3293            .text_document
3294            .as_ref()
3295            .and_then(|td| td.diagnostic.as_ref())
3296            .is_some();
3297
3298        assert!(
3299            !supports_pull,
3300            "Should NOT detect pull diagnostic support when text_document is None"
3301        );
3302    }
3303
3304    #[test]
3305    fn test_resource_limit_constants() {
3306        // Verify resource limit constants have expected values
3307        assert_eq!(MAX_RULE_LIST_SIZE, 100);
3308        assert_eq!(MAX_LINE_LENGTH, 10_000);
3309    }
3310
3311    #[test]
3312    fn test_is_valid_rule_name_edge_cases() {
3313        // Test malformed MDxxx patterns - not in alias map
3314        assert!(!is_valid_rule_name("MD/01")); // invalid character
3315        assert!(!is_valid_rule_name("MD:01")); // invalid character
3316        assert!(!is_valid_rule_name("ND001")); // 'N' instead of 'M'
3317        assert!(!is_valid_rule_name("ME001")); // 'E' instead of 'D'
3318
3319        // Test non-ASCII characters - not in alias map
3320        assert!(!is_valid_rule_name("MD0â‘ 1")); // Unicode digit
3321        assert!(!is_valid_rule_name("ï¼­D001")); // Fullwidth M
3322
3323        // Test special characters - not in alias map
3324        assert!(!is_valid_rule_name("MD\x00\x00\x00")); // null bytes
3325    }
3326
3327    /// Generic parity test: LSP config must produce identical results to TOML config.
3328    ///
3329    /// This test ensures that ANY config field works identically whether applied via:
3330    /// 1. LSP settings (JSON → apply_rule_config)
3331    /// 2. TOML file parsing (direct RuleConfig construction)
3332    ///
3333    /// When adding new config fields to RuleConfig, add them to TEST_CONFIGS below.
3334    /// The test will fail if LSP handling diverges from TOML handling.
3335    #[tokio::test]
3336    async fn test_lsp_toml_config_parity_generic() {
3337        use crate::config::RuleConfig;
3338        use crate::rule::Severity;
3339
3340        let server = create_test_server();
3341
3342        // Define test configurations covering all field types and combinations.
3343        // Each entry: (description, LSP JSON, expected TOML RuleConfig)
3344        // When adding new RuleConfig fields, add test cases here.
3345        let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3346            // Severity alone (the bug from issue #229)
3347            (
3348                "severity only - error",
3349                serde_json::json!({"severity": "error"}),
3350                RuleConfig {
3351                    severity: Some(Severity::Error),
3352                    values: std::collections::BTreeMap::new(),
3353                },
3354            ),
3355            (
3356                "severity only - warning",
3357                serde_json::json!({"severity": "warning"}),
3358                RuleConfig {
3359                    severity: Some(Severity::Warning),
3360                    values: std::collections::BTreeMap::new(),
3361                },
3362            ),
3363            (
3364                "severity only - info",
3365                serde_json::json!({"severity": "info"}),
3366                RuleConfig {
3367                    severity: Some(Severity::Info),
3368                    values: std::collections::BTreeMap::new(),
3369                },
3370            ),
3371            // Value types: integer
3372            (
3373                "integer value",
3374                serde_json::json!({"lineLength": 120}),
3375                RuleConfig {
3376                    severity: None,
3377                    values: [("line_length".to_string(), toml::Value::Integer(120))]
3378                        .into_iter()
3379                        .collect(),
3380                },
3381            ),
3382            // Value types: boolean
3383            (
3384                "boolean value",
3385                serde_json::json!({"enabled": true}),
3386                RuleConfig {
3387                    severity: None,
3388                    values: [("enabled".to_string(), toml::Value::Boolean(true))]
3389                        .into_iter()
3390                        .collect(),
3391                },
3392            ),
3393            // Value types: string
3394            (
3395                "string value",
3396                serde_json::json!({"style": "consistent"}),
3397                RuleConfig {
3398                    severity: None,
3399                    values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3400                        .into_iter()
3401                        .collect(),
3402                },
3403            ),
3404            // Value types: array
3405            (
3406                "array value",
3407                serde_json::json!({"allowedElements": ["div", "span"]}),
3408                RuleConfig {
3409                    severity: None,
3410                    values: [(
3411                        "allowed_elements".to_string(),
3412                        toml::Value::Array(vec![
3413                            toml::Value::String("div".to_string()),
3414                            toml::Value::String("span".to_string()),
3415                        ]),
3416                    )]
3417                    .into_iter()
3418                    .collect(),
3419                },
3420            ),
3421            // Mixed: severity + values (critical combination)
3422            (
3423                "severity + integer",
3424                serde_json::json!({"severity": "info", "lineLength": 80}),
3425                RuleConfig {
3426                    severity: Some(Severity::Info),
3427                    values: [("line_length".to_string(), toml::Value::Integer(80))]
3428                        .into_iter()
3429                        .collect(),
3430                },
3431            ),
3432            (
3433                "severity + multiple values",
3434                serde_json::json!({
3435                    "severity": "warning",
3436                    "lineLength": 100,
3437                    "strict": false,
3438                    "style": "atx"
3439                }),
3440                RuleConfig {
3441                    severity: Some(Severity::Warning),
3442                    values: [
3443                        ("line_length".to_string(), toml::Value::Integer(100)),
3444                        ("strict".to_string(), toml::Value::Boolean(false)),
3445                        ("style".to_string(), toml::Value::String("atx".to_string())),
3446                    ]
3447                    .into_iter()
3448                    .collect(),
3449                },
3450            ),
3451            // camelCase to snake_case conversion
3452            (
3453                "camelCase conversion",
3454                serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3455                RuleConfig {
3456                    severity: None,
3457                    values: [
3458                        ("code_blocks".to_string(), toml::Value::Boolean(true)),
3459                        ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3460                    ]
3461                    .into_iter()
3462                    .collect(),
3463                },
3464            ),
3465        ];
3466
3467        for (description, lsp_json, expected_toml_config) in test_configs {
3468            let mut lsp_config = crate::config::Config::default();
3469            server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3470
3471            let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3472
3473            // Compare severity
3474            assert_eq!(
3475                lsp_rule.severity, expected_toml_config.severity,
3476                "Parity failure [{description}]: severity mismatch. \
3477                 LSP={:?}, TOML={:?}",
3478                lsp_rule.severity, expected_toml_config.severity
3479            );
3480
3481            // Compare values
3482            assert_eq!(
3483                lsp_rule.values, expected_toml_config.values,
3484                "Parity failure [{description}]: values mismatch. \
3485                 LSP={:?}, TOML={:?}",
3486                lsp_rule.values, expected_toml_config.values
3487            );
3488        }
3489    }
3490
3491    /// Test apply_rule_config_if_absent preserves all existing config
3492    #[tokio::test]
3493    async fn test_lsp_config_if_absent_preserves_existing() {
3494        use crate::config::RuleConfig;
3495        use crate::rule::Severity;
3496
3497        let server = create_test_server();
3498
3499        // Pre-existing file config with severity AND values
3500        let mut config = crate::config::Config::default();
3501        config.rules.insert(
3502            "MD013".to_string(),
3503            RuleConfig {
3504                severity: Some(Severity::Error),
3505                values: [("line_length".to_string(), toml::Value::Integer(80))]
3506                    .into_iter()
3507                    .collect(),
3508            },
3509        );
3510
3511        // LSP tries to override with different values
3512        let lsp_json = serde_json::json!({
3513            "severity": "info",
3514            "lineLength": 120
3515        });
3516        server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3517
3518        let rule = config.rules.get("MD013").expect("Rule should exist");
3519
3520        // Original severity preserved
3521        assert_eq!(
3522            rule.severity,
3523            Some(Severity::Error),
3524            "Existing severity should not be overwritten"
3525        );
3526
3527        // Original values preserved
3528        assert_eq!(
3529            rule.values.get("line_length"),
3530            Some(&toml::Value::Integer(80)),
3531            "Existing values should not be overwritten"
3532        );
3533    }
3534
3535    // Tests for apply_formatting_options (issue #265)
3536
3537    #[test]
3538    fn test_apply_formatting_options_insert_final_newline() {
3539        let options = FormattingOptions {
3540            tab_size: 4,
3541            insert_spaces: true,
3542            properties: HashMap::new(),
3543            trim_trailing_whitespace: None,
3544            insert_final_newline: Some(true),
3545            trim_final_newlines: None,
3546        };
3547
3548        // Content without final newline should get one added
3549        let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3550        assert_eq!(result, "hello\n");
3551
3552        // Content with final newline should stay the same
3553        let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3554        assert_eq!(result, "hello\n");
3555    }
3556
3557    #[test]
3558    fn test_apply_formatting_options_trim_final_newlines() {
3559        let options = FormattingOptions {
3560            tab_size: 4,
3561            insert_spaces: true,
3562            properties: HashMap::new(),
3563            trim_trailing_whitespace: None,
3564            insert_final_newline: None,
3565            trim_final_newlines: Some(true),
3566        };
3567
3568        // Multiple trailing newlines should be removed
3569        let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3570        assert_eq!(result, "hello");
3571
3572        // Single trailing newline should also be removed (trim_final_newlines removes ALL)
3573        let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3574        assert_eq!(result, "hello");
3575    }
3576
3577    #[test]
3578    fn test_apply_formatting_options_trim_and_insert_combined() {
3579        // This is the common case: trim extra newlines, then ensure exactly one
3580        let options = FormattingOptions {
3581            tab_size: 4,
3582            insert_spaces: true,
3583            properties: HashMap::new(),
3584            trim_trailing_whitespace: None,
3585            insert_final_newline: Some(true),
3586            trim_final_newlines: Some(true),
3587        };
3588
3589        // Multiple trailing newlines → exactly one
3590        let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3591        assert_eq!(result, "hello\n");
3592
3593        // No trailing newline → add one
3594        let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3595        assert_eq!(result, "hello\n");
3596
3597        // Already has exactly one → unchanged
3598        let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3599        assert_eq!(result, "hello\n");
3600    }
3601
3602    #[test]
3603    fn test_apply_formatting_options_trim_trailing_whitespace() {
3604        let options = FormattingOptions {
3605            tab_size: 4,
3606            insert_spaces: true,
3607            properties: HashMap::new(),
3608            trim_trailing_whitespace: Some(true),
3609            insert_final_newline: Some(true),
3610            trim_final_newlines: None,
3611        };
3612
3613        // Trailing whitespace on lines should be removed
3614        let result = RumdlLanguageServer::apply_formatting_options("hello  \nworld\t\n".to_string(), &options);
3615        assert_eq!(result, "hello\nworld\n");
3616    }
3617
3618    #[test]
3619    fn test_apply_formatting_options_issue_265_scenario() {
3620        // Issue #265: MD012 at end of file doesn't work with LSP formatting
3621        // The editor (nvim) may strip trailing newlines from buffer before sending to LSP
3622        // With proper FormattingOptions handling, we should still get the right result
3623
3624        let options = FormattingOptions {
3625            tab_size: 4,
3626            insert_spaces: true,
3627            properties: HashMap::new(),
3628            trim_trailing_whitespace: None,
3629            insert_final_newline: Some(true),
3630            trim_final_newlines: Some(true),
3631        };
3632
3633        // Scenario 1: Editor sends content with multiple trailing newlines
3634        let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
3635        assert_eq!(
3636            result, "hello foobar hello.\n",
3637            "Should have exactly one trailing newline"
3638        );
3639
3640        // Scenario 2: Editor sends content with trailing newlines stripped
3641        let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
3642        assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
3643
3644        // Scenario 3: Content is already correct
3645        let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
3646        assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
3647    }
3648
3649    #[test]
3650    fn test_apply_formatting_options_no_options() {
3651        // When all options are None/false, content should be unchanged
3652        let options = FormattingOptions {
3653            tab_size: 4,
3654            insert_spaces: true,
3655            properties: HashMap::new(),
3656            trim_trailing_whitespace: None,
3657            insert_final_newline: None,
3658            trim_final_newlines: None,
3659        };
3660
3661        let content = "hello  \nworld\n\n\n";
3662        let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3663        assert_eq!(result, content, "Content should be unchanged when no options set");
3664    }
3665
3666    #[test]
3667    fn test_apply_formatting_options_empty_content() {
3668        let options = FormattingOptions {
3669            tab_size: 4,
3670            insert_spaces: true,
3671            properties: HashMap::new(),
3672            trim_trailing_whitespace: Some(true),
3673            insert_final_newline: Some(true),
3674            trim_final_newlines: Some(true),
3675        };
3676
3677        // Empty content should stay empty (no newline added to truly empty documents)
3678        let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
3679        assert_eq!(result, "");
3680
3681        // Just newlines should become single newline (content existed, so gets final newline)
3682        let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
3683        assert_eq!(result, "\n");
3684    }
3685
3686    #[test]
3687    fn test_apply_formatting_options_multiline_content() {
3688        let options = FormattingOptions {
3689            tab_size: 4,
3690            insert_spaces: true,
3691            properties: HashMap::new(),
3692            trim_trailing_whitespace: Some(true),
3693            insert_final_newline: Some(true),
3694            trim_final_newlines: Some(true),
3695        };
3696
3697        let content = "# Heading  \n\nParagraph  \n- List item  \n\n\n";
3698        let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3699        assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
3700    }
3701
3702    #[test]
3703    fn test_code_action_kind_filtering() {
3704        // Test the hierarchical code action kind matching used in code_action handler
3705        // LSP spec: source.fixAll.rumdl should match requests for source.fixAll
3706
3707        let matches = |action_kind: &str, requested: &str| -> bool { action_kind.starts_with(requested) };
3708
3709        // source.fixAll.rumdl matches source.fixAll (parent kind)
3710        assert!(matches("source.fixAll.rumdl", "source.fixAll"));
3711
3712        // source.fixAll.rumdl matches source.fixAll.rumdl (exact match)
3713        assert!(matches("source.fixAll.rumdl", "source.fixAll.rumdl"));
3714
3715        // source.fixAll.rumdl matches source (grandparent kind)
3716        assert!(matches("source.fixAll.rumdl", "source"));
3717
3718        // quickfix matches quickfix (exact match)
3719        assert!(matches("quickfix", "quickfix"));
3720
3721        // source.fixAll.rumdl does NOT match quickfix
3722        assert!(!matches("source.fixAll.rumdl", "quickfix"));
3723
3724        // quickfix does NOT match source.fixAll
3725        assert!(!matches("quickfix", "source.fixAll"));
3726
3727        // source.fixAll does NOT match source.fixAll.rumdl (child is more specific)
3728        assert!(!matches("source.fixAll", "source.fixAll.rumdl"));
3729    }
3730
3731    #[test]
3732    fn test_code_action_kind_filter_with_empty_array() {
3733        // LSP spec: "If provided with no kinds, all supported kinds are returned"
3734        // An empty array should be treated the same as None (return all actions)
3735
3736        let filter_actions = |kinds: Option<Vec<&str>>| -> bool {
3737            // Simulates our filtering logic
3738            if let Some(ref k) = kinds
3739                && !k.is_empty()
3740            {
3741                // Would filter
3742                false
3743            } else {
3744                // Return all
3745                true
3746            }
3747        };
3748
3749        // None returns all actions
3750        assert!(filter_actions(None));
3751
3752        // Empty array returns all actions (per LSP spec)
3753        assert!(filter_actions(Some(vec![])));
3754
3755        // Non-empty array triggers filtering
3756        assert!(!filter_actions(Some(vec!["source.fixAll"])));
3757    }
3758
3759    #[test]
3760    fn test_code_action_kind_constants() {
3761        // Verify our custom code action kind string matches LSP conventions
3762        let fix_all_rumdl = CodeActionKind::new("source.fixAll.rumdl");
3763        assert_eq!(fix_all_rumdl.as_str(), "source.fixAll.rumdl");
3764
3765        // Verify it's a sub-kind of SOURCE_FIX_ALL
3766        assert!(
3767            fix_all_rumdl
3768                .as_str()
3769                .starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str())
3770        );
3771    }
3772
3773    // ==================== Completion Tests ====================
3774
3775    #[test]
3776    fn test_detect_code_fence_language_position_basic() {
3777        // Basic case: cursor right after ```
3778        let text = "```\ncode\n```";
3779        let pos = Position { line: 0, character: 3 };
3780        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3781        assert!(result.is_some());
3782        let (start_col, current_text) = result.unwrap();
3783        assert_eq!(start_col, 3);
3784        assert_eq!(current_text, "");
3785    }
3786
3787    #[test]
3788    fn test_detect_code_fence_language_position_partial_lang() {
3789        // Cursor in the middle of typing a language
3790        let text = "```py\ncode\n```";
3791        let pos = Position { line: 0, character: 5 };
3792        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3793        assert!(result.is_some());
3794        let (start_col, current_text) = result.unwrap();
3795        assert_eq!(start_col, 3);
3796        assert_eq!(current_text, "py");
3797    }
3798
3799    #[test]
3800    fn test_detect_code_fence_language_position_full_lang() {
3801        // Cursor at end of language tag
3802        let text = "```python\ncode\n```";
3803        let pos = Position { line: 0, character: 9 };
3804        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3805        assert!(result.is_some());
3806        let (start_col, current_text) = result.unwrap();
3807        assert_eq!(start_col, 3);
3808        assert_eq!(current_text, "python");
3809    }
3810
3811    #[test]
3812    fn test_detect_code_fence_language_position_tilde_fence() {
3813        // Using ~~~ instead of ```
3814        let text = "~~~rust\ncode\n~~~";
3815        let pos = Position { line: 0, character: 7 };
3816        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3817        assert!(result.is_some());
3818        let (start_col, current_text) = result.unwrap();
3819        assert_eq!(start_col, 3);
3820        assert_eq!(current_text, "rust");
3821    }
3822
3823    #[test]
3824    fn test_detect_code_fence_language_position_indented() {
3825        // Indented code fence
3826        let text = "  ```js\ncode\n  ```";
3827        let pos = Position { line: 0, character: 7 };
3828        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3829        assert!(result.is_some());
3830        let (start_col, current_text) = result.unwrap();
3831        assert_eq!(start_col, 5); // 2 spaces + 3 backticks
3832        assert_eq!(current_text, "js");
3833    }
3834
3835    #[test]
3836    fn test_detect_code_fence_language_position_not_fence_line() {
3837        // Not on a fence line (inside code block content)
3838        let text = "```python\ncode\n```";
3839        let pos = Position { line: 1, character: 2 };
3840        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3841        assert!(result.is_none());
3842    }
3843
3844    #[test]
3845    fn test_detect_code_fence_language_position_closing_fence() {
3846        // On closing fence - should NOT trigger completion
3847        let text = "```python\ncode\n```";
3848        let pos = Position { line: 2, character: 3 };
3849        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3850        // Closing fence should return None (no completion on closing fences)
3851        assert!(result.is_none(), "Should not offer completion on closing fence");
3852    }
3853
3854    #[test]
3855    fn test_detect_code_fence_language_position_extended_fence() {
3856        // Extended fence with 4 backticks
3857        let text = "````python\ncode\n````";
3858        let pos = Position { line: 0, character: 10 };
3859        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3860        assert!(result.is_some());
3861        let (start_col, current_text) = result.unwrap();
3862        assert_eq!(start_col, 4); // 4 backticks
3863        assert_eq!(current_text, "python");
3864    }
3865
3866    #[test]
3867    fn test_detect_code_fence_language_position_extended_fence_5_backticks() {
3868        // Extended fence with 5 backticks
3869        let text = "`````js\ncode\n`````";
3870        let pos = Position { line: 0, character: 7 };
3871        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3872        assert!(result.is_some());
3873        let (start_col, current_text) = result.unwrap();
3874        assert_eq!(start_col, 5);
3875        assert_eq!(current_text, "js");
3876    }
3877
3878    #[test]
3879    fn test_detect_code_fence_language_position_nested_code_blocks() {
3880        // Nested code block (documenting markdown in markdown)
3881        // Outer: 4 backticks, Inner: 3 backticks
3882        let text = "````markdown\n```python\ncode\n```\n````";
3883
3884        // Opening fence of outer block
3885        let pos = Position { line: 0, character: 12 };
3886        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3887        assert!(result.is_some());
3888        let (_, current_text) = result.unwrap();
3889        assert_eq!(current_text, "markdown");
3890
3891        // Inner opening fence - should be treated as content (we're inside outer block)
3892        // Note: This is actually content of the outer block, not a real code fence
3893        // The detection is line-based and doesn't have full context, so it will detect it
3894        // This is acceptable behavior - editors typically don't complete inside code blocks anyway
3895    }
3896
3897    #[test]
3898    fn test_detect_code_fence_language_position_extended_closing_fence() {
3899        // Extended closing fence should not trigger completion
3900        let text = "````python\ncode here\n````";
3901        let pos = Position { line: 2, character: 4 };
3902        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3903        assert!(
3904            result.is_none(),
3905            "Should not offer completion on extended closing fence"
3906        );
3907    }
3908
3909    #[test]
3910    fn test_detect_code_fence_language_position_cursor_before_fence() {
3911        // Cursor before the fence characters
3912        let text = "```python\ncode\n```";
3913        let pos = Position { line: 0, character: 2 };
3914        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3915        assert!(result.is_none());
3916    }
3917
3918    #[test]
3919    fn test_detect_code_fence_language_position_with_info_string() {
3920        // Info string with space (should not complete after space)
3921        let text = "```python filename.py\ncode\n```";
3922        let pos = Position { line: 0, character: 15 };
3923        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3924        // Should return None because cursor is after a space
3925        assert!(result.is_none());
3926    }
3927
3928    #[test]
3929    fn test_detect_code_fence_language_position_regular_text() {
3930        // Regular markdown text (not a code fence)
3931        let text = "# Heading\n\nSome text.";
3932        let pos = Position { line: 0, character: 5 };
3933        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3934        assert!(result.is_none());
3935    }
3936
3937    #[test]
3938    fn test_detect_code_fence_language_position_inline_code() {
3939        // Inline code (not a fenced block)
3940        let text = "Use `code` here.";
3941        let pos = Position { line: 0, character: 5 };
3942        let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3943        assert!(result.is_none());
3944    }
3945
3946    #[tokio::test]
3947    async fn test_completion_provides_language_items() {
3948        use std::fs;
3949        use tempfile::tempdir;
3950
3951        let temp_dir = tempdir().unwrap();
3952        let test_file = temp_dir.path().join("test.md");
3953        fs::write(&test_file, "```py\ncode\n```").unwrap();
3954
3955        let server = create_test_server();
3956        let uri = Url::from_file_path(&test_file).unwrap();
3957
3958        // Open the document
3959        let content = "```py\ncode\n```".to_string();
3960        server.documents.write().await.insert(
3961            uri.clone(),
3962            DocumentEntry {
3963                content: content.clone(),
3964                version: Some(1),
3965                from_disk: false,
3966            },
3967        );
3968
3969        // Get completions at position after ```
3970        let items = server
3971            .get_language_completions(&uri, "py", 3, Position { line: 0, character: 5 })
3972            .await;
3973
3974        // Should have python-related items
3975        assert!(!items.is_empty(), "Should return completion items");
3976
3977        // Check that python is in the results
3978        let has_python = items.iter().any(|item| item.label.to_lowercase() == "python");
3979        assert!(has_python, "Should include 'python' as a completion item");
3980    }
3981
3982    #[tokio::test]
3983    async fn test_completion_filters_by_prefix() {
3984        let temp_dir = tempfile::tempdir().unwrap();
3985        let test_file = temp_dir.path().join("test.md");
3986        std::fs::write(&test_file, "```ru\ncode\n```").unwrap();
3987
3988        let server = create_test_server();
3989        let uri = Url::from_file_path(&test_file).unwrap();
3990
3991        // Get completions filtered by "ru"
3992        let items = server
3993            .get_language_completions(&uri, "ru", 3, Position { line: 0, character: 5 })
3994            .await;
3995
3996        // All items should start with "ru"
3997        for item in &items {
3998            assert!(
3999                item.label.to_lowercase().starts_with("ru"),
4000                "Completion '{}' should start with 'ru'",
4001                item.label
4002            );
4003        }
4004
4005        // Should include rust and ruby
4006        let has_rust = items.iter().any(|item| item.label.to_lowercase() == "rust");
4007        let has_ruby = items.iter().any(|item| item.label.to_lowercase() == "ruby");
4008        assert!(has_rust, "Should include 'rust'");
4009        assert!(has_ruby, "Should include 'ruby'");
4010    }
4011
4012    #[tokio::test]
4013    async fn test_completion_empty_prefix_returns_all() {
4014        let temp_dir = tempfile::tempdir().unwrap();
4015        let test_file = temp_dir.path().join("test.md");
4016        std::fs::write(&test_file, "```\ncode\n```").unwrap();
4017
4018        let server = create_test_server();
4019        let uri = Url::from_file_path(&test_file).unwrap();
4020
4021        // Get completions with empty prefix
4022        let items = server
4023            .get_language_completions(&uri, "", 3, Position { line: 0, character: 3 })
4024            .await;
4025
4026        // Should have many items (up to the limit of 100)
4027        assert!(items.len() >= 10, "Should return multiple language options");
4028        assert!(items.len() <= 100, "Should be limited to 100 items");
4029    }
4030
4031    #[tokio::test]
4032    async fn test_completion_respects_md040_allowed_languages() {
4033        use std::fs;
4034
4035        let temp_dir = tempfile::tempdir().unwrap();
4036        let test_file = temp_dir.path().join("test.md");
4037        fs::write(&test_file, "```\ncode\n```").unwrap();
4038
4039        // Create config with allowed_languages
4040        let config_file = temp_dir.path().join(".rumdl.toml");
4041        fs::write(
4042            &config_file,
4043            r#"
4044[MD040]
4045allowed-languages = ["Python", "Rust", "Go"]
4046"#,
4047        )
4048        .unwrap();
4049
4050        let server = create_test_server();
4051
4052        // Set workspace root so config is discovered
4053        {
4054            let mut roots = server.workspace_roots.write().await;
4055            roots.push(temp_dir.path().to_path_buf());
4056        }
4057
4058        let uri = Url::from_file_path(&test_file).unwrap();
4059
4060        // Get completions
4061        let items = server
4062            .get_language_completions(&uri, "", 3, Position { line: 0, character: 3 })
4063            .await;
4064
4065        // Should only have items for Python, Rust, Go and their aliases
4066        for item in &items {
4067            let label_lower = item.label.to_lowercase();
4068            let detail = item.detail.as_ref().map(|d| d.to_lowercase()).unwrap_or_default();
4069
4070            // Check that the canonical language (in detail) is one of the allowed ones
4071            let is_allowed = detail.contains("python") || detail.contains("rust") || detail.contains("go");
4072            assert!(
4073                is_allowed,
4074                "Completion '{label_lower}' (detail: '{detail}') should be for Python, Rust, or Go"
4075            );
4076        }
4077    }
4078
4079    #[tokio::test]
4080    async fn test_completion_respects_md040_disallowed_languages() {
4081        use std::fs;
4082
4083        let temp_dir = tempfile::tempdir().unwrap();
4084        let test_file = temp_dir.path().join("test.md");
4085        fs::write(&test_file, "```py\ncode\n```").unwrap();
4086
4087        // Create config with disallowed_languages
4088        let config_file = temp_dir.path().join(".rumdl.toml");
4089        fs::write(
4090            &config_file,
4091            r#"
4092[MD040]
4093disallowed-languages = ["Python"]
4094"#,
4095        )
4096        .unwrap();
4097
4098        let server = create_test_server();
4099
4100        // Set workspace root so config is discovered
4101        {
4102            let mut roots = server.workspace_roots.write().await;
4103            roots.push(temp_dir.path().to_path_buf());
4104        }
4105
4106        let uri = Url::from_file_path(&test_file).unwrap();
4107
4108        // Get completions filtered by "py"
4109        let items = server
4110            .get_language_completions(&uri, "py", 3, Position { line: 0, character: 5 })
4111            .await;
4112
4113        // Should NOT include Python or py
4114        for item in &items {
4115            let detail = item.detail.as_ref().map(|d| d.to_lowercase()).unwrap_or_default();
4116            assert!(
4117                !detail.contains("python"),
4118                "Completion '{}' should not include Python (disallowed)",
4119                item.label
4120            );
4121        }
4122    }
4123
4124    #[test]
4125    fn test_is_closing_fence_basic() {
4126        // Opening fence only - the next fence IS a closing fence
4127        // (markdown spec: opening fence creates a code block that needs closing)
4128        let lines = vec!["```python"];
4129        assert!(
4130            RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4131            "After opening fence, next fence is closing"
4132        );
4133    }
4134
4135    #[test]
4136    fn test_is_closing_fence_with_content() {
4137        // Opening fence with content - next fence would be closing
4138        let lines = vec!["```python", "some code"];
4139        assert!(
4140            RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4141            "After opening fence with content, next fence is closing"
4142        );
4143    }
4144
4145    #[test]
4146    fn test_is_closing_fence_no_prior_fence() {
4147        // No prior fence - next fence is opening
4148        let lines: Vec<&str> = vec!["# Hello", "Some text"];
4149        assert!(
4150            !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4151            "With no prior fence, next fence is opening"
4152        );
4153    }
4154
4155    #[test]
4156    fn test_is_closing_fence_already_closed() {
4157        // Closed code block - next fence would be opening
4158        let lines = vec!["```python", "some code", "```"];
4159        assert!(
4160            !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4161            "After closed code block, next fence is opening"
4162        );
4163    }
4164
4165    #[test]
4166    fn test_is_closing_fence_extended() {
4167        // Extended fence - needs matching or longer fence to close
4168        let lines = vec!["````python", "some code"];
4169        // 3 backticks won't close 4-backtick fence
4170        assert!(
4171            !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4172            "3 backticks cannot close 4-backtick fence"
4173        );
4174        // 4 backticks will close
4175        assert!(
4176            RumdlLanguageServer::is_closing_fence(&lines, '`', 4),
4177            "4 backticks can close 4-backtick fence"
4178        );
4179        // 5 backticks will also close (>= rule)
4180        assert!(
4181            RumdlLanguageServer::is_closing_fence(&lines, '`', 5),
4182            "5 backticks can close 4-backtick fence"
4183        );
4184    }
4185
4186    #[test]
4187    fn test_is_closing_fence_mixed_chars() {
4188        // Tilde fence cannot be closed by backtick fence
4189        let lines = vec!["~~~python", "some code"];
4190        assert!(
4191            !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4192            "Backtick fence cannot close tilde fence"
4193        );
4194        assert!(
4195            RumdlLanguageServer::is_closing_fence(&lines, '~', 3),
4196            "Tilde fence can close tilde fence"
4197        );
4198    }
4199
4200    #[tokio::test]
4201    async fn test_completion_method_integration() {
4202        use std::fs;
4203
4204        let temp_dir = tempfile::tempdir().unwrap();
4205        let test_file = temp_dir.path().join("test.md");
4206        let content = "# Hello\n\n```py\nprint('hi')\n```";
4207        fs::write(&test_file, content).unwrap();
4208
4209        let server = create_test_server();
4210        let uri = Url::from_file_path(&test_file).unwrap();
4211
4212        // Open the document
4213        server.documents.write().await.insert(
4214            uri.clone(),
4215            DocumentEntry {
4216                content: content.to_string(),
4217                version: Some(1),
4218                from_disk: false,
4219            },
4220        );
4221
4222        // Call completion method directly
4223        let params = CompletionParams {
4224            text_document_position: TextDocumentPositionParams {
4225                text_document: TextDocumentIdentifier { uri: uri.clone() },
4226                position: Position { line: 2, character: 5 }, // After ```py
4227            },
4228            work_done_progress_params: WorkDoneProgressParams::default(),
4229            partial_result_params: PartialResultParams::default(),
4230            context: None,
4231        };
4232
4233        let result = server.completion(params).await.unwrap();
4234        assert!(result.is_some(), "Completion should return items");
4235
4236        if let Some(CompletionResponse::Array(items)) = result {
4237            assert!(!items.is_empty(), "Should have completion items");
4238            // Check python is in the results
4239            let has_python = items.iter().any(|i| i.label.to_lowercase() == "python");
4240            assert!(has_python, "Should include python as completion");
4241        } else {
4242            panic!("Expected CompletionResponse::Array");
4243        }
4244    }
4245
4246    #[tokio::test]
4247    async fn test_completion_not_triggered_on_closing_fence() {
4248        use std::fs;
4249
4250        let temp_dir = tempfile::tempdir().unwrap();
4251        let test_file = temp_dir.path().join("test.md");
4252        let content = "```python\nprint('hi')\n```";
4253        fs::write(&test_file, content).unwrap();
4254
4255        let server = create_test_server();
4256        let uri = Url::from_file_path(&test_file).unwrap();
4257
4258        // Open the document
4259        server.documents.write().await.insert(
4260            uri.clone(),
4261            DocumentEntry {
4262                content: content.to_string(),
4263                version: Some(1),
4264                from_disk: false,
4265            },
4266        );
4267
4268        // Call completion method on closing fence
4269        let params = CompletionParams {
4270            text_document_position: TextDocumentPositionParams {
4271                text_document: TextDocumentIdentifier { uri: uri.clone() },
4272                position: Position { line: 2, character: 3 }, // On closing ```
4273            },
4274            work_done_progress_params: WorkDoneProgressParams::default(),
4275            partial_result_params: PartialResultParams::default(),
4276            context: None,
4277        };
4278
4279        let result = server.completion(params).await.unwrap();
4280        assert!(result.is_none(), "Should NOT offer completion on closing fence");
4281    }
4282
4283    #[tokio::test]
4284    async fn test_completion_graceful_when_document_not_found() {
4285        let server = create_test_server();
4286
4287        // Use a URI for a document that doesn't exist and isn't opened
4288        let uri = Url::parse("file:///nonexistent/path/test.md").unwrap();
4289
4290        let params = CompletionParams {
4291            text_document_position: TextDocumentPositionParams {
4292                text_document: TextDocumentIdentifier { uri },
4293                position: Position { line: 0, character: 3 },
4294            },
4295            work_done_progress_params: WorkDoneProgressParams::default(),
4296            partial_result_params: PartialResultParams::default(),
4297            context: None,
4298        };
4299
4300        // Should return Ok(None), not an error
4301        let result = server.completion(params).await;
4302        assert!(result.is_ok(), "Completion should not error for missing document");
4303        assert!(result.unwrap().is_none(), "Should return None for missing document");
4304    }
4305}