1use 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;
18use crate::lint;
19use crate::lsp::index_worker::IndexWorker;
20use crate::lsp::types::{
21 ConfigurationPreference, IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig, warning_to_code_actions,
22 warning_to_diagnostic,
23};
24use crate::rule::{FixCapability, Rule};
25use crate::rules;
26use crate::workspace_index::WorkspaceIndex;
27
28const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
30
31const MAX_RULE_LIST_SIZE: usize = 100;
33
34const MAX_LINE_LENGTH: usize = 10_000;
36
37#[inline]
39fn is_markdown_extension(ext: &str) -> bool {
40 MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
41}
42
43#[derive(Clone, Debug, PartialEq)]
45struct DocumentEntry {
46 content: String,
48 version: Option<i32>,
50 from_disk: bool,
52}
53
54#[derive(Clone, Debug)]
56pub(crate) struct ConfigCacheEntry {
57 pub(crate) config: Config,
59 pub(crate) config_file: Option<PathBuf>,
61 pub(crate) from_global_fallback: bool,
63}
64
65#[derive(Clone)]
75pub struct RumdlLanguageServer {
76 client: Client,
77 config: Arc<RwLock<RumdlLspConfig>>,
79 #[cfg_attr(test, allow(dead_code))]
81 pub(crate) rumdl_config: Arc<RwLock<Config>>,
82 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
84 #[cfg_attr(test, allow(dead_code))]
86 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
87 #[cfg_attr(test, allow(dead_code))]
90 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
91 workspace_index: Arc<RwLock<WorkspaceIndex>>,
93 index_state: Arc<RwLock<IndexState>>,
95 update_tx: mpsc::Sender<IndexUpdate>,
97 client_supports_pull_diagnostics: Arc<RwLock<bool>>,
100}
101
102impl RumdlLanguageServer {
103 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
104 let mut initial_config = RumdlLspConfig::default();
106 if let Some(path) = cli_config_path {
107 initial_config.config_path = Some(path.to_string());
108 }
109
110 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
112 let index_state = Arc::new(RwLock::new(IndexState::default()));
113 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
114
115 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
117 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
118
119 let worker = IndexWorker::new(
121 update_rx,
122 workspace_index.clone(),
123 index_state.clone(),
124 client.clone(),
125 workspace_roots.clone(),
126 relint_tx,
127 );
128 tokio::spawn(worker.run());
129
130 Self {
131 client,
132 config: Arc::new(RwLock::new(initial_config)),
133 rumdl_config: Arc::new(RwLock::new(Config::default())),
134 documents: Arc::new(RwLock::new(HashMap::new())),
135 workspace_roots,
136 config_cache: Arc::new(RwLock::new(HashMap::new())),
137 workspace_index,
138 index_state,
139 update_tx,
140 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
141 }
142 }
143
144 async fn get_document_content(&self, uri: &Url) -> Option<String> {
150 {
152 let docs = self.documents.read().await;
153 if let Some(entry) = docs.get(uri) {
154 return Some(entry.content.clone());
155 }
156 }
157
158 if let Ok(path) = uri.to_file_path() {
160 if let Ok(content) = tokio::fs::read_to_string(&path).await {
161 let entry = DocumentEntry {
163 content: content.clone(),
164 version: None,
165 from_disk: true,
166 };
167
168 let mut docs = self.documents.write().await;
169 docs.insert(uri.clone(), entry);
170
171 log::debug!("Loaded document from disk and cached: {uri}");
172 return Some(content);
173 } else {
174 log::debug!("Failed to read file from disk: {uri}");
175 }
176 }
177
178 None
179 }
180
181 fn is_valid_rule_name(name: &str) -> bool {
185 let bytes = name.as_bytes();
186
187 if bytes.len() == 3
189 && bytes[0].eq_ignore_ascii_case(&b'A')
190 && bytes[1].eq_ignore_ascii_case(&b'L')
191 && bytes[2].eq_ignore_ascii_case(&b'L')
192 {
193 return true;
194 }
195
196 if bytes.len() != 5 {
198 return false;
199 }
200
201 if !bytes[0].eq_ignore_ascii_case(&b'M') || !bytes[1].eq_ignore_ascii_case(&b'D') {
203 return false;
204 }
205
206 let d0 = bytes[2].wrapping_sub(b'0');
208 let d1 = bytes[3].wrapping_sub(b'0');
209 let d2 = bytes[4].wrapping_sub(b'0');
210
211 if d0 > 9 || d1 > 9 || d2 > 9 {
213 return false;
214 }
215
216 let num = (d0 as u32) * 100 + (d1 as u32) * 10 + (d2 as u32);
217
218 matches!(num, 1 | 3..=5 | 7 | 9..=14 | 18..=62)
220 }
221
222 fn apply_lsp_config_overrides(
224 &self,
225 mut filtered_rules: Vec<Box<dyn Rule>>,
226 lsp_config: &RumdlLspConfig,
227 ) -> Vec<Box<dyn Rule>> {
228 let mut enable_rules: Vec<String> = Vec::new();
230 if let Some(enable) = &lsp_config.enable_rules {
231 enable_rules.extend(enable.iter().cloned());
232 }
233 if let Some(settings) = &lsp_config.settings
234 && let Some(enable) = &settings.enable
235 {
236 enable_rules.extend(enable.iter().cloned());
237 }
238
239 if !enable_rules.is_empty() {
241 let enable_set: std::collections::HashSet<String> = enable_rules.into_iter().collect();
242 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
243 }
244
245 let mut disable_rules: Vec<String> = Vec::new();
247 if let Some(disable) = &lsp_config.disable_rules {
248 disable_rules.extend(disable.iter().cloned());
249 }
250 if let Some(settings) = &lsp_config.settings
251 && let Some(disable) = &settings.disable
252 {
253 disable_rules.extend(disable.iter().cloned());
254 }
255
256 if !disable_rules.is_empty() {
258 let disable_set: std::collections::HashSet<String> = disable_rules.into_iter().collect();
259 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
260 }
261
262 filtered_rules
263 }
264
265 fn merge_lsp_settings(&self, mut file_config: Config, lsp_config: &RumdlLspConfig) -> Config {
271 let Some(settings) = &lsp_config.settings else {
272 return file_config;
273 };
274
275 match lsp_config.configuration_preference {
276 ConfigurationPreference::EditorFirst => {
277 self.apply_lsp_settings_to_config(&mut file_config, settings);
279 }
280 ConfigurationPreference::FilesystemFirst => {
281 self.apply_lsp_settings_if_absent(&mut file_config, settings);
283 }
284 ConfigurationPreference::EditorOnly => {
285 let mut default_config = Config::default();
287 self.apply_lsp_settings_to_config(&mut default_config, settings);
288 return default_config;
289 }
290 }
291
292 file_config
293 }
294
295 fn apply_lsp_settings_to_config(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
297 if let Some(line_length) = settings.line_length {
299 config.global.line_length = crate::types::LineLength::new(line_length);
300 }
301
302 if let Some(disable) = &settings.disable {
304 config.global.disable.extend(disable.iter().cloned());
305 }
306
307 if let Some(enable) = &settings.enable {
309 config.global.enable.extend(enable.iter().cloned());
310 }
311
312 for (rule_name, rule_config) in &settings.rules {
314 self.apply_rule_config(config, rule_name, rule_config);
315 }
316 }
317
318 fn apply_lsp_settings_if_absent(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
320 if config.global.line_length.get() == 80
323 && let Some(line_length) = settings.line_length
324 {
325 config.global.line_length = crate::types::LineLength::new(line_length);
326 }
327
328 if let Some(disable) = &settings.disable {
330 config.global.disable.extend(disable.iter().cloned());
331 }
332
333 if let Some(enable) = &settings.enable {
334 config.global.enable.extend(enable.iter().cloned());
335 }
336
337 for (rule_name, rule_config) in &settings.rules {
339 self.apply_rule_config_if_absent(config, rule_name, rule_config);
340 }
341 }
342
343 fn apply_rule_config(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
348 let rule_key = rule_name.to_uppercase();
349
350 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
352
353 if let Some(obj) = rule_config.as_object() {
355 for (key, value) in obj {
356 let config_key = Self::camel_to_snake(key);
358
359 if config_key == "severity" {
361 if let Some(severity_str) = value.as_str() {
362 match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
363 severity_str.to_string(),
364 )) {
365 Ok(severity) => {
366 rule_entry.severity = Some(severity);
367 }
368 Err(_) => {
369 log::warn!(
370 "Invalid severity '{severity_str}' for rule {rule_key}. \
371 Valid values: error, warning, info"
372 );
373 }
374 }
375 }
376 continue;
377 }
378
379 if let Some(toml_value) = Self::json_to_toml(value) {
381 rule_entry.values.insert(config_key, toml_value);
382 }
383 }
384 }
385 }
386
387 fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
395 let rule_key = rule_name.to_uppercase();
396
397 let existing_rule = config.rules.get(&rule_key);
399 let has_existing_values = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
400 let has_existing_severity = existing_rule.and_then(|r| r.severity).is_some();
401
402 if let Some(obj) = rule_config.as_object() {
404 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
405
406 for (key, value) in obj {
407 let config_key = Self::camel_to_snake(key);
408
409 if config_key == "severity" {
411 if !has_existing_severity && let Some(severity_str) = value.as_str() {
412 match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
413 severity_str.to_string(),
414 )) {
415 Ok(severity) => {
416 rule_entry.severity = Some(severity);
417 }
418 Err(_) => {
419 log::warn!(
420 "Invalid severity '{severity_str}' for rule {rule_key}. \
421 Valid values: error, warning, info"
422 );
423 }
424 }
425 }
426 continue;
427 }
428
429 if !has_existing_values && let Some(toml_value) = Self::json_to_toml(value) {
431 rule_entry.values.insert(config_key, toml_value);
432 }
433 }
434 }
435 }
436
437 fn camel_to_snake(s: &str) -> String {
439 let mut result = String::new();
440 for (i, c) in s.chars().enumerate() {
441 if c.is_uppercase() && i > 0 {
442 result.push('_');
443 }
444 result.push(c.to_lowercase().next().unwrap_or(c));
445 }
446 result
447 }
448
449 fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
451 match json {
452 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
453 serde_json::Value::Number(n) => {
454 if let Some(i) = n.as_i64() {
455 Some(toml::Value::Integer(i))
456 } else {
457 n.as_f64().map(toml::Value::Float)
458 }
459 }
460 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
461 serde_json::Value::Array(arr) => {
462 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
463 Some(toml::Value::Array(toml_arr))
464 }
465 serde_json::Value::Object(obj) => {
466 let mut table = toml::map::Map::new();
467 for (k, v) in obj {
468 if let Some(toml_v) = Self::json_to_toml(v) {
469 table.insert(Self::camel_to_snake(k), toml_v);
470 }
471 }
472 Some(toml::Value::Table(table))
473 }
474 serde_json::Value::Null => None,
475 }
476 }
477
478 async fn should_exclude_uri(&self, uri: &Url) -> bool {
480 let file_path = match uri.to_file_path() {
482 Ok(path) => path,
483 Err(_) => return false, };
485
486 let rumdl_config = self.resolve_config_for_file(&file_path).await;
488 let exclude_patterns = &rumdl_config.global.exclude;
489
490 if exclude_patterns.is_empty() {
492 return false;
493 }
494
495 let path_to_check = if file_path.is_absolute() {
498 if let Ok(cwd) = std::env::current_dir() {
500 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
502 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
503 relative.to_string_lossy().to_string()
504 } else {
505 file_path.to_string_lossy().to_string()
507 }
508 } else {
509 file_path.to_string_lossy().to_string()
511 }
512 } else {
513 file_path.to_string_lossy().to_string()
514 }
515 } else {
516 file_path.to_string_lossy().to_string()
518 };
519
520 for pattern in exclude_patterns {
522 if let Ok(glob) = globset::Glob::new(pattern) {
523 let matcher = glob.compile_matcher();
524 if matcher.is_match(&path_to_check) {
525 log::debug!("Excluding file from LSP linting: {path_to_check}");
526 return true;
527 }
528 }
529 }
530
531 false
532 }
533
534 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
536 let config_guard = self.config.read().await;
537
538 if !config_guard.enable_linting {
540 return Ok(Vec::new());
541 }
542
543 let lsp_config = config_guard.clone();
544 drop(config_guard); if self.should_exclude_uri(uri).await {
548 return Ok(Vec::new());
549 }
550
551 let file_path = uri.to_file_path().ok();
553 let file_config = if let Some(ref path) = file_path {
554 self.resolve_config_for_file(path).await
555 } else {
556 (*self.rumdl_config.read().await).clone()
558 };
559
560 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
562
563 let all_rules = rules::all_rules(&rumdl_config);
564 let flavor = rumdl_config.markdown_flavor();
565
566 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
568
569 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
571
572 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
574 Ok(warnings) => warnings,
575 Err(e) => {
576 log::error!("Failed to lint document {uri}: {e}");
577 return Ok(Vec::new());
578 }
579 };
580
581 if let Some(ref path) = file_path {
583 let index_state = self.index_state.read().await.clone();
584 if matches!(index_state, IndexState::Ready) {
585 let workspace_index = self.workspace_index.read().await;
586 if let Some(file_index) = workspace_index.get_file(path) {
587 match crate::run_cross_file_checks(
588 path,
589 file_index,
590 &filtered_rules,
591 &workspace_index,
592 Some(&rumdl_config),
593 ) {
594 Ok(cross_file_warnings) => {
595 all_warnings.extend(cross_file_warnings);
596 }
597 Err(e) => {
598 log::warn!("Failed to run cross-file checks for {uri}: {e}");
599 }
600 }
601 }
602 }
603 }
604
605 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
606 Ok(diagnostics)
607 }
608
609 async fn update_diagnostics(&self, uri: Url, text: String) {
615 if *self.client_supports_pull_diagnostics.read().await {
617 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
618 return;
619 }
620
621 let version = {
623 let docs = self.documents.read().await;
624 docs.get(&uri).and_then(|entry| entry.version)
625 };
626
627 match self.lint_document(&uri, &text).await {
628 Ok(diagnostics) => {
629 self.client.publish_diagnostics(uri, diagnostics, version).await;
630 }
631 Err(e) => {
632 log::error!("Failed to update diagnostics: {e}");
633 }
634 }
635 }
636
637 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
639 if self.should_exclude_uri(uri).await {
641 return Ok(None);
642 }
643
644 let config_guard = self.config.read().await;
645 let lsp_config = config_guard.clone();
646 drop(config_guard);
647
648 let file_config = if let Ok(file_path) = uri.to_file_path() {
650 self.resolve_config_for_file(&file_path).await
651 } else {
652 (*self.rumdl_config.read().await).clone()
654 };
655
656 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
658
659 let all_rules = rules::all_rules(&rumdl_config);
660 let flavor = rumdl_config.markdown_flavor();
661
662 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
664
665 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
667
668 let mut rules_with_warnings = std::collections::HashSet::new();
671 let mut fixed_text = text.to_string();
672
673 match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
674 Ok(warnings) => {
675 for warning in warnings {
676 if let Some(rule_name) = &warning.rule_name {
677 rules_with_warnings.insert(rule_name.clone());
678 }
679 }
680 }
681 Err(e) => {
682 log::warn!("Failed to lint document for auto-fix: {e}");
683 return Ok(None);
684 }
685 }
686
687 if rules_with_warnings.is_empty() {
689 return Ok(None);
690 }
691
692 let mut any_changes = false;
694
695 for rule in &filtered_rules {
696 if !rules_with_warnings.contains(rule.name()) {
698 continue;
699 }
700
701 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
702 match rule.fix(&ctx) {
703 Ok(new_text) => {
704 if new_text != fixed_text {
705 fixed_text = new_text;
706 any_changes = true;
707 }
708 }
709 Err(e) => {
710 let msg = e.to_string();
712 if !msg.contains("does not support automatic fixing") {
713 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
714 }
715 }
716 }
717 }
718
719 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
720 }
721
722 fn get_end_position(&self, text: &str) -> Position {
724 let mut line = 0u32;
725 let mut character = 0u32;
726
727 for ch in text.chars() {
728 if ch == '\n' {
729 line += 1;
730 character = 0;
731 } else {
732 character += 1;
733 }
734 }
735
736 Position { line, character }
737 }
738
739 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
741 let config_guard = self.config.read().await;
742 let lsp_config = config_guard.clone();
743 drop(config_guard);
744
745 let file_config = if let Ok(file_path) = uri.to_file_path() {
747 self.resolve_config_for_file(&file_path).await
748 } else {
749 (*self.rumdl_config.read().await).clone()
751 };
752
753 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
755
756 let all_rules = rules::all_rules(&rumdl_config);
757 let flavor = rumdl_config.markdown_flavor();
758
759 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
761
762 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
764
765 match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
766 Ok(warnings) => {
767 let mut actions = Vec::new();
768 let mut fixable_count = 0;
769
770 for warning in &warnings {
771 let warning_line = (warning.line.saturating_sub(1)) as u32;
773 if warning_line >= range.start.line && warning_line <= range.end.line {
774 let mut warning_actions = warning_to_code_actions(warning, uri, text);
776 actions.append(&mut warning_actions);
777
778 if warning.fix.is_some() {
779 fixable_count += 1;
780 }
781 }
782 }
783
784 if fixable_count > 1 {
786 let fixable_warnings: Vec<_> = warnings
789 .iter()
790 .filter(|w| {
791 if let Some(rule_name) = &w.rule_name {
792 filtered_rules
793 .iter()
794 .find(|r| r.name() == rule_name)
795 .map(|r| r.fix_capability() != FixCapability::Unfixable)
796 .unwrap_or(false)
797 } else {
798 false
799 }
800 })
801 .cloned()
802 .collect();
803
804 let total_fixable = fixable_warnings.len();
806
807 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
808 && fixed_content != text
809 {
810 let mut line = 0u32;
812 let mut character = 0u32;
813 for ch in text.chars() {
814 if ch == '\n' {
815 line += 1;
816 character = 0;
817 } else {
818 character += 1;
819 }
820 }
821
822 let fix_all_action = CodeAction {
823 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
824 kind: Some(CodeActionKind::QUICKFIX),
825 diagnostics: Some(Vec::new()),
826 edit: Some(WorkspaceEdit {
827 changes: Some(
828 [(
829 uri.clone(),
830 vec![TextEdit {
831 range: Range {
832 start: Position { line: 0, character: 0 },
833 end: Position { line, character },
834 },
835 new_text: fixed_content,
836 }],
837 )]
838 .into_iter()
839 .collect(),
840 ),
841 ..Default::default()
842 }),
843 command: None,
844 is_preferred: Some(true),
845 disabled: None,
846 data: None,
847 };
848
849 actions.insert(0, fix_all_action);
851 }
852 }
853
854 Ok(actions)
855 }
856 Err(e) => {
857 log::error!("Failed to get code actions: {e}");
858 Ok(Vec::new())
859 }
860 }
861 }
862
863 async fn load_configuration(&self, notify_client: bool) {
865 let config_guard = self.config.read().await;
866 let explicit_config_path = config_guard.config_path.clone();
867 drop(config_guard);
868
869 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
871 Ok(sourced_config) => {
872 let loaded_files = sourced_config.loaded_files.clone();
873 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
875
876 if !loaded_files.is_empty() {
877 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
878 log::info!("{message}");
879 if notify_client {
880 self.client.log_message(MessageType::INFO, &message).await;
881 }
882 } else {
883 log::info!("Using default rumdl configuration (no config files found)");
884 }
885 }
886 Err(e) => {
887 let message = format!("Failed to load rumdl config: {e}");
888 log::warn!("{message}");
889 if notify_client {
890 self.client.log_message(MessageType::WARNING, &message).await;
891 }
892 *self.rumdl_config.write().await = crate::config::Config::default();
894 }
895 }
896 }
897
898 async fn reload_configuration(&self) {
900 self.load_configuration(true).await;
901 }
902
903 fn load_config_for_lsp(
905 config_path: Option<&str>,
906 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
907 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
909 }
910
911 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
918 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
920
921 {
923 let cache = self.config_cache.read().await;
924 if let Some(entry) = cache.get(&search_dir) {
925 let source_owned: String; let source: &str = if entry.from_global_fallback {
927 "global/user fallback"
928 } else if let Some(path) = &entry.config_file {
929 source_owned = path.to_string_lossy().to_string();
930 &source_owned
931 } else {
932 "<unknown>"
933 };
934 log::debug!(
935 "Config cache hit for directory: {} (loaded from: {})",
936 search_dir.display(),
937 source
938 );
939 return entry.config.clone();
940 }
941 }
942
943 log::debug!(
945 "Config cache miss for directory: {}, searching for config...",
946 search_dir.display()
947 );
948
949 let workspace_root = {
951 let workspace_roots = self.workspace_roots.read().await;
952 workspace_roots
953 .iter()
954 .find(|root| search_dir.starts_with(root))
955 .map(|p| p.to_path_buf())
956 };
957
958 let mut current_dir = search_dir.clone();
960 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
961
962 loop {
963 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
965
966 for config_file_name in CONFIG_FILES {
967 let config_path = current_dir.join(config_file_name);
968 if config_path.exists() {
969 if *config_file_name == "pyproject.toml" {
971 if let Ok(content) = std::fs::read_to_string(&config_path) {
972 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
973 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
974 } else {
975 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
976 continue;
977 }
978 } else {
979 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
980 continue;
981 }
982 } else {
983 log::debug!("Found config file: {}", config_path.display());
984 }
985
986 if let Some(config_path_str) = config_path.to_str() {
988 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
989 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
990 break;
991 }
992 } else {
993 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
994 }
995 }
996 }
997
998 if found_config.is_some() {
999 break;
1000 }
1001
1002 if let Some(ref root) = workspace_root
1004 && ¤t_dir == root
1005 {
1006 log::debug!("Hit workspace root without finding config: {}", root.display());
1007 break;
1008 }
1009
1010 if let Some(parent) = current_dir.parent() {
1012 current_dir = parent.to_path_buf();
1013 } else {
1014 break;
1016 }
1017 }
1018
1019 let (config, config_file) = if let Some((cfg, path)) = found_config {
1021 (cfg, path)
1022 } else {
1023 log::debug!("No project config found; using global/user fallback config");
1024 let fallback = self.rumdl_config.read().await.clone();
1025 (fallback, None)
1026 };
1027
1028 let from_global = config_file.is_none();
1030 let entry = ConfigCacheEntry {
1031 config: config.clone(),
1032 config_file,
1033 from_global_fallback: from_global,
1034 };
1035
1036 self.config_cache.write().await.insert(search_dir, entry);
1037
1038 config
1039 }
1040}
1041
1042#[tower_lsp::async_trait]
1043impl LanguageServer for RumdlLanguageServer {
1044 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1045 log::info!("Initializing rumdl Language Server");
1046
1047 if let Some(options) = params.initialization_options
1049 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
1050 {
1051 *self.config.write().await = config;
1052 }
1053
1054 let supports_pull = params
1057 .capabilities
1058 .text_document
1059 .as_ref()
1060 .and_then(|td| td.diagnostic.as_ref())
1061 .is_some();
1062
1063 if supports_pull {
1064 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1065 *self.client_supports_pull_diagnostics.write().await = true;
1066 } else {
1067 log::info!("Client does not support pull diagnostics - using push model");
1068 }
1069
1070 let mut roots = Vec::new();
1072 if let Some(workspace_folders) = params.workspace_folders {
1073 for folder in workspace_folders {
1074 if let Ok(path) = folder.uri.to_file_path() {
1075 log::info!("Workspace root: {}", path.display());
1076 roots.push(path);
1077 }
1078 }
1079 } else if let Some(root_uri) = params.root_uri
1080 && let Ok(path) = root_uri.to_file_path()
1081 {
1082 log::info!("Workspace root: {}", path.display());
1083 roots.push(path);
1084 }
1085 *self.workspace_roots.write().await = roots;
1086
1087 self.load_configuration(false).await;
1089
1090 Ok(InitializeResult {
1091 capabilities: ServerCapabilities {
1092 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1093 open_close: Some(true),
1094 change: Some(TextDocumentSyncKind::FULL),
1095 will_save: Some(false),
1096 will_save_wait_until: Some(true),
1097 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1098 include_text: Some(false),
1099 })),
1100 })),
1101 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1102 document_formatting_provider: Some(OneOf::Left(true)),
1103 document_range_formatting_provider: Some(OneOf::Left(true)),
1104 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1105 identifier: Some("rumdl".to_string()),
1106 inter_file_dependencies: true,
1107 workspace_diagnostics: true,
1108 work_done_progress_options: WorkDoneProgressOptions::default(),
1109 })),
1110 workspace: Some(WorkspaceServerCapabilities {
1111 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1112 supported: Some(true),
1113 change_notifications: Some(OneOf::Left(true)),
1114 }),
1115 file_operations: None,
1116 }),
1117 ..Default::default()
1118 },
1119 server_info: Some(ServerInfo {
1120 name: "rumdl".to_string(),
1121 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1122 }),
1123 })
1124 }
1125
1126 async fn initialized(&self, _: InitializedParams) {
1127 let version = env!("CARGO_PKG_VERSION");
1128
1129 let (binary_path, build_time) = std::env::current_exe()
1131 .ok()
1132 .map(|path| {
1133 let path_str = path.to_str().unwrap_or("unknown").to_string();
1134 let build_time = std::fs::metadata(&path)
1135 .ok()
1136 .and_then(|metadata| metadata.modified().ok())
1137 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1138 .and_then(|duration| {
1139 let secs = duration.as_secs();
1140 chrono::DateTime::from_timestamp(secs as i64, 0)
1141 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1142 })
1143 .unwrap_or_else(|| "unknown".to_string());
1144 (path_str, build_time)
1145 })
1146 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1147
1148 let working_dir = std::env::current_dir()
1149 .ok()
1150 .and_then(|p| p.to_str().map(|s| s.to_string()))
1151 .unwrap_or_else(|| "unknown".to_string());
1152
1153 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1154 log::info!("Working directory: {working_dir}");
1155
1156 self.client
1157 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1158 .await;
1159
1160 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1162 log::warn!("Failed to trigger initial workspace indexing");
1163 } else {
1164 log::info!("Triggered initial workspace indexing for cross-file analysis");
1165 }
1166
1167 let markdown_patterns = [
1170 "**/*.md",
1171 "**/*.markdown",
1172 "**/*.mdx",
1173 "**/*.mkd",
1174 "**/*.mkdn",
1175 "**/*.mdown",
1176 "**/*.mdwn",
1177 "**/*.qmd",
1178 "**/*.rmd",
1179 ];
1180 let watchers: Vec<_> = markdown_patterns
1181 .iter()
1182 .map(|pattern| FileSystemWatcher {
1183 glob_pattern: GlobPattern::String((*pattern).to_string()),
1184 kind: Some(WatchKind::all()),
1185 })
1186 .collect();
1187
1188 let registration = Registration {
1189 id: "markdown-watcher".to_string(),
1190 method: "workspace/didChangeWatchedFiles".to_string(),
1191 register_options: Some(
1192 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1193 ),
1194 };
1195
1196 if self.client.register_capability(vec![registration]).await.is_err() {
1197 log::debug!("Client does not support file watching capability");
1198 }
1199 }
1200
1201 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1202 let mut roots = self.workspace_roots.write().await;
1204
1205 for removed in ¶ms.event.removed {
1207 if let Ok(path) = removed.uri.to_file_path() {
1208 roots.retain(|r| r != &path);
1209 log::info!("Removed workspace root: {}", path.display());
1210 }
1211 }
1212
1213 for added in ¶ms.event.added {
1215 if let Ok(path) = added.uri.to_file_path()
1216 && !roots.contains(&path)
1217 {
1218 log::info!("Added workspace root: {}", path.display());
1219 roots.push(path);
1220 }
1221 }
1222 drop(roots);
1223
1224 self.config_cache.write().await.clear();
1226
1227 self.reload_configuration().await;
1229
1230 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1232 log::warn!("Failed to trigger workspace rescan after folder change");
1233 }
1234 }
1235
1236 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1237 log::debug!("Configuration changed: {:?}", params.settings);
1238
1239 let settings_value = params.settings;
1243
1244 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1246 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1247 } else {
1248 settings_value
1249 };
1250
1251 let mut config_applied = false;
1253 let mut warnings: Vec<String> = Vec::new();
1254
1255 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1259 && (rule_settings.disable.is_some()
1260 || rule_settings.enable.is_some()
1261 || rule_settings.line_length.is_some()
1262 || !rule_settings.rules.is_empty())
1263 {
1264 if let Some(ref disable) = rule_settings.disable {
1266 for rule in disable {
1267 if !Self::is_valid_rule_name(rule) {
1268 warnings.push(format!("Unknown rule in disable list: {rule}"));
1269 }
1270 }
1271 }
1272 if let Some(ref enable) = rule_settings.enable {
1273 for rule in enable {
1274 if !Self::is_valid_rule_name(rule) {
1275 warnings.push(format!("Unknown rule in enable list: {rule}"));
1276 }
1277 }
1278 }
1279 for rule_name in rule_settings.rules.keys() {
1281 if !Self::is_valid_rule_name(rule_name) {
1282 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1283 }
1284 }
1285
1286 log::info!("Applied rule settings from configuration (Neovim style)");
1287 let mut config = self.config.write().await;
1288 config.settings = Some(rule_settings);
1289 drop(config);
1290 config_applied = true;
1291 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1292 && (full_config.config_path.is_some()
1293 || full_config.enable_rules.is_some()
1294 || full_config.disable_rules.is_some()
1295 || full_config.settings.is_some()
1296 || !full_config.enable_linting
1297 || full_config.enable_auto_fix)
1298 {
1299 if let Some(ref rules) = full_config.enable_rules {
1301 for rule in rules {
1302 if !Self::is_valid_rule_name(rule) {
1303 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1304 }
1305 }
1306 }
1307 if let Some(ref rules) = full_config.disable_rules {
1308 for rule in rules {
1309 if !Self::is_valid_rule_name(rule) {
1310 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1311 }
1312 }
1313 }
1314
1315 log::info!("Applied full LSP configuration from settings");
1316 *self.config.write().await = full_config;
1317 config_applied = true;
1318 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1319 let mut config = self.config.write().await;
1322
1323 let mut rules = std::collections::HashMap::new();
1325 let mut disable = Vec::new();
1326 let mut enable = Vec::new();
1327 let mut line_length = None;
1328
1329 for (key, value) in obj {
1330 match key.as_str() {
1331 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1332 Ok(d) => {
1333 if d.len() > MAX_RULE_LIST_SIZE {
1334 warnings.push(format!(
1335 "Too many rules in 'disable' ({} > {}), truncating",
1336 d.len(),
1337 MAX_RULE_LIST_SIZE
1338 ));
1339 }
1340 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1341 if !Self::is_valid_rule_name(rule) {
1342 warnings.push(format!("Unknown rule in disable: {rule}"));
1343 }
1344 }
1345 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1346 }
1347 Err(_) => {
1348 warnings.push(format!(
1349 "Invalid 'disable' value: expected array of strings, got {value}"
1350 ));
1351 }
1352 },
1353 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1354 Ok(e) => {
1355 if e.len() > MAX_RULE_LIST_SIZE {
1356 warnings.push(format!(
1357 "Too many rules in 'enable' ({} > {}), truncating",
1358 e.len(),
1359 MAX_RULE_LIST_SIZE
1360 ));
1361 }
1362 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1363 if !Self::is_valid_rule_name(rule) {
1364 warnings.push(format!("Unknown rule in enable: {rule}"));
1365 }
1366 }
1367 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1368 }
1369 Err(_) => {
1370 warnings.push(format!(
1371 "Invalid 'enable' value: expected array of strings, got {value}"
1372 ));
1373 }
1374 },
1375 "lineLength" | "line_length" | "line-length" => {
1376 if let Some(l) = value.as_u64() {
1377 match usize::try_from(l) {
1378 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1379 Ok(len) => warnings.push(format!(
1380 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1381 )),
1382 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1383 }
1384 } else {
1385 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1386 }
1387 }
1388 _ if key.starts_with("MD") || key.starts_with("md") => {
1390 let normalized = key.to_uppercase();
1391 if !Self::is_valid_rule_name(&normalized) {
1392 warnings.push(format!("Unknown rule: {key}"));
1393 }
1394 rules.insert(normalized, value);
1395 }
1396 _ => {
1397 warnings.push(format!("Unknown configuration key: {key}"));
1399 }
1400 }
1401 }
1402
1403 let settings = LspRuleSettings {
1404 line_length,
1405 disable: if disable.is_empty() { None } else { Some(disable) },
1406 enable: if enable.is_empty() { None } else { Some(enable) },
1407 rules,
1408 };
1409
1410 log::info!("Applied Neovim-style rule settings (manual parse)");
1411 config.settings = Some(settings);
1412 drop(config);
1413 config_applied = true;
1414 } else {
1415 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1416 }
1417
1418 for warning in &warnings {
1420 log::warn!("{warning}");
1421 }
1422
1423 if !warnings.is_empty() {
1425 let message = if warnings.len() == 1 {
1426 format!("rumdl: {}", warnings[0])
1427 } else {
1428 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1429 };
1430 self.client.log_message(MessageType::WARNING, message).await;
1431 }
1432
1433 if !config_applied {
1434 log::debug!("No configuration changes applied");
1435 }
1436
1437 self.config_cache.write().await.clear();
1439
1440 let doc_list: Vec<_> = {
1442 let documents = self.documents.read().await;
1443 documents
1444 .iter()
1445 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1446 .collect()
1447 };
1448
1449 let tasks = doc_list.into_iter().map(|(uri, text)| {
1451 let server = self.clone();
1452 tokio::spawn(async move {
1453 server.update_diagnostics(uri, text).await;
1454 })
1455 });
1456
1457 let _ = join_all(tasks).await;
1459 }
1460
1461 async fn shutdown(&self) -> JsonRpcResult<()> {
1462 log::info!("Shutting down rumdl Language Server");
1463
1464 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1466
1467 Ok(())
1468 }
1469
1470 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1471 let uri = params.text_document.uri;
1472 let text = params.text_document.text;
1473 let version = params.text_document.version;
1474
1475 let entry = DocumentEntry {
1476 content: text.clone(),
1477 version: Some(version),
1478 from_disk: false,
1479 };
1480 self.documents.write().await.insert(uri.clone(), entry);
1481
1482 if let Ok(path) = uri.to_file_path() {
1484 let _ = self
1485 .update_tx
1486 .send(IndexUpdate::FileChanged {
1487 path,
1488 content: text.clone(),
1489 })
1490 .await;
1491 }
1492
1493 self.update_diagnostics(uri, text).await;
1494 }
1495
1496 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1497 let uri = params.text_document.uri;
1498 let version = params.text_document.version;
1499
1500 if let Some(change) = params.content_changes.into_iter().next() {
1501 let text = change.text;
1502
1503 let entry = DocumentEntry {
1504 content: text.clone(),
1505 version: Some(version),
1506 from_disk: false,
1507 };
1508 self.documents.write().await.insert(uri.clone(), entry);
1509
1510 if let Ok(path) = uri.to_file_path() {
1512 let _ = self
1513 .update_tx
1514 .send(IndexUpdate::FileChanged {
1515 path,
1516 content: text.clone(),
1517 })
1518 .await;
1519 }
1520
1521 self.update_diagnostics(uri, text).await;
1522 }
1523 }
1524
1525 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1526 let config_guard = self.config.read().await;
1527 let enable_auto_fix = config_guard.enable_auto_fix;
1528 drop(config_guard);
1529
1530 if !enable_auto_fix {
1531 return Ok(None);
1532 }
1533
1534 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1536 return Ok(None);
1537 };
1538
1539 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1541 Ok(Some(fixed_text)) => {
1542 Ok(Some(vec![TextEdit {
1544 range: Range {
1545 start: Position { line: 0, character: 0 },
1546 end: self.get_end_position(&text),
1547 },
1548 new_text: fixed_text,
1549 }]))
1550 }
1551 Ok(None) => Ok(None),
1552 Err(e) => {
1553 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1554 Ok(None)
1555 }
1556 }
1557 }
1558
1559 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1560 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1563 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1564 .await;
1565 }
1566 }
1567
1568 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1569 self.documents.write().await.remove(¶ms.text_document.uri);
1571
1572 self.client
1575 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1576 .await;
1577 }
1578
1579 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1580 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1582
1583 let mut config_changed = false;
1584
1585 for change in ¶ms.changes {
1586 if let Ok(path) = change.uri.to_file_path() {
1587 let file_name = path.file_name().and_then(|f| f.to_str());
1588 let extension = path.extension().and_then(|e| e.to_str());
1589
1590 if let Some(name) = file_name
1592 && CONFIG_FILES.contains(&name)
1593 && !config_changed
1594 {
1595 log::info!("Config file changed: {}, invalidating config cache", path.display());
1596
1597 let mut cache = self.config_cache.write().await;
1599 cache.retain(|_, entry| {
1600 if let Some(config_file) = &entry.config_file {
1601 config_file != &path
1602 } else {
1603 true
1604 }
1605 });
1606
1607 drop(cache);
1609 self.reload_configuration().await;
1610 config_changed = true;
1611 }
1612
1613 if let Some(ext) = extension
1615 && is_markdown_extension(ext)
1616 {
1617 match change.typ {
1618 FileChangeType::CREATED | FileChangeType::CHANGED => {
1619 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1621 let _ = self
1622 .update_tx
1623 .send(IndexUpdate::FileChanged {
1624 path: path.clone(),
1625 content,
1626 })
1627 .await;
1628 }
1629 }
1630 FileChangeType::DELETED => {
1631 let _ = self
1632 .update_tx
1633 .send(IndexUpdate::FileDeleted { path: path.clone() })
1634 .await;
1635 }
1636 _ => {}
1637 }
1638 }
1639 }
1640 }
1641
1642 if config_changed {
1644 let docs_to_update: Vec<(Url, String)> = {
1645 let docs = self.documents.read().await;
1646 docs.iter()
1647 .filter(|(_, entry)| !entry.from_disk)
1648 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1649 .collect()
1650 };
1651
1652 for (uri, text) in docs_to_update {
1653 self.update_diagnostics(uri, text).await;
1654 }
1655 }
1656 }
1657
1658 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1659 let uri = params.text_document.uri;
1660 let range = params.range;
1661
1662 if let Some(text) = self.get_document_content(&uri).await {
1663 match self.get_code_actions(&uri, &text, range).await {
1664 Ok(actions) => {
1665 let response: Vec<CodeActionOrCommand> =
1666 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1667 Ok(Some(response))
1668 }
1669 Err(e) => {
1670 log::error!("Failed to get code actions: {e}");
1671 Ok(None)
1672 }
1673 }
1674 } else {
1675 Ok(None)
1676 }
1677 }
1678
1679 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1680 log::debug!(
1685 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1686 params.range
1687 );
1688
1689 let formatting_params = DocumentFormattingParams {
1690 text_document: params.text_document,
1691 options: params.options,
1692 work_done_progress_params: params.work_done_progress_params,
1693 };
1694
1695 self.formatting(formatting_params).await
1696 }
1697
1698 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1699 let uri = params.text_document.uri;
1700
1701 log::debug!("Formatting request for: {uri}");
1702
1703 if let Some(text) = self.get_document_content(&uri).await {
1704 let config_guard = self.config.read().await;
1706 let lsp_config = config_guard.clone();
1707 drop(config_guard);
1708
1709 let file_config = if let Ok(file_path) = uri.to_file_path() {
1711 self.resolve_config_for_file(&file_path).await
1712 } else {
1713 self.rumdl_config.read().await.clone()
1715 };
1716
1717 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1719
1720 let all_rules = rules::all_rules(&rumdl_config);
1721 let flavor = rumdl_config.markdown_flavor();
1722
1723 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1725
1726 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1728
1729 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1731 Ok(warnings) => {
1732 log::debug!(
1733 "Found {} warnings, {} with fixes",
1734 warnings.len(),
1735 warnings.iter().filter(|w| w.fix.is_some()).count()
1736 );
1737
1738 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1739 if has_fixes {
1740 let fixable_warnings: Vec<_> = warnings
1744 .iter()
1745 .filter(|w| {
1746 if let Some(rule_name) = &w.rule_name {
1747 filtered_rules
1748 .iter()
1749 .find(|r| r.name() == rule_name)
1750 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1751 .unwrap_or(false)
1752 } else {
1753 false
1754 }
1755 })
1756 .cloned()
1757 .collect();
1758
1759 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1760 Ok(fixed_content) => {
1761 if fixed_content != text {
1762 log::debug!("Returning formatting edits");
1763 let end_position = self.get_end_position(&text);
1764 let edit = TextEdit {
1765 range: Range {
1766 start: Position { line: 0, character: 0 },
1767 end: end_position,
1768 },
1769 new_text: fixed_content,
1770 };
1771 return Ok(Some(vec![edit]));
1772 }
1773 }
1774 Err(e) => {
1775 log::error!("Failed to apply fixes: {e}");
1776 }
1777 }
1778 }
1779 Ok(Some(Vec::new()))
1780 }
1781 Err(e) => {
1782 log::error!("Failed to format document: {e}");
1783 Ok(Some(Vec::new()))
1784 }
1785 }
1786 } else {
1787 log::warn!("Document not found: {uri}");
1788 Ok(None)
1789 }
1790 }
1791
1792 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1793 let uri = params.text_document.uri;
1794
1795 if let Some(text) = self.get_document_content(&uri).await {
1796 match self.lint_document(&uri, &text).await {
1797 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1798 RelatedFullDocumentDiagnosticReport {
1799 related_documents: None,
1800 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1801 result_id: None,
1802 items: diagnostics,
1803 },
1804 },
1805 ))),
1806 Err(e) => {
1807 log::error!("Failed to get diagnostics: {e}");
1808 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1809 RelatedFullDocumentDiagnosticReport {
1810 related_documents: None,
1811 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1812 result_id: None,
1813 items: Vec::new(),
1814 },
1815 },
1816 )))
1817 }
1818 }
1819 } else {
1820 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1821 RelatedFullDocumentDiagnosticReport {
1822 related_documents: None,
1823 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1824 result_id: None,
1825 items: Vec::new(),
1826 },
1827 },
1828 )))
1829 }
1830 }
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835 use super::*;
1836 use crate::rule::LintWarning;
1837 use tower_lsp::LspService;
1838
1839 fn create_test_server() -> RumdlLanguageServer {
1840 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1841 service.inner().clone()
1842 }
1843
1844 #[test]
1845 fn test_is_valid_rule_name() {
1846 assert!(RumdlLanguageServer::is_valid_rule_name("MD001"));
1848 assert!(RumdlLanguageServer::is_valid_rule_name("md001")); assert!(RumdlLanguageServer::is_valid_rule_name("Md001")); assert!(RumdlLanguageServer::is_valid_rule_name("mD001")); assert!(RumdlLanguageServer::is_valid_rule_name("all")); assert!(RumdlLanguageServer::is_valid_rule_name("ALL")); assert!(RumdlLanguageServer::is_valid_rule_name("All")); assert!(RumdlLanguageServer::is_valid_rule_name("MD003")); assert!(RumdlLanguageServer::is_valid_rule_name("MD005")); assert!(RumdlLanguageServer::is_valid_rule_name("MD007")); assert!(RumdlLanguageServer::is_valid_rule_name("MD009")); assert!(RumdlLanguageServer::is_valid_rule_name("MD014")); assert!(RumdlLanguageServer::is_valid_rule_name("MD018")); assert!(RumdlLanguageServer::is_valid_rule_name("MD062")); assert!(RumdlLanguageServer::is_valid_rule_name("MD041")); assert!(RumdlLanguageServer::is_valid_rule_name("MD060")); assert!(RumdlLanguageServer::is_valid_rule_name("MD061")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD002")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD006")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD008")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD015")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD016")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD017")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD000")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD063")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD999")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD13")); assert!(!RumdlLanguageServer::is_valid_rule_name("INVALID"));
1885 assert!(!RumdlLanguageServer::is_valid_rule_name(""));
1886 assert!(!RumdlLanguageServer::is_valid_rule_name("MD"));
1887 assert!(!RumdlLanguageServer::is_valid_rule_name("MD0001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD1")); }
1890
1891 #[tokio::test]
1892 async fn test_server_creation() {
1893 let server = create_test_server();
1894
1895 let config = server.config.read().await;
1897 assert!(config.enable_linting);
1898 assert!(!config.enable_auto_fix);
1899 }
1900
1901 #[tokio::test]
1902 async fn test_lint_document() {
1903 let server = create_test_server();
1904
1905 let uri = Url::parse("file:///test.md").unwrap();
1907 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1908
1909 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1910
1911 assert!(!diagnostics.is_empty());
1913 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1914 }
1915
1916 #[tokio::test]
1917 async fn test_lint_document_disabled() {
1918 let server = create_test_server();
1919
1920 server.config.write().await.enable_linting = false;
1922
1923 let uri = Url::parse("file:///test.md").unwrap();
1924 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1925
1926 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1927
1928 assert!(diagnostics.is_empty());
1930 }
1931
1932 #[tokio::test]
1933 async fn test_get_code_actions() {
1934 let server = create_test_server();
1935
1936 let uri = Url::parse("file:///test.md").unwrap();
1937 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1938
1939 let range = Range {
1941 start: Position { line: 0, character: 0 },
1942 end: Position { line: 3, character: 21 },
1943 };
1944
1945 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1946
1947 assert!(!actions.is_empty());
1949 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1950 }
1951
1952 #[tokio::test]
1953 async fn test_get_code_actions_outside_range() {
1954 let server = create_test_server();
1955
1956 let uri = Url::parse("file:///test.md").unwrap();
1957 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1958
1959 let range = Range {
1961 start: Position { line: 0, character: 0 },
1962 end: Position { line: 0, character: 6 },
1963 };
1964
1965 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1966
1967 assert!(actions.is_empty());
1969 }
1970
1971 #[tokio::test]
1972 async fn test_document_storage() {
1973 let server = create_test_server();
1974
1975 let uri = Url::parse("file:///test.md").unwrap();
1976 let text = "# Test Document";
1977
1978 let entry = DocumentEntry {
1980 content: text.to_string(),
1981 version: Some(1),
1982 from_disk: false,
1983 };
1984 server.documents.write().await.insert(uri.clone(), entry);
1985
1986 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1988 assert_eq!(stored, Some(text.to_string()));
1989
1990 server.documents.write().await.remove(&uri);
1992
1993 let stored = server.documents.read().await.get(&uri).cloned();
1995 assert_eq!(stored, None);
1996 }
1997
1998 #[tokio::test]
1999 async fn test_configuration_loading() {
2000 let server = create_test_server();
2001
2002 server.load_configuration(false).await;
2004
2005 let rumdl_config = server.rumdl_config.read().await;
2008 drop(rumdl_config); }
2011
2012 #[tokio::test]
2013 async fn test_load_config_for_lsp() {
2014 let result = RumdlLanguageServer::load_config_for_lsp(None);
2016 assert!(result.is_ok());
2017
2018 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2020 assert!(result.is_err());
2021 }
2022
2023 #[tokio::test]
2024 async fn test_warning_conversion() {
2025 let warning = LintWarning {
2026 message: "Test warning".to_string(),
2027 line: 1,
2028 column: 1,
2029 end_line: 1,
2030 end_column: 10,
2031 severity: crate::rule::Severity::Warning,
2032 fix: None,
2033 rule_name: Some("MD001".to_string()),
2034 };
2035
2036 let diagnostic = warning_to_diagnostic(&warning);
2038 assert_eq!(diagnostic.message, "Test warning");
2039 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2040 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2041
2042 let uri = Url::parse("file:///test.md").unwrap();
2044 let actions = warning_to_code_actions(&warning, &uri, "Test content");
2045 assert_eq!(actions.len(), 1);
2047 assert_eq!(actions[0].title, "Ignore MD001 for this line");
2048 }
2049
2050 #[tokio::test]
2051 async fn test_multiple_documents() {
2052 let server = create_test_server();
2053
2054 let uri1 = Url::parse("file:///test1.md").unwrap();
2055 let uri2 = Url::parse("file:///test2.md").unwrap();
2056 let text1 = "# Document 1";
2057 let text2 = "# Document 2";
2058
2059 {
2061 let mut docs = server.documents.write().await;
2062 let entry1 = DocumentEntry {
2063 content: text1.to_string(),
2064 version: Some(1),
2065 from_disk: false,
2066 };
2067 let entry2 = DocumentEntry {
2068 content: text2.to_string(),
2069 version: Some(1),
2070 from_disk: false,
2071 };
2072 docs.insert(uri1.clone(), entry1);
2073 docs.insert(uri2.clone(), entry2);
2074 }
2075
2076 let docs = server.documents.read().await;
2078 assert_eq!(docs.len(), 2);
2079 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2080 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2081 }
2082
2083 #[tokio::test]
2084 async fn test_auto_fix_on_save() {
2085 let server = create_test_server();
2086
2087 {
2089 let mut config = server.config.write().await;
2090 config.enable_auto_fix = true;
2091 }
2092
2093 let uri = Url::parse("file:///test.md").unwrap();
2094 let text = "#Heading without space"; let entry = DocumentEntry {
2098 content: text.to_string(),
2099 version: Some(1),
2100 from_disk: false,
2101 };
2102 server.documents.write().await.insert(uri.clone(), entry);
2103
2104 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2106 assert!(fixed.is_some());
2107 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2109 }
2110
2111 #[tokio::test]
2112 async fn test_get_end_position() {
2113 let server = create_test_server();
2114
2115 let pos = server.get_end_position("Hello");
2117 assert_eq!(pos.line, 0);
2118 assert_eq!(pos.character, 5);
2119
2120 let pos = server.get_end_position("Hello\nWorld\nTest");
2122 assert_eq!(pos.line, 2);
2123 assert_eq!(pos.character, 4);
2124
2125 let pos = server.get_end_position("");
2127 assert_eq!(pos.line, 0);
2128 assert_eq!(pos.character, 0);
2129
2130 let pos = server.get_end_position("Hello\n");
2132 assert_eq!(pos.line, 1);
2133 assert_eq!(pos.character, 0);
2134 }
2135
2136 #[tokio::test]
2137 async fn test_empty_document_handling() {
2138 let server = create_test_server();
2139
2140 let uri = Url::parse("file:///empty.md").unwrap();
2141 let text = "";
2142
2143 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2145 assert!(diagnostics.is_empty());
2146
2147 let range = Range {
2149 start: Position { line: 0, character: 0 },
2150 end: Position { line: 0, character: 0 },
2151 };
2152 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2153 assert!(actions.is_empty());
2154 }
2155
2156 #[tokio::test]
2157 async fn test_config_update() {
2158 let server = create_test_server();
2159
2160 {
2162 let mut config = server.config.write().await;
2163 config.enable_auto_fix = true;
2164 config.config_path = Some("/custom/path.toml".to_string());
2165 }
2166
2167 let config = server.config.read().await;
2169 assert!(config.enable_auto_fix);
2170 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2171 }
2172
2173 #[tokio::test]
2174 async fn test_document_formatting() {
2175 let server = create_test_server();
2176 let uri = Url::parse("file:///test.md").unwrap();
2177 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2178
2179 let entry = DocumentEntry {
2181 content: text.to_string(),
2182 version: Some(1),
2183 from_disk: false,
2184 };
2185 server.documents.write().await.insert(uri.clone(), entry);
2186
2187 let params = DocumentFormattingParams {
2189 text_document: TextDocumentIdentifier { uri: uri.clone() },
2190 options: FormattingOptions {
2191 tab_size: 4,
2192 insert_spaces: true,
2193 properties: HashMap::new(),
2194 trim_trailing_whitespace: Some(true),
2195 insert_final_newline: Some(true),
2196 trim_final_newlines: Some(true),
2197 },
2198 work_done_progress_params: WorkDoneProgressParams::default(),
2199 };
2200
2201 let result = server.formatting(params).await.unwrap();
2203
2204 assert!(result.is_some());
2206 let edits = result.unwrap();
2207 assert!(!edits.is_empty());
2208
2209 let edit = &edits[0];
2211 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
2214 assert_eq!(edit.new_text, expected);
2215 }
2216
2217 #[tokio::test]
2220 async fn test_unfixable_rules_excluded_from_formatting() {
2221 let server = create_test_server();
2222 let uri = Url::parse("file:///test.md").unwrap();
2223
2224 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2226
2227 let entry = DocumentEntry {
2229 content: text.to_string(),
2230 version: Some(1),
2231 from_disk: false,
2232 };
2233 server.documents.write().await.insert(uri.clone(), entry);
2234
2235 let format_params = DocumentFormattingParams {
2237 text_document: TextDocumentIdentifier { uri: uri.clone() },
2238 options: FormattingOptions {
2239 tab_size: 4,
2240 insert_spaces: true,
2241 properties: HashMap::new(),
2242 trim_trailing_whitespace: Some(true),
2243 insert_final_newline: Some(true),
2244 trim_final_newlines: Some(true),
2245 },
2246 work_done_progress_params: WorkDoneProgressParams::default(),
2247 };
2248
2249 let format_result = server.formatting(format_params).await.unwrap();
2250 assert!(format_result.is_some(), "Should return formatting edits");
2251
2252 let edits = format_result.unwrap();
2253 assert!(!edits.is_empty(), "Should have formatting edits");
2254
2255 let formatted = &edits[0].new_text;
2256 assert!(
2257 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2258 "HTML should be preserved during formatting (Unfixable rule)"
2259 );
2260 assert!(
2261 !formatted.contains("spaces "),
2262 "Trailing spaces should be removed (fixable rule)"
2263 );
2264
2265 let range = Range {
2267 start: Position { line: 0, character: 0 },
2268 end: Position { line: 10, character: 0 },
2269 };
2270
2271 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2272
2273 let html_fix_actions: Vec<_> = code_actions
2275 .iter()
2276 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2277 .collect();
2278
2279 assert!(
2280 !html_fix_actions.is_empty(),
2281 "Quick Fix actions should be available for HTML (Unfixable rules)"
2282 );
2283
2284 let fix_all_actions: Vec<_> = code_actions
2286 .iter()
2287 .filter(|action| action.title.contains("Fix all"))
2288 .collect();
2289
2290 if let Some(fix_all_action) = fix_all_actions.first()
2291 && let Some(ref edit) = fix_all_action.edit
2292 && let Some(ref changes) = edit.changes
2293 && let Some(text_edits) = changes.get(&uri)
2294 && let Some(text_edit) = text_edits.first()
2295 {
2296 let fixed_all = &text_edit.new_text;
2297 assert!(
2298 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2299 "Fix All should preserve HTML (Unfixable rules)"
2300 );
2301 assert!(
2302 !fixed_all.contains("spaces "),
2303 "Fix All should remove trailing spaces (fixable rules)"
2304 );
2305 }
2306 }
2307
2308 #[tokio::test]
2310 async fn test_resolve_config_for_file_multi_root() {
2311 use std::fs;
2312 use tempfile::tempdir;
2313
2314 let temp_dir = tempdir().unwrap();
2315 let temp_path = temp_dir.path();
2316
2317 let project_a = temp_path.join("project_a");
2319 let project_a_docs = project_a.join("docs");
2320 fs::create_dir_all(&project_a_docs).unwrap();
2321
2322 let config_a = project_a.join(".rumdl.toml");
2323 fs::write(
2324 &config_a,
2325 r#"
2326[global]
2327
2328[MD013]
2329line_length = 60
2330"#,
2331 )
2332 .unwrap();
2333
2334 let project_b = temp_path.join("project_b");
2336 fs::create_dir(&project_b).unwrap();
2337
2338 let config_b = project_b.join(".rumdl.toml");
2339 fs::write(
2340 &config_b,
2341 r#"
2342[global]
2343
2344[MD013]
2345line_length = 120
2346"#,
2347 )
2348 .unwrap();
2349
2350 let server = create_test_server();
2352
2353 {
2355 let mut roots = server.workspace_roots.write().await;
2356 roots.push(project_a.clone());
2357 roots.push(project_b.clone());
2358 }
2359
2360 let file_a = project_a_docs.join("test.md");
2362 fs::write(&file_a, "# Test A\n").unwrap();
2363
2364 let config_for_a = server.resolve_config_for_file(&file_a).await;
2365 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2366 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2367
2368 let file_b = project_b.join("test.md");
2370 fs::write(&file_b, "# Test B\n").unwrap();
2371
2372 let config_for_b = server.resolve_config_for_file(&file_b).await;
2373 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2374 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2375 }
2376
2377 #[tokio::test]
2379 async fn test_config_resolution_respects_workspace_boundaries() {
2380 use std::fs;
2381 use tempfile::tempdir;
2382
2383 let temp_dir = tempdir().unwrap();
2384 let temp_path = temp_dir.path();
2385
2386 let parent_config = temp_path.join(".rumdl.toml");
2388 fs::write(
2389 &parent_config,
2390 r#"
2391[global]
2392
2393[MD013]
2394line_length = 80
2395"#,
2396 )
2397 .unwrap();
2398
2399 let workspace_root = temp_path.join("workspace");
2401 let workspace_subdir = workspace_root.join("subdir");
2402 fs::create_dir_all(&workspace_subdir).unwrap();
2403
2404 let workspace_config = workspace_root.join(".rumdl.toml");
2405 fs::write(
2406 &workspace_config,
2407 r#"
2408[global]
2409
2410[MD013]
2411line_length = 100
2412"#,
2413 )
2414 .unwrap();
2415
2416 let server = create_test_server();
2417
2418 {
2420 let mut roots = server.workspace_roots.write().await;
2421 roots.push(workspace_root.clone());
2422 }
2423
2424 let test_file = workspace_subdir.join("deep").join("test.md");
2426 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2427 fs::write(&test_file, "# Test\n").unwrap();
2428
2429 let config = server.resolve_config_for_file(&test_file).await;
2430 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2431
2432 assert_eq!(
2434 line_length,
2435 Some(100),
2436 "Should find workspace config, not parent config outside workspace"
2437 );
2438 }
2439
2440 #[tokio::test]
2442 async fn test_config_cache_hit() {
2443 use std::fs;
2444 use tempfile::tempdir;
2445
2446 let temp_dir = tempdir().unwrap();
2447 let temp_path = temp_dir.path();
2448
2449 let project = temp_path.join("project");
2450 fs::create_dir(&project).unwrap();
2451
2452 let config_file = project.join(".rumdl.toml");
2453 fs::write(
2454 &config_file,
2455 r#"
2456[global]
2457
2458[MD013]
2459line_length = 75
2460"#,
2461 )
2462 .unwrap();
2463
2464 let server = create_test_server();
2465 {
2466 let mut roots = server.workspace_roots.write().await;
2467 roots.push(project.clone());
2468 }
2469
2470 let test_file = project.join("test.md");
2471 fs::write(&test_file, "# Test\n").unwrap();
2472
2473 let config1 = server.resolve_config_for_file(&test_file).await;
2475 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2476 assert_eq!(line_length1, Some(75));
2477
2478 {
2480 let cache = server.config_cache.read().await;
2481 let search_dir = test_file.parent().unwrap();
2482 assert!(
2483 cache.contains_key(search_dir),
2484 "Cache should be populated after first call"
2485 );
2486 }
2487
2488 let config2 = server.resolve_config_for_file(&test_file).await;
2490 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2491 assert_eq!(line_length2, Some(75));
2492 }
2493
2494 #[tokio::test]
2496 async fn test_nested_directory_config_search() {
2497 use std::fs;
2498 use tempfile::tempdir;
2499
2500 let temp_dir = tempdir().unwrap();
2501 let temp_path = temp_dir.path();
2502
2503 let project = temp_path.join("project");
2504 fs::create_dir(&project).unwrap();
2505
2506 let config = project.join(".rumdl.toml");
2508 fs::write(
2509 &config,
2510 r#"
2511[global]
2512
2513[MD013]
2514line_length = 110
2515"#,
2516 )
2517 .unwrap();
2518
2519 let deep_dir = project.join("src").join("docs").join("guides");
2521 fs::create_dir_all(&deep_dir).unwrap();
2522 let deep_file = deep_dir.join("test.md");
2523 fs::write(&deep_file, "# Test\n").unwrap();
2524
2525 let server = create_test_server();
2526 {
2527 let mut roots = server.workspace_roots.write().await;
2528 roots.push(project.clone());
2529 }
2530
2531 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2532 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2533
2534 assert_eq!(
2535 line_length,
2536 Some(110),
2537 "Should find config by searching upward from deep directory"
2538 );
2539 }
2540
2541 #[tokio::test]
2543 async fn test_fallback_to_default_config() {
2544 use std::fs;
2545 use tempfile::tempdir;
2546
2547 let temp_dir = tempdir().unwrap();
2548 let temp_path = temp_dir.path();
2549
2550 let project = temp_path.join("project");
2551 fs::create_dir(&project).unwrap();
2552
2553 let test_file = project.join("test.md");
2556 fs::write(&test_file, "# Test\n").unwrap();
2557
2558 let server = create_test_server();
2559 {
2560 let mut roots = server.workspace_roots.write().await;
2561 roots.push(project.clone());
2562 }
2563
2564 let config = server.resolve_config_for_file(&test_file).await;
2565
2566 assert_eq!(
2568 config.global.line_length.get(),
2569 80,
2570 "Should fall back to default config when no config file found"
2571 );
2572 }
2573
2574 #[tokio::test]
2576 async fn test_config_priority_closer_wins() {
2577 use std::fs;
2578 use tempfile::tempdir;
2579
2580 let temp_dir = tempdir().unwrap();
2581 let temp_path = temp_dir.path();
2582
2583 let project = temp_path.join("project");
2584 fs::create_dir(&project).unwrap();
2585
2586 let parent_config = project.join(".rumdl.toml");
2588 fs::write(
2589 &parent_config,
2590 r#"
2591[global]
2592
2593[MD013]
2594line_length = 100
2595"#,
2596 )
2597 .unwrap();
2598
2599 let subdir = project.join("subdir");
2601 fs::create_dir(&subdir).unwrap();
2602
2603 let subdir_config = subdir.join(".rumdl.toml");
2604 fs::write(
2605 &subdir_config,
2606 r#"
2607[global]
2608
2609[MD013]
2610line_length = 50
2611"#,
2612 )
2613 .unwrap();
2614
2615 let server = create_test_server();
2616 {
2617 let mut roots = server.workspace_roots.write().await;
2618 roots.push(project.clone());
2619 }
2620
2621 let test_file = subdir.join("test.md");
2623 fs::write(&test_file, "# Test\n").unwrap();
2624
2625 let config = server.resolve_config_for_file(&test_file).await;
2626 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2627
2628 assert_eq!(
2629 line_length,
2630 Some(50),
2631 "Closer config (subdir) should override parent config"
2632 );
2633 }
2634
2635 #[tokio::test]
2641 async fn test_issue_131_pyproject_without_rumdl_section() {
2642 use std::fs;
2643 use tempfile::tempdir;
2644
2645 let parent_dir = tempdir().unwrap();
2647
2648 let project_dir = parent_dir.path().join("project");
2650 fs::create_dir(&project_dir).unwrap();
2651
2652 fs::write(
2654 project_dir.join("pyproject.toml"),
2655 r#"
2656[project]
2657name = "test-project"
2658version = "0.1.0"
2659"#,
2660 )
2661 .unwrap();
2662
2663 fs::write(
2666 parent_dir.path().join(".rumdl.toml"),
2667 r#"
2668[global]
2669disable = ["MD013"]
2670"#,
2671 )
2672 .unwrap();
2673
2674 let test_file = project_dir.join("test.md");
2675 fs::write(&test_file, "# Test\n").unwrap();
2676
2677 let server = create_test_server();
2678
2679 {
2681 let mut roots = server.workspace_roots.write().await;
2682 roots.push(parent_dir.path().to_path_buf());
2683 }
2684
2685 let config = server.resolve_config_for_file(&test_file).await;
2687
2688 assert!(
2691 config.global.disable.contains(&"MD013".to_string()),
2692 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2693 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2694 );
2695
2696 let cache = server.config_cache.read().await;
2699 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2700
2701 assert!(
2702 cache_entry.config_file.is_some(),
2703 "Should have found a config file (parent .rumdl.toml)"
2704 );
2705
2706 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2707 assert!(
2708 found_config_path.ends_with(".rumdl.toml"),
2709 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2710 );
2711 assert!(
2712 found_config_path.parent().unwrap() == parent_dir.path(),
2713 "Should have loaded config from parent directory, not project_dir"
2714 );
2715 }
2716
2717 #[tokio::test]
2722 async fn test_issue_131_pyproject_with_rumdl_section() {
2723 use std::fs;
2724 use tempfile::tempdir;
2725
2726 let parent_dir = tempdir().unwrap();
2728
2729 let project_dir = parent_dir.path().join("project");
2731 fs::create_dir(&project_dir).unwrap();
2732
2733 fs::write(
2735 project_dir.join("pyproject.toml"),
2736 r#"
2737[project]
2738name = "test-project"
2739
2740[tool.rumdl.global]
2741disable = ["MD033"]
2742"#,
2743 )
2744 .unwrap();
2745
2746 fs::write(
2748 parent_dir.path().join(".rumdl.toml"),
2749 r#"
2750[global]
2751disable = ["MD041"]
2752"#,
2753 )
2754 .unwrap();
2755
2756 let test_file = project_dir.join("test.md");
2757 fs::write(&test_file, "# Test\n").unwrap();
2758
2759 let server = create_test_server();
2760
2761 {
2763 let mut roots = server.workspace_roots.write().await;
2764 roots.push(parent_dir.path().to_path_buf());
2765 }
2766
2767 let config = server.resolve_config_for_file(&test_file).await;
2769
2770 assert!(
2772 config.global.disable.contains(&"MD033".to_string()),
2773 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2774 Expected MD033 from project_dir pyproject.toml to be disabled."
2775 );
2776
2777 assert!(
2779 !config.global.disable.contains(&"MD041".to_string()),
2780 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2781 );
2782
2783 let cache = server.config_cache.read().await;
2785 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2786
2787 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2788
2789 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2790 assert!(
2791 found_config_path.ends_with("pyproject.toml"),
2792 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2793 );
2794 assert!(
2795 found_config_path.parent().unwrap() == project_dir,
2796 "Should have loaded pyproject.toml from project_dir, not parent"
2797 );
2798 }
2799
2800 #[tokio::test]
2805 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2806 use std::fs;
2807 use tempfile::tempdir;
2808
2809 let temp_dir = tempdir().unwrap();
2810
2811 fs::write(
2813 temp_dir.path().join("pyproject.toml"),
2814 r#"
2815[project]
2816name = "test-project"
2817
2818[tool.rumdl.global]
2819disable = ["MD022"]
2820"#,
2821 )
2822 .unwrap();
2823
2824 let test_file = temp_dir.path().join("test.md");
2825 fs::write(&test_file, "# Test\n").unwrap();
2826
2827 let server = create_test_server();
2828
2829 {
2831 let mut roots = server.workspace_roots.write().await;
2832 roots.push(temp_dir.path().to_path_buf());
2833 }
2834
2835 let config = server.resolve_config_for_file(&test_file).await;
2837
2838 assert!(
2840 config.global.disable.contains(&"MD022".to_string()),
2841 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2842 );
2843
2844 let cache = server.config_cache.read().await;
2846 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2847 assert!(
2848 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2849 "Should have loaded pyproject.toml"
2850 );
2851 }
2852
2853 #[tokio::test]
2858 async fn test_issue_182_pull_diagnostics_capability_default() {
2859 let server = create_test_server();
2860
2861 assert!(
2863 !*server.client_supports_pull_diagnostics.read().await,
2864 "Default should be false - push diagnostics by default"
2865 );
2866 }
2867
2868 #[tokio::test]
2870 async fn test_issue_182_pull_diagnostics_flag_update() {
2871 let server = create_test_server();
2872
2873 *server.client_supports_pull_diagnostics.write().await = true;
2875
2876 assert!(
2877 *server.client_supports_pull_diagnostics.read().await,
2878 "Flag should be settable to true"
2879 );
2880 }
2881
2882 #[tokio::test]
2886 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2887 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2888
2889 let caps_with_diagnostic = ClientCapabilities {
2891 text_document: Some(TextDocumentClientCapabilities {
2892 diagnostic: Some(DiagnosticClientCapabilities {
2893 dynamic_registration: Some(true),
2894 related_document_support: Some(false),
2895 }),
2896 ..Default::default()
2897 }),
2898 ..Default::default()
2899 };
2900
2901 let supports_pull = caps_with_diagnostic
2903 .text_document
2904 .as_ref()
2905 .and_then(|td| td.diagnostic.as_ref())
2906 .is_some();
2907
2908 assert!(supports_pull, "Should detect pull diagnostic support");
2909 }
2910
2911 #[tokio::test]
2913 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2914 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2915
2916 let caps_without_diagnostic = ClientCapabilities {
2918 text_document: Some(TextDocumentClientCapabilities {
2919 diagnostic: None, ..Default::default()
2921 }),
2922 ..Default::default()
2923 };
2924
2925 let supports_pull = caps_without_diagnostic
2927 .text_document
2928 .as_ref()
2929 .and_then(|td| td.diagnostic.as_ref())
2930 .is_some();
2931
2932 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2933 }
2934
2935 #[tokio::test]
2937 async fn test_issue_182_capability_detection_no_text_document() {
2938 use tower_lsp::lsp_types::ClientCapabilities;
2939
2940 let caps_no_text_doc = ClientCapabilities {
2942 text_document: None,
2943 ..Default::default()
2944 };
2945
2946 let supports_pull = caps_no_text_doc
2948 .text_document
2949 .as_ref()
2950 .and_then(|td| td.diagnostic.as_ref())
2951 .is_some();
2952
2953 assert!(
2954 !supports_pull,
2955 "Should NOT detect pull diagnostic support when text_document is None"
2956 );
2957 }
2958
2959 #[test]
2960 fn test_resource_limit_constants() {
2961 assert_eq!(MAX_RULE_LIST_SIZE, 100);
2963 assert_eq!(MAX_LINE_LENGTH, 10_000);
2964 }
2965
2966 #[test]
2967 fn test_is_valid_rule_name_zero_alloc() {
2968 assert!(!RumdlLanguageServer::is_valid_rule_name("MD/01")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD:01")); assert!(!RumdlLanguageServer::is_valid_rule_name("ND001")); assert!(!RumdlLanguageServer::is_valid_rule_name("ME001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD0①1")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD\x00\x00\x00")); }
2984
2985 #[tokio::test]
2994 async fn test_lsp_toml_config_parity_generic() {
2995 use crate::config::RuleConfig;
2996 use crate::rule::Severity;
2997
2998 let server = create_test_server();
2999
3000 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3004 (
3006 "severity only - error",
3007 serde_json::json!({"severity": "error"}),
3008 RuleConfig {
3009 severity: Some(Severity::Error),
3010 values: std::collections::BTreeMap::new(),
3011 },
3012 ),
3013 (
3014 "severity only - warning",
3015 serde_json::json!({"severity": "warning"}),
3016 RuleConfig {
3017 severity: Some(Severity::Warning),
3018 values: std::collections::BTreeMap::new(),
3019 },
3020 ),
3021 (
3022 "severity only - info",
3023 serde_json::json!({"severity": "info"}),
3024 RuleConfig {
3025 severity: Some(Severity::Info),
3026 values: std::collections::BTreeMap::new(),
3027 },
3028 ),
3029 (
3031 "integer value",
3032 serde_json::json!({"lineLength": 120}),
3033 RuleConfig {
3034 severity: None,
3035 values: [("line_length".to_string(), toml::Value::Integer(120))]
3036 .into_iter()
3037 .collect(),
3038 },
3039 ),
3040 (
3042 "boolean value",
3043 serde_json::json!({"enabled": true}),
3044 RuleConfig {
3045 severity: None,
3046 values: [("enabled".to_string(), toml::Value::Boolean(true))]
3047 .into_iter()
3048 .collect(),
3049 },
3050 ),
3051 (
3053 "string value",
3054 serde_json::json!({"style": "consistent"}),
3055 RuleConfig {
3056 severity: None,
3057 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3058 .into_iter()
3059 .collect(),
3060 },
3061 ),
3062 (
3064 "array value",
3065 serde_json::json!({"allowedElements": ["div", "span"]}),
3066 RuleConfig {
3067 severity: None,
3068 values: [(
3069 "allowed_elements".to_string(),
3070 toml::Value::Array(vec![
3071 toml::Value::String("div".to_string()),
3072 toml::Value::String("span".to_string()),
3073 ]),
3074 )]
3075 .into_iter()
3076 .collect(),
3077 },
3078 ),
3079 (
3081 "severity + integer",
3082 serde_json::json!({"severity": "info", "lineLength": 80}),
3083 RuleConfig {
3084 severity: Some(Severity::Info),
3085 values: [("line_length".to_string(), toml::Value::Integer(80))]
3086 .into_iter()
3087 .collect(),
3088 },
3089 ),
3090 (
3091 "severity + multiple values",
3092 serde_json::json!({
3093 "severity": "warning",
3094 "lineLength": 100,
3095 "strict": false,
3096 "style": "atx"
3097 }),
3098 RuleConfig {
3099 severity: Some(Severity::Warning),
3100 values: [
3101 ("line_length".to_string(), toml::Value::Integer(100)),
3102 ("strict".to_string(), toml::Value::Boolean(false)),
3103 ("style".to_string(), toml::Value::String("atx".to_string())),
3104 ]
3105 .into_iter()
3106 .collect(),
3107 },
3108 ),
3109 (
3111 "camelCase conversion",
3112 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3113 RuleConfig {
3114 severity: None,
3115 values: [
3116 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3117 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3118 ]
3119 .into_iter()
3120 .collect(),
3121 },
3122 ),
3123 ];
3124
3125 for (description, lsp_json, expected_toml_config) in test_configs {
3126 let mut lsp_config = crate::config::Config::default();
3127 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3128
3129 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3130
3131 assert_eq!(
3133 lsp_rule.severity, expected_toml_config.severity,
3134 "Parity failure [{description}]: severity mismatch. \
3135 LSP={:?}, TOML={:?}",
3136 lsp_rule.severity, expected_toml_config.severity
3137 );
3138
3139 assert_eq!(
3141 lsp_rule.values, expected_toml_config.values,
3142 "Parity failure [{description}]: values mismatch. \
3143 LSP={:?}, TOML={:?}",
3144 lsp_rule.values, expected_toml_config.values
3145 );
3146 }
3147 }
3148
3149 #[tokio::test]
3151 async fn test_lsp_config_if_absent_preserves_existing() {
3152 use crate::config::RuleConfig;
3153 use crate::rule::Severity;
3154
3155 let server = create_test_server();
3156
3157 let mut config = crate::config::Config::default();
3159 config.rules.insert(
3160 "MD013".to_string(),
3161 RuleConfig {
3162 severity: Some(Severity::Error),
3163 values: [("line_length".to_string(), toml::Value::Integer(80))]
3164 .into_iter()
3165 .collect(),
3166 },
3167 );
3168
3169 let lsp_json = serde_json::json!({
3171 "severity": "info",
3172 "lineLength": 120
3173 });
3174 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3175
3176 let rule = config.rules.get("MD013").expect("Rule should exist");
3177
3178 assert_eq!(
3180 rule.severity,
3181 Some(Severity::Error),
3182 "Existing severity should not be overwritten"
3183 );
3184
3185 assert_eq!(
3187 rule.values.get("line_length"),
3188 Some(&toml::Value::Integer(80)),
3189 "Existing values should not be overwritten"
3190 );
3191 }
3192}