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 let Some(toml_value) = Self::json_to_toml(value) {
361 rule_entry.values.insert(config_key, toml_value);
362 }
363 }
364 }
365 }
366
367 fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
369 let rule_key = rule_name.to_uppercase();
370
371 let existing_rule = config.rules.get(&rule_key);
373 let has_existing = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
374
375 if has_existing {
376 log::debug!("Rule {rule_key} already configured in file, skipping LSP settings");
378 return;
379 }
380
381 self.apply_rule_config(config, rule_name, rule_config);
383 }
384
385 fn camel_to_snake(s: &str) -> String {
387 let mut result = String::new();
388 for (i, c) in s.chars().enumerate() {
389 if c.is_uppercase() && i > 0 {
390 result.push('_');
391 }
392 result.push(c.to_lowercase().next().unwrap_or(c));
393 }
394 result
395 }
396
397 fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
399 match json {
400 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
401 serde_json::Value::Number(n) => {
402 if let Some(i) = n.as_i64() {
403 Some(toml::Value::Integer(i))
404 } else {
405 n.as_f64().map(toml::Value::Float)
406 }
407 }
408 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
409 serde_json::Value::Array(arr) => {
410 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
411 Some(toml::Value::Array(toml_arr))
412 }
413 serde_json::Value::Object(obj) => {
414 let mut table = toml::map::Map::new();
415 for (k, v) in obj {
416 if let Some(toml_v) = Self::json_to_toml(v) {
417 table.insert(Self::camel_to_snake(k), toml_v);
418 }
419 }
420 Some(toml::Value::Table(table))
421 }
422 serde_json::Value::Null => None,
423 }
424 }
425
426 async fn should_exclude_uri(&self, uri: &Url) -> bool {
428 let file_path = match uri.to_file_path() {
430 Ok(path) => path,
431 Err(_) => return false, };
433
434 let rumdl_config = self.resolve_config_for_file(&file_path).await;
436 let exclude_patterns = &rumdl_config.global.exclude;
437
438 if exclude_patterns.is_empty() {
440 return false;
441 }
442
443 let path_to_check = if file_path.is_absolute() {
446 if let Ok(cwd) = std::env::current_dir() {
448 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
450 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
451 relative.to_string_lossy().to_string()
452 } else {
453 file_path.to_string_lossy().to_string()
455 }
456 } else {
457 file_path.to_string_lossy().to_string()
459 }
460 } else {
461 file_path.to_string_lossy().to_string()
462 }
463 } else {
464 file_path.to_string_lossy().to_string()
466 };
467
468 for pattern in exclude_patterns {
470 if let Ok(glob) = globset::Glob::new(pattern) {
471 let matcher = glob.compile_matcher();
472 if matcher.is_match(&path_to_check) {
473 log::debug!("Excluding file from LSP linting: {path_to_check}");
474 return true;
475 }
476 }
477 }
478
479 false
480 }
481
482 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
484 let config_guard = self.config.read().await;
485
486 if !config_guard.enable_linting {
488 return Ok(Vec::new());
489 }
490
491 let lsp_config = config_guard.clone();
492 drop(config_guard); if self.should_exclude_uri(uri).await {
496 return Ok(Vec::new());
497 }
498
499 let file_path = uri.to_file_path().ok();
501 let file_config = if let Some(ref path) = file_path {
502 self.resolve_config_for_file(path).await
503 } else {
504 (*self.rumdl_config.read().await).clone()
506 };
507
508 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
510
511 let all_rules = rules::all_rules(&rumdl_config);
512 let flavor = rumdl_config.markdown_flavor();
513
514 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
516
517 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
519
520 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor) {
522 Ok(warnings) => warnings,
523 Err(e) => {
524 log::error!("Failed to lint document {uri}: {e}");
525 return Ok(Vec::new());
526 }
527 };
528
529 if let Some(ref path) = file_path {
531 let index_state = self.index_state.read().await.clone();
532 if matches!(index_state, IndexState::Ready) {
533 let workspace_index = self.workspace_index.read().await;
534 if let Some(file_index) = workspace_index.get_file(path) {
535 match crate::run_cross_file_checks(path, file_index, &filtered_rules, &workspace_index) {
536 Ok(cross_file_warnings) => {
537 all_warnings.extend(cross_file_warnings);
538 }
539 Err(e) => {
540 log::warn!("Failed to run cross-file checks for {uri}: {e}");
541 }
542 }
543 }
544 }
545 }
546
547 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
548 Ok(diagnostics)
549 }
550
551 async fn update_diagnostics(&self, uri: Url, text: String) {
557 if *self.client_supports_pull_diagnostics.read().await {
559 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
560 return;
561 }
562
563 let version = {
565 let docs = self.documents.read().await;
566 docs.get(&uri).and_then(|entry| entry.version)
567 };
568
569 match self.lint_document(&uri, &text).await {
570 Ok(diagnostics) => {
571 self.client.publish_diagnostics(uri, diagnostics, version).await;
572 }
573 Err(e) => {
574 log::error!("Failed to update diagnostics: {e}");
575 }
576 }
577 }
578
579 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
581 if self.should_exclude_uri(uri).await {
583 return Ok(None);
584 }
585
586 let config_guard = self.config.read().await;
587 let lsp_config = config_guard.clone();
588 drop(config_guard);
589
590 let file_config = if let Ok(file_path) = uri.to_file_path() {
592 self.resolve_config_for_file(&file_path).await
593 } else {
594 (*self.rumdl_config.read().await).clone()
596 };
597
598 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
600
601 let all_rules = rules::all_rules(&rumdl_config);
602 let flavor = rumdl_config.markdown_flavor();
603
604 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
606
607 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
609
610 let mut rules_with_warnings = std::collections::HashSet::new();
613 let mut fixed_text = text.to_string();
614
615 match lint(&fixed_text, &filtered_rules, false, flavor) {
616 Ok(warnings) => {
617 for warning in warnings {
618 if let Some(rule_name) = &warning.rule_name {
619 rules_with_warnings.insert(rule_name.clone());
620 }
621 }
622 }
623 Err(e) => {
624 log::warn!("Failed to lint document for auto-fix: {e}");
625 return Ok(None);
626 }
627 }
628
629 if rules_with_warnings.is_empty() {
631 return Ok(None);
632 }
633
634 let mut any_changes = false;
636
637 for rule in &filtered_rules {
638 if !rules_with_warnings.contains(rule.name()) {
640 continue;
641 }
642
643 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
644 match rule.fix(&ctx) {
645 Ok(new_text) => {
646 if new_text != fixed_text {
647 fixed_text = new_text;
648 any_changes = true;
649 }
650 }
651 Err(e) => {
652 let msg = e.to_string();
654 if !msg.contains("does not support automatic fixing") {
655 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
656 }
657 }
658 }
659 }
660
661 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
662 }
663
664 fn get_end_position(&self, text: &str) -> Position {
666 let mut line = 0u32;
667 let mut character = 0u32;
668
669 for ch in text.chars() {
670 if ch == '\n' {
671 line += 1;
672 character = 0;
673 } else {
674 character += 1;
675 }
676 }
677
678 Position { line, character }
679 }
680
681 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
683 let config_guard = self.config.read().await;
684 let lsp_config = config_guard.clone();
685 drop(config_guard);
686
687 let file_config = if let Ok(file_path) = uri.to_file_path() {
689 self.resolve_config_for_file(&file_path).await
690 } else {
691 (*self.rumdl_config.read().await).clone()
693 };
694
695 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
697
698 let all_rules = rules::all_rules(&rumdl_config);
699 let flavor = rumdl_config.markdown_flavor();
700
701 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
703
704 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
706
707 match crate::lint(text, &filtered_rules, false, flavor) {
708 Ok(warnings) => {
709 let mut actions = Vec::new();
710 let mut fixable_count = 0;
711
712 for warning in &warnings {
713 let warning_line = (warning.line.saturating_sub(1)) as u32;
715 if warning_line >= range.start.line && warning_line <= range.end.line {
716 let mut warning_actions = warning_to_code_actions(warning, uri, text);
718 actions.append(&mut warning_actions);
719
720 if warning.fix.is_some() {
721 fixable_count += 1;
722 }
723 }
724 }
725
726 if fixable_count > 1 {
728 let fixable_warnings: Vec<_> = warnings
731 .iter()
732 .filter(|w| {
733 if let Some(rule_name) = &w.rule_name {
734 filtered_rules
735 .iter()
736 .find(|r| r.name() == rule_name)
737 .map(|r| r.fix_capability() != FixCapability::Unfixable)
738 .unwrap_or(false)
739 } else {
740 false
741 }
742 })
743 .cloned()
744 .collect();
745
746 let total_fixable = fixable_warnings.len();
748
749 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
750 && fixed_content != text
751 {
752 let mut line = 0u32;
754 let mut character = 0u32;
755 for ch in text.chars() {
756 if ch == '\n' {
757 line += 1;
758 character = 0;
759 } else {
760 character += 1;
761 }
762 }
763
764 let fix_all_action = CodeAction {
765 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
766 kind: Some(CodeActionKind::QUICKFIX),
767 diagnostics: Some(Vec::new()),
768 edit: Some(WorkspaceEdit {
769 changes: Some(
770 [(
771 uri.clone(),
772 vec![TextEdit {
773 range: Range {
774 start: Position { line: 0, character: 0 },
775 end: Position { line, character },
776 },
777 new_text: fixed_content,
778 }],
779 )]
780 .into_iter()
781 .collect(),
782 ),
783 ..Default::default()
784 }),
785 command: None,
786 is_preferred: Some(true),
787 disabled: None,
788 data: None,
789 };
790
791 actions.insert(0, fix_all_action);
793 }
794 }
795
796 Ok(actions)
797 }
798 Err(e) => {
799 log::error!("Failed to get code actions: {e}");
800 Ok(Vec::new())
801 }
802 }
803 }
804
805 async fn load_configuration(&self, notify_client: bool) {
807 let config_guard = self.config.read().await;
808 let explicit_config_path = config_guard.config_path.clone();
809 drop(config_guard);
810
811 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
813 Ok(sourced_config) => {
814 let loaded_files = sourced_config.loaded_files.clone();
815 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
817
818 if !loaded_files.is_empty() {
819 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
820 log::info!("{message}");
821 if notify_client {
822 self.client.log_message(MessageType::INFO, &message).await;
823 }
824 } else {
825 log::info!("Using default rumdl configuration (no config files found)");
826 }
827 }
828 Err(e) => {
829 let message = format!("Failed to load rumdl config: {e}");
830 log::warn!("{message}");
831 if notify_client {
832 self.client.log_message(MessageType::WARNING, &message).await;
833 }
834 *self.rumdl_config.write().await = crate::config::Config::default();
836 }
837 }
838 }
839
840 async fn reload_configuration(&self) {
842 self.load_configuration(true).await;
843 }
844
845 fn load_config_for_lsp(
847 config_path: Option<&str>,
848 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
849 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
851 }
852
853 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
860 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
862
863 {
865 let cache = self.config_cache.read().await;
866 if let Some(entry) = cache.get(&search_dir) {
867 let source_owned: String; let source: &str = if entry.from_global_fallback {
869 "global/user fallback"
870 } else if let Some(path) = &entry.config_file {
871 source_owned = path.to_string_lossy().to_string();
872 &source_owned
873 } else {
874 "<unknown>"
875 };
876 log::debug!(
877 "Config cache hit for directory: {} (loaded from: {})",
878 search_dir.display(),
879 source
880 );
881 return entry.config.clone();
882 }
883 }
884
885 log::debug!(
887 "Config cache miss for directory: {}, searching for config...",
888 search_dir.display()
889 );
890
891 let workspace_root = {
893 let workspace_roots = self.workspace_roots.read().await;
894 workspace_roots
895 .iter()
896 .find(|root| search_dir.starts_with(root))
897 .map(|p| p.to_path_buf())
898 };
899
900 let mut current_dir = search_dir.clone();
902 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
903
904 loop {
905 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
907
908 for config_file_name in CONFIG_FILES {
909 let config_path = current_dir.join(config_file_name);
910 if config_path.exists() {
911 if *config_file_name == "pyproject.toml" {
913 if let Ok(content) = std::fs::read_to_string(&config_path) {
914 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
915 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
916 } else {
917 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
918 continue;
919 }
920 } else {
921 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
922 continue;
923 }
924 } else {
925 log::debug!("Found config file: {}", config_path.display());
926 }
927
928 if let Some(config_path_str) = config_path.to_str() {
930 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
931 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
932 break;
933 }
934 } else {
935 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
936 }
937 }
938 }
939
940 if found_config.is_some() {
941 break;
942 }
943
944 if let Some(ref root) = workspace_root
946 && ¤t_dir == root
947 {
948 log::debug!("Hit workspace root without finding config: {}", root.display());
949 break;
950 }
951
952 if let Some(parent) = current_dir.parent() {
954 current_dir = parent.to_path_buf();
955 } else {
956 break;
958 }
959 }
960
961 let (config, config_file) = if let Some((cfg, path)) = found_config {
963 (cfg, path)
964 } else {
965 log::debug!("No project config found; using global/user fallback config");
966 let fallback = self.rumdl_config.read().await.clone();
967 (fallback, None)
968 };
969
970 let from_global = config_file.is_none();
972 let entry = ConfigCacheEntry {
973 config: config.clone(),
974 config_file,
975 from_global_fallback: from_global,
976 };
977
978 self.config_cache.write().await.insert(search_dir, entry);
979
980 config
981 }
982}
983
984#[tower_lsp::async_trait]
985impl LanguageServer for RumdlLanguageServer {
986 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
987 log::info!("Initializing rumdl Language Server");
988
989 if let Some(options) = params.initialization_options
991 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
992 {
993 *self.config.write().await = config;
994 }
995
996 let supports_pull = params
999 .capabilities
1000 .text_document
1001 .as_ref()
1002 .and_then(|td| td.diagnostic.as_ref())
1003 .is_some();
1004
1005 if supports_pull {
1006 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1007 *self.client_supports_pull_diagnostics.write().await = true;
1008 } else {
1009 log::info!("Client does not support pull diagnostics - using push model");
1010 }
1011
1012 let mut roots = Vec::new();
1014 if let Some(workspace_folders) = params.workspace_folders {
1015 for folder in workspace_folders {
1016 if let Ok(path) = folder.uri.to_file_path() {
1017 log::info!("Workspace root: {}", path.display());
1018 roots.push(path);
1019 }
1020 }
1021 } else if let Some(root_uri) = params.root_uri
1022 && let Ok(path) = root_uri.to_file_path()
1023 {
1024 log::info!("Workspace root: {}", path.display());
1025 roots.push(path);
1026 }
1027 *self.workspace_roots.write().await = roots;
1028
1029 self.load_configuration(false).await;
1031
1032 Ok(InitializeResult {
1033 capabilities: ServerCapabilities {
1034 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1035 open_close: Some(true),
1036 change: Some(TextDocumentSyncKind::FULL),
1037 will_save: Some(false),
1038 will_save_wait_until: Some(true),
1039 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1040 include_text: Some(false),
1041 })),
1042 })),
1043 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1044 document_formatting_provider: Some(OneOf::Left(true)),
1045 document_range_formatting_provider: Some(OneOf::Left(true)),
1046 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1047 identifier: Some("rumdl".to_string()),
1048 inter_file_dependencies: true,
1049 workspace_diagnostics: true,
1050 work_done_progress_options: WorkDoneProgressOptions::default(),
1051 })),
1052 workspace: Some(WorkspaceServerCapabilities {
1053 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1054 supported: Some(true),
1055 change_notifications: Some(OneOf::Left(true)),
1056 }),
1057 file_operations: None,
1058 }),
1059 ..Default::default()
1060 },
1061 server_info: Some(ServerInfo {
1062 name: "rumdl".to_string(),
1063 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1064 }),
1065 })
1066 }
1067
1068 async fn initialized(&self, _: InitializedParams) {
1069 let version = env!("CARGO_PKG_VERSION");
1070
1071 let (binary_path, build_time) = std::env::current_exe()
1073 .ok()
1074 .map(|path| {
1075 let path_str = path.to_str().unwrap_or("unknown").to_string();
1076 let build_time = std::fs::metadata(&path)
1077 .ok()
1078 .and_then(|metadata| metadata.modified().ok())
1079 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1080 .and_then(|duration| {
1081 let secs = duration.as_secs();
1082 chrono::DateTime::from_timestamp(secs as i64, 0)
1083 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1084 })
1085 .unwrap_or_else(|| "unknown".to_string());
1086 (path_str, build_time)
1087 })
1088 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1089
1090 let working_dir = std::env::current_dir()
1091 .ok()
1092 .and_then(|p| p.to_str().map(|s| s.to_string()))
1093 .unwrap_or_else(|| "unknown".to_string());
1094
1095 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1096 log::info!("Working directory: {working_dir}");
1097
1098 self.client
1099 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1100 .await;
1101
1102 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1104 log::warn!("Failed to trigger initial workspace indexing");
1105 } else {
1106 log::info!("Triggered initial workspace indexing for cross-file analysis");
1107 }
1108
1109 let markdown_patterns = [
1112 "**/*.md",
1113 "**/*.markdown",
1114 "**/*.mdx",
1115 "**/*.mkd",
1116 "**/*.mkdn",
1117 "**/*.mdown",
1118 "**/*.mdwn",
1119 "**/*.qmd",
1120 "**/*.rmd",
1121 ];
1122 let watchers: Vec<_> = markdown_patterns
1123 .iter()
1124 .map(|pattern| FileSystemWatcher {
1125 glob_pattern: GlobPattern::String((*pattern).to_string()),
1126 kind: Some(WatchKind::all()),
1127 })
1128 .collect();
1129
1130 let registration = Registration {
1131 id: "markdown-watcher".to_string(),
1132 method: "workspace/didChangeWatchedFiles".to_string(),
1133 register_options: Some(
1134 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1135 ),
1136 };
1137
1138 if self.client.register_capability(vec![registration]).await.is_err() {
1139 log::debug!("Client does not support file watching capability");
1140 }
1141 }
1142
1143 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1144 let mut roots = self.workspace_roots.write().await;
1146
1147 for removed in ¶ms.event.removed {
1149 if let Ok(path) = removed.uri.to_file_path() {
1150 roots.retain(|r| r != &path);
1151 log::info!("Removed workspace root: {}", path.display());
1152 }
1153 }
1154
1155 for added in ¶ms.event.added {
1157 if let Ok(path) = added.uri.to_file_path()
1158 && !roots.contains(&path)
1159 {
1160 log::info!("Added workspace root: {}", path.display());
1161 roots.push(path);
1162 }
1163 }
1164 drop(roots);
1165
1166 self.config_cache.write().await.clear();
1168
1169 self.reload_configuration().await;
1171
1172 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1174 log::warn!("Failed to trigger workspace rescan after folder change");
1175 }
1176 }
1177
1178 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1179 log::debug!("Configuration changed: {:?}", params.settings);
1180
1181 let settings_value = params.settings;
1185
1186 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1188 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1189 } else {
1190 settings_value
1191 };
1192
1193 let mut config_applied = false;
1195 let mut warnings: Vec<String> = Vec::new();
1196
1197 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1201 && (rule_settings.disable.is_some()
1202 || rule_settings.enable.is_some()
1203 || rule_settings.line_length.is_some()
1204 || !rule_settings.rules.is_empty())
1205 {
1206 if let Some(ref disable) = rule_settings.disable {
1208 for rule in disable {
1209 if !Self::is_valid_rule_name(rule) {
1210 warnings.push(format!("Unknown rule in disable list: {rule}"));
1211 }
1212 }
1213 }
1214 if let Some(ref enable) = rule_settings.enable {
1215 for rule in enable {
1216 if !Self::is_valid_rule_name(rule) {
1217 warnings.push(format!("Unknown rule in enable list: {rule}"));
1218 }
1219 }
1220 }
1221 for rule_name in rule_settings.rules.keys() {
1223 if !Self::is_valid_rule_name(rule_name) {
1224 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1225 }
1226 }
1227
1228 log::info!("Applied rule settings from configuration (Neovim style)");
1229 let mut config = self.config.write().await;
1230 config.settings = Some(rule_settings);
1231 drop(config);
1232 config_applied = true;
1233 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1234 && (full_config.config_path.is_some()
1235 || full_config.enable_rules.is_some()
1236 || full_config.disable_rules.is_some()
1237 || full_config.settings.is_some()
1238 || !full_config.enable_linting
1239 || full_config.enable_auto_fix)
1240 {
1241 if let Some(ref rules) = full_config.enable_rules {
1243 for rule in rules {
1244 if !Self::is_valid_rule_name(rule) {
1245 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1246 }
1247 }
1248 }
1249 if let Some(ref rules) = full_config.disable_rules {
1250 for rule in rules {
1251 if !Self::is_valid_rule_name(rule) {
1252 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1253 }
1254 }
1255 }
1256
1257 log::info!("Applied full LSP configuration from settings");
1258 *self.config.write().await = full_config;
1259 config_applied = true;
1260 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1261 let mut config = self.config.write().await;
1264
1265 let mut rules = std::collections::HashMap::new();
1267 let mut disable = Vec::new();
1268 let mut enable = Vec::new();
1269 let mut line_length = None;
1270
1271 for (key, value) in obj {
1272 match key.as_str() {
1273 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1274 Ok(d) => {
1275 if d.len() > MAX_RULE_LIST_SIZE {
1276 warnings.push(format!(
1277 "Too many rules in 'disable' ({} > {}), truncating",
1278 d.len(),
1279 MAX_RULE_LIST_SIZE
1280 ));
1281 }
1282 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1283 if !Self::is_valid_rule_name(rule) {
1284 warnings.push(format!("Unknown rule in disable: {rule}"));
1285 }
1286 }
1287 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1288 }
1289 Err(_) => {
1290 warnings.push(format!(
1291 "Invalid 'disable' value: expected array of strings, got {value}"
1292 ));
1293 }
1294 },
1295 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1296 Ok(e) => {
1297 if e.len() > MAX_RULE_LIST_SIZE {
1298 warnings.push(format!(
1299 "Too many rules in 'enable' ({} > {}), truncating",
1300 e.len(),
1301 MAX_RULE_LIST_SIZE
1302 ));
1303 }
1304 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1305 if !Self::is_valid_rule_name(rule) {
1306 warnings.push(format!("Unknown rule in enable: {rule}"));
1307 }
1308 }
1309 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1310 }
1311 Err(_) => {
1312 warnings.push(format!(
1313 "Invalid 'enable' value: expected array of strings, got {value}"
1314 ));
1315 }
1316 },
1317 "lineLength" | "line_length" | "line-length" => {
1318 if let Some(l) = value.as_u64() {
1319 match usize::try_from(l) {
1320 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1321 Ok(len) => warnings.push(format!(
1322 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1323 )),
1324 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1325 }
1326 } else {
1327 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1328 }
1329 }
1330 _ if key.starts_with("MD") || key.starts_with("md") => {
1332 let normalized = key.to_uppercase();
1333 if !Self::is_valid_rule_name(&normalized) {
1334 warnings.push(format!("Unknown rule: {key}"));
1335 }
1336 rules.insert(normalized, value);
1337 }
1338 _ => {
1339 warnings.push(format!("Unknown configuration key: {key}"));
1341 }
1342 }
1343 }
1344
1345 let settings = LspRuleSettings {
1346 line_length,
1347 disable: if disable.is_empty() { None } else { Some(disable) },
1348 enable: if enable.is_empty() { None } else { Some(enable) },
1349 rules,
1350 };
1351
1352 log::info!("Applied Neovim-style rule settings (manual parse)");
1353 config.settings = Some(settings);
1354 drop(config);
1355 config_applied = true;
1356 } else {
1357 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1358 }
1359
1360 for warning in &warnings {
1362 log::warn!("{warning}");
1363 }
1364
1365 if !warnings.is_empty() {
1367 let message = if warnings.len() == 1 {
1368 format!("rumdl: {}", warnings[0])
1369 } else {
1370 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1371 };
1372 self.client.log_message(MessageType::WARNING, message).await;
1373 }
1374
1375 if !config_applied {
1376 log::debug!("No configuration changes applied");
1377 }
1378
1379 self.config_cache.write().await.clear();
1381
1382 let doc_list: Vec<_> = {
1384 let documents = self.documents.read().await;
1385 documents
1386 .iter()
1387 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1388 .collect()
1389 };
1390
1391 let tasks = doc_list.into_iter().map(|(uri, text)| {
1393 let server = self.clone();
1394 tokio::spawn(async move {
1395 server.update_diagnostics(uri, text).await;
1396 })
1397 });
1398
1399 let _ = join_all(tasks).await;
1401 }
1402
1403 async fn shutdown(&self) -> JsonRpcResult<()> {
1404 log::info!("Shutting down rumdl Language Server");
1405
1406 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1408
1409 Ok(())
1410 }
1411
1412 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1413 let uri = params.text_document.uri;
1414 let text = params.text_document.text;
1415 let version = params.text_document.version;
1416
1417 let entry = DocumentEntry {
1418 content: text.clone(),
1419 version: Some(version),
1420 from_disk: false,
1421 };
1422 self.documents.write().await.insert(uri.clone(), entry);
1423
1424 if let Ok(path) = uri.to_file_path() {
1426 let _ = self
1427 .update_tx
1428 .send(IndexUpdate::FileChanged {
1429 path,
1430 content: text.clone(),
1431 })
1432 .await;
1433 }
1434
1435 self.update_diagnostics(uri, text).await;
1436 }
1437
1438 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1439 let uri = params.text_document.uri;
1440 let version = params.text_document.version;
1441
1442 if let Some(change) = params.content_changes.into_iter().next() {
1443 let text = change.text;
1444
1445 let entry = DocumentEntry {
1446 content: text.clone(),
1447 version: Some(version),
1448 from_disk: false,
1449 };
1450 self.documents.write().await.insert(uri.clone(), entry);
1451
1452 if let Ok(path) = uri.to_file_path() {
1454 let _ = self
1455 .update_tx
1456 .send(IndexUpdate::FileChanged {
1457 path,
1458 content: text.clone(),
1459 })
1460 .await;
1461 }
1462
1463 self.update_diagnostics(uri, text).await;
1464 }
1465 }
1466
1467 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1468 let config_guard = self.config.read().await;
1469 let enable_auto_fix = config_guard.enable_auto_fix;
1470 drop(config_guard);
1471
1472 if !enable_auto_fix {
1473 return Ok(None);
1474 }
1475
1476 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1478 return Ok(None);
1479 };
1480
1481 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1483 Ok(Some(fixed_text)) => {
1484 Ok(Some(vec![TextEdit {
1486 range: Range {
1487 start: Position { line: 0, character: 0 },
1488 end: self.get_end_position(&text),
1489 },
1490 new_text: fixed_text,
1491 }]))
1492 }
1493 Ok(None) => Ok(None),
1494 Err(e) => {
1495 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1496 Ok(None)
1497 }
1498 }
1499 }
1500
1501 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1502 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1505 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1506 .await;
1507 }
1508 }
1509
1510 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1511 self.documents.write().await.remove(¶ms.text_document.uri);
1513
1514 self.client
1517 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1518 .await;
1519 }
1520
1521 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1522 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1524
1525 let mut config_changed = false;
1526
1527 for change in ¶ms.changes {
1528 if let Ok(path) = change.uri.to_file_path() {
1529 let file_name = path.file_name().and_then(|f| f.to_str());
1530 let extension = path.extension().and_then(|e| e.to_str());
1531
1532 if let Some(name) = file_name
1534 && CONFIG_FILES.contains(&name)
1535 && !config_changed
1536 {
1537 log::info!("Config file changed: {}, invalidating config cache", path.display());
1538
1539 let mut cache = self.config_cache.write().await;
1541 cache.retain(|_, entry| {
1542 if let Some(config_file) = &entry.config_file {
1543 config_file != &path
1544 } else {
1545 true
1546 }
1547 });
1548
1549 drop(cache);
1551 self.reload_configuration().await;
1552 config_changed = true;
1553 }
1554
1555 if let Some(ext) = extension
1557 && is_markdown_extension(ext)
1558 {
1559 match change.typ {
1560 FileChangeType::CREATED | FileChangeType::CHANGED => {
1561 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1563 let _ = self
1564 .update_tx
1565 .send(IndexUpdate::FileChanged {
1566 path: path.clone(),
1567 content,
1568 })
1569 .await;
1570 }
1571 }
1572 FileChangeType::DELETED => {
1573 let _ = self
1574 .update_tx
1575 .send(IndexUpdate::FileDeleted { path: path.clone() })
1576 .await;
1577 }
1578 _ => {}
1579 }
1580 }
1581 }
1582 }
1583
1584 if config_changed {
1586 let docs_to_update: Vec<(Url, String)> = {
1587 let docs = self.documents.read().await;
1588 docs.iter()
1589 .filter(|(_, entry)| !entry.from_disk)
1590 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1591 .collect()
1592 };
1593
1594 for (uri, text) in docs_to_update {
1595 self.update_diagnostics(uri, text).await;
1596 }
1597 }
1598 }
1599
1600 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1601 let uri = params.text_document.uri;
1602 let range = params.range;
1603
1604 if let Some(text) = self.get_document_content(&uri).await {
1605 match self.get_code_actions(&uri, &text, range).await {
1606 Ok(actions) => {
1607 let response: Vec<CodeActionOrCommand> =
1608 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1609 Ok(Some(response))
1610 }
1611 Err(e) => {
1612 log::error!("Failed to get code actions: {e}");
1613 Ok(None)
1614 }
1615 }
1616 } else {
1617 Ok(None)
1618 }
1619 }
1620
1621 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1622 log::debug!(
1627 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1628 params.range
1629 );
1630
1631 let formatting_params = DocumentFormattingParams {
1632 text_document: params.text_document,
1633 options: params.options,
1634 work_done_progress_params: params.work_done_progress_params,
1635 };
1636
1637 self.formatting(formatting_params).await
1638 }
1639
1640 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1641 let uri = params.text_document.uri;
1642
1643 log::debug!("Formatting request for: {uri}");
1644
1645 if let Some(text) = self.get_document_content(&uri).await {
1646 let config_guard = self.config.read().await;
1648 let lsp_config = config_guard.clone();
1649 drop(config_guard);
1650
1651 let file_config = if let Ok(file_path) = uri.to_file_path() {
1653 self.resolve_config_for_file(&file_path).await
1654 } else {
1655 self.rumdl_config.read().await.clone()
1657 };
1658
1659 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1661
1662 let all_rules = rules::all_rules(&rumdl_config);
1663 let flavor = rumdl_config.markdown_flavor();
1664
1665 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1667
1668 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1670
1671 match crate::lint(&text, &filtered_rules, false, flavor) {
1673 Ok(warnings) => {
1674 log::debug!(
1675 "Found {} warnings, {} with fixes",
1676 warnings.len(),
1677 warnings.iter().filter(|w| w.fix.is_some()).count()
1678 );
1679
1680 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1681 if has_fixes {
1682 let fixable_warnings: Vec<_> = warnings
1686 .iter()
1687 .filter(|w| {
1688 if let Some(rule_name) = &w.rule_name {
1689 filtered_rules
1690 .iter()
1691 .find(|r| r.name() == rule_name)
1692 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1693 .unwrap_or(false)
1694 } else {
1695 false
1696 }
1697 })
1698 .cloned()
1699 .collect();
1700
1701 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1702 Ok(fixed_content) => {
1703 if fixed_content != text {
1704 log::debug!("Returning formatting edits");
1705 let end_position = self.get_end_position(&text);
1706 let edit = TextEdit {
1707 range: Range {
1708 start: Position { line: 0, character: 0 },
1709 end: end_position,
1710 },
1711 new_text: fixed_content,
1712 };
1713 return Ok(Some(vec![edit]));
1714 }
1715 }
1716 Err(e) => {
1717 log::error!("Failed to apply fixes: {e}");
1718 }
1719 }
1720 }
1721 Ok(Some(Vec::new()))
1722 }
1723 Err(e) => {
1724 log::error!("Failed to format document: {e}");
1725 Ok(Some(Vec::new()))
1726 }
1727 }
1728 } else {
1729 log::warn!("Document not found: {uri}");
1730 Ok(None)
1731 }
1732 }
1733
1734 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1735 let uri = params.text_document.uri;
1736
1737 if let Some(text) = self.get_document_content(&uri).await {
1738 match self.lint_document(&uri, &text).await {
1739 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1740 RelatedFullDocumentDiagnosticReport {
1741 related_documents: None,
1742 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1743 result_id: None,
1744 items: diagnostics,
1745 },
1746 },
1747 ))),
1748 Err(e) => {
1749 log::error!("Failed to get diagnostics: {e}");
1750 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1751 RelatedFullDocumentDiagnosticReport {
1752 related_documents: None,
1753 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1754 result_id: None,
1755 items: Vec::new(),
1756 },
1757 },
1758 )))
1759 }
1760 }
1761 } else {
1762 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1763 RelatedFullDocumentDiagnosticReport {
1764 related_documents: None,
1765 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1766 result_id: None,
1767 items: Vec::new(),
1768 },
1769 },
1770 )))
1771 }
1772 }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777 use super::*;
1778 use crate::rule::LintWarning;
1779 use tower_lsp::LspService;
1780
1781 fn create_test_server() -> RumdlLanguageServer {
1782 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1783 service.inner().clone()
1784 }
1785
1786 #[test]
1787 fn test_is_valid_rule_name() {
1788 assert!(RumdlLanguageServer::is_valid_rule_name("MD001"));
1790 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"));
1827 assert!(!RumdlLanguageServer::is_valid_rule_name(""));
1828 assert!(!RumdlLanguageServer::is_valid_rule_name("MD"));
1829 assert!(!RumdlLanguageServer::is_valid_rule_name("MD0001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD1")); }
1832
1833 #[tokio::test]
1834 async fn test_server_creation() {
1835 let server = create_test_server();
1836
1837 let config = server.config.read().await;
1839 assert!(config.enable_linting);
1840 assert!(!config.enable_auto_fix);
1841 }
1842
1843 #[tokio::test]
1844 async fn test_lint_document() {
1845 let server = create_test_server();
1846
1847 let uri = Url::parse("file:///test.md").unwrap();
1849 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1850
1851 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1852
1853 assert!(!diagnostics.is_empty());
1855 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1856 }
1857
1858 #[tokio::test]
1859 async fn test_lint_document_disabled() {
1860 let server = create_test_server();
1861
1862 server.config.write().await.enable_linting = false;
1864
1865 let uri = Url::parse("file:///test.md").unwrap();
1866 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1867
1868 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1869
1870 assert!(diagnostics.is_empty());
1872 }
1873
1874 #[tokio::test]
1875 async fn test_get_code_actions() {
1876 let server = create_test_server();
1877
1878 let uri = Url::parse("file:///test.md").unwrap();
1879 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1880
1881 let range = Range {
1883 start: Position { line: 0, character: 0 },
1884 end: Position { line: 3, character: 21 },
1885 };
1886
1887 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1888
1889 assert!(!actions.is_empty());
1891 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1892 }
1893
1894 #[tokio::test]
1895 async fn test_get_code_actions_outside_range() {
1896 let server = create_test_server();
1897
1898 let uri = Url::parse("file:///test.md").unwrap();
1899 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1900
1901 let range = Range {
1903 start: Position { line: 0, character: 0 },
1904 end: Position { line: 0, character: 6 },
1905 };
1906
1907 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1908
1909 assert!(actions.is_empty());
1911 }
1912
1913 #[tokio::test]
1914 async fn test_document_storage() {
1915 let server = create_test_server();
1916
1917 let uri = Url::parse("file:///test.md").unwrap();
1918 let text = "# Test Document";
1919
1920 let entry = DocumentEntry {
1922 content: text.to_string(),
1923 version: Some(1),
1924 from_disk: false,
1925 };
1926 server.documents.write().await.insert(uri.clone(), entry);
1927
1928 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1930 assert_eq!(stored, Some(text.to_string()));
1931
1932 server.documents.write().await.remove(&uri);
1934
1935 let stored = server.documents.read().await.get(&uri).cloned();
1937 assert_eq!(stored, None);
1938 }
1939
1940 #[tokio::test]
1941 async fn test_configuration_loading() {
1942 let server = create_test_server();
1943
1944 server.load_configuration(false).await;
1946
1947 let rumdl_config = server.rumdl_config.read().await;
1950 drop(rumdl_config); }
1953
1954 #[tokio::test]
1955 async fn test_load_config_for_lsp() {
1956 let result = RumdlLanguageServer::load_config_for_lsp(None);
1958 assert!(result.is_ok());
1959
1960 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1962 assert!(result.is_err());
1963 }
1964
1965 #[tokio::test]
1966 async fn test_warning_conversion() {
1967 let warning = LintWarning {
1968 message: "Test warning".to_string(),
1969 line: 1,
1970 column: 1,
1971 end_line: 1,
1972 end_column: 10,
1973 severity: crate::rule::Severity::Warning,
1974 fix: None,
1975 rule_name: Some("MD001".to_string()),
1976 };
1977
1978 let diagnostic = warning_to_diagnostic(&warning);
1980 assert_eq!(diagnostic.message, "Test warning");
1981 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1982 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1983
1984 let uri = Url::parse("file:///test.md").unwrap();
1986 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1987 assert_eq!(actions.len(), 1);
1989 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1990 }
1991
1992 #[tokio::test]
1993 async fn test_multiple_documents() {
1994 let server = create_test_server();
1995
1996 let uri1 = Url::parse("file:///test1.md").unwrap();
1997 let uri2 = Url::parse("file:///test2.md").unwrap();
1998 let text1 = "# Document 1";
1999 let text2 = "# Document 2";
2000
2001 {
2003 let mut docs = server.documents.write().await;
2004 let entry1 = DocumentEntry {
2005 content: text1.to_string(),
2006 version: Some(1),
2007 from_disk: false,
2008 };
2009 let entry2 = DocumentEntry {
2010 content: text2.to_string(),
2011 version: Some(1),
2012 from_disk: false,
2013 };
2014 docs.insert(uri1.clone(), entry1);
2015 docs.insert(uri2.clone(), entry2);
2016 }
2017
2018 let docs = server.documents.read().await;
2020 assert_eq!(docs.len(), 2);
2021 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2022 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2023 }
2024
2025 #[tokio::test]
2026 async fn test_auto_fix_on_save() {
2027 let server = create_test_server();
2028
2029 {
2031 let mut config = server.config.write().await;
2032 config.enable_auto_fix = true;
2033 }
2034
2035 let uri = Url::parse("file:///test.md").unwrap();
2036 let text = "#Heading without space"; let entry = DocumentEntry {
2040 content: text.to_string(),
2041 version: Some(1),
2042 from_disk: false,
2043 };
2044 server.documents.write().await.insert(uri.clone(), entry);
2045
2046 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2048 assert!(fixed.is_some());
2049 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2051 }
2052
2053 #[tokio::test]
2054 async fn test_get_end_position() {
2055 let server = create_test_server();
2056
2057 let pos = server.get_end_position("Hello");
2059 assert_eq!(pos.line, 0);
2060 assert_eq!(pos.character, 5);
2061
2062 let pos = server.get_end_position("Hello\nWorld\nTest");
2064 assert_eq!(pos.line, 2);
2065 assert_eq!(pos.character, 4);
2066
2067 let pos = server.get_end_position("");
2069 assert_eq!(pos.line, 0);
2070 assert_eq!(pos.character, 0);
2071
2072 let pos = server.get_end_position("Hello\n");
2074 assert_eq!(pos.line, 1);
2075 assert_eq!(pos.character, 0);
2076 }
2077
2078 #[tokio::test]
2079 async fn test_empty_document_handling() {
2080 let server = create_test_server();
2081
2082 let uri = Url::parse("file:///empty.md").unwrap();
2083 let text = "";
2084
2085 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2087 assert!(diagnostics.is_empty());
2088
2089 let range = Range {
2091 start: Position { line: 0, character: 0 },
2092 end: Position { line: 0, character: 0 },
2093 };
2094 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2095 assert!(actions.is_empty());
2096 }
2097
2098 #[tokio::test]
2099 async fn test_config_update() {
2100 let server = create_test_server();
2101
2102 {
2104 let mut config = server.config.write().await;
2105 config.enable_auto_fix = true;
2106 config.config_path = Some("/custom/path.toml".to_string());
2107 }
2108
2109 let config = server.config.read().await;
2111 assert!(config.enable_auto_fix);
2112 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2113 }
2114
2115 #[tokio::test]
2116 async fn test_document_formatting() {
2117 let server = create_test_server();
2118 let uri = Url::parse("file:///test.md").unwrap();
2119 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2120
2121 let entry = DocumentEntry {
2123 content: text.to_string(),
2124 version: Some(1),
2125 from_disk: false,
2126 };
2127 server.documents.write().await.insert(uri.clone(), entry);
2128
2129 let params = DocumentFormattingParams {
2131 text_document: TextDocumentIdentifier { uri: uri.clone() },
2132 options: FormattingOptions {
2133 tab_size: 4,
2134 insert_spaces: true,
2135 properties: HashMap::new(),
2136 trim_trailing_whitespace: Some(true),
2137 insert_final_newline: Some(true),
2138 trim_final_newlines: Some(true),
2139 },
2140 work_done_progress_params: WorkDoneProgressParams::default(),
2141 };
2142
2143 let result = server.formatting(params).await.unwrap();
2145
2146 assert!(result.is_some());
2148 let edits = result.unwrap();
2149 assert!(!edits.is_empty());
2150
2151 let edit = &edits[0];
2153 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
2156 assert_eq!(edit.new_text, expected);
2157 }
2158
2159 #[tokio::test]
2162 async fn test_unfixable_rules_excluded_from_formatting() {
2163 let server = create_test_server();
2164 let uri = Url::parse("file:///test.md").unwrap();
2165
2166 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2168
2169 let entry = DocumentEntry {
2171 content: text.to_string(),
2172 version: Some(1),
2173 from_disk: false,
2174 };
2175 server.documents.write().await.insert(uri.clone(), entry);
2176
2177 let format_params = DocumentFormattingParams {
2179 text_document: TextDocumentIdentifier { uri: uri.clone() },
2180 options: FormattingOptions {
2181 tab_size: 4,
2182 insert_spaces: true,
2183 properties: HashMap::new(),
2184 trim_trailing_whitespace: Some(true),
2185 insert_final_newline: Some(true),
2186 trim_final_newlines: Some(true),
2187 },
2188 work_done_progress_params: WorkDoneProgressParams::default(),
2189 };
2190
2191 let format_result = server.formatting(format_params).await.unwrap();
2192 assert!(format_result.is_some(), "Should return formatting edits");
2193
2194 let edits = format_result.unwrap();
2195 assert!(!edits.is_empty(), "Should have formatting edits");
2196
2197 let formatted = &edits[0].new_text;
2198 assert!(
2199 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2200 "HTML should be preserved during formatting (Unfixable rule)"
2201 );
2202 assert!(
2203 !formatted.contains("spaces "),
2204 "Trailing spaces should be removed (fixable rule)"
2205 );
2206
2207 let range = Range {
2209 start: Position { line: 0, character: 0 },
2210 end: Position { line: 10, character: 0 },
2211 };
2212
2213 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2214
2215 let html_fix_actions: Vec<_> = code_actions
2217 .iter()
2218 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2219 .collect();
2220
2221 assert!(
2222 !html_fix_actions.is_empty(),
2223 "Quick Fix actions should be available for HTML (Unfixable rules)"
2224 );
2225
2226 let fix_all_actions: Vec<_> = code_actions
2228 .iter()
2229 .filter(|action| action.title.contains("Fix all"))
2230 .collect();
2231
2232 if let Some(fix_all_action) = fix_all_actions.first()
2233 && let Some(ref edit) = fix_all_action.edit
2234 && let Some(ref changes) = edit.changes
2235 && let Some(text_edits) = changes.get(&uri)
2236 && let Some(text_edit) = text_edits.first()
2237 {
2238 let fixed_all = &text_edit.new_text;
2239 assert!(
2240 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2241 "Fix All should preserve HTML (Unfixable rules)"
2242 );
2243 assert!(
2244 !fixed_all.contains("spaces "),
2245 "Fix All should remove trailing spaces (fixable rules)"
2246 );
2247 }
2248 }
2249
2250 #[tokio::test]
2252 async fn test_resolve_config_for_file_multi_root() {
2253 use std::fs;
2254 use tempfile::tempdir;
2255
2256 let temp_dir = tempdir().unwrap();
2257 let temp_path = temp_dir.path();
2258
2259 let project_a = temp_path.join("project_a");
2261 let project_a_docs = project_a.join("docs");
2262 fs::create_dir_all(&project_a_docs).unwrap();
2263
2264 let config_a = project_a.join(".rumdl.toml");
2265 fs::write(
2266 &config_a,
2267 r#"
2268[global]
2269
2270[MD013]
2271line_length = 60
2272"#,
2273 )
2274 .unwrap();
2275
2276 let project_b = temp_path.join("project_b");
2278 fs::create_dir(&project_b).unwrap();
2279
2280 let config_b = project_b.join(".rumdl.toml");
2281 fs::write(
2282 &config_b,
2283 r#"
2284[global]
2285
2286[MD013]
2287line_length = 120
2288"#,
2289 )
2290 .unwrap();
2291
2292 let server = create_test_server();
2294
2295 {
2297 let mut roots = server.workspace_roots.write().await;
2298 roots.push(project_a.clone());
2299 roots.push(project_b.clone());
2300 }
2301
2302 let file_a = project_a_docs.join("test.md");
2304 fs::write(&file_a, "# Test A\n").unwrap();
2305
2306 let config_for_a = server.resolve_config_for_file(&file_a).await;
2307 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2308 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2309
2310 let file_b = project_b.join("test.md");
2312 fs::write(&file_b, "# Test B\n").unwrap();
2313
2314 let config_for_b = server.resolve_config_for_file(&file_b).await;
2315 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2316 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2317 }
2318
2319 #[tokio::test]
2321 async fn test_config_resolution_respects_workspace_boundaries() {
2322 use std::fs;
2323 use tempfile::tempdir;
2324
2325 let temp_dir = tempdir().unwrap();
2326 let temp_path = temp_dir.path();
2327
2328 let parent_config = temp_path.join(".rumdl.toml");
2330 fs::write(
2331 &parent_config,
2332 r#"
2333[global]
2334
2335[MD013]
2336line_length = 80
2337"#,
2338 )
2339 .unwrap();
2340
2341 let workspace_root = temp_path.join("workspace");
2343 let workspace_subdir = workspace_root.join("subdir");
2344 fs::create_dir_all(&workspace_subdir).unwrap();
2345
2346 let workspace_config = workspace_root.join(".rumdl.toml");
2347 fs::write(
2348 &workspace_config,
2349 r#"
2350[global]
2351
2352[MD013]
2353line_length = 100
2354"#,
2355 )
2356 .unwrap();
2357
2358 let server = create_test_server();
2359
2360 {
2362 let mut roots = server.workspace_roots.write().await;
2363 roots.push(workspace_root.clone());
2364 }
2365
2366 let test_file = workspace_subdir.join("deep").join("test.md");
2368 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2369 fs::write(&test_file, "# Test\n").unwrap();
2370
2371 let config = server.resolve_config_for_file(&test_file).await;
2372 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2373
2374 assert_eq!(
2376 line_length,
2377 Some(100),
2378 "Should find workspace config, not parent config outside workspace"
2379 );
2380 }
2381
2382 #[tokio::test]
2384 async fn test_config_cache_hit() {
2385 use std::fs;
2386 use tempfile::tempdir;
2387
2388 let temp_dir = tempdir().unwrap();
2389 let temp_path = temp_dir.path();
2390
2391 let project = temp_path.join("project");
2392 fs::create_dir(&project).unwrap();
2393
2394 let config_file = project.join(".rumdl.toml");
2395 fs::write(
2396 &config_file,
2397 r#"
2398[global]
2399
2400[MD013]
2401line_length = 75
2402"#,
2403 )
2404 .unwrap();
2405
2406 let server = create_test_server();
2407 {
2408 let mut roots = server.workspace_roots.write().await;
2409 roots.push(project.clone());
2410 }
2411
2412 let test_file = project.join("test.md");
2413 fs::write(&test_file, "# Test\n").unwrap();
2414
2415 let config1 = server.resolve_config_for_file(&test_file).await;
2417 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2418 assert_eq!(line_length1, Some(75));
2419
2420 {
2422 let cache = server.config_cache.read().await;
2423 let search_dir = test_file.parent().unwrap();
2424 assert!(
2425 cache.contains_key(search_dir),
2426 "Cache should be populated after first call"
2427 );
2428 }
2429
2430 let config2 = server.resolve_config_for_file(&test_file).await;
2432 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2433 assert_eq!(line_length2, Some(75));
2434 }
2435
2436 #[tokio::test]
2438 async fn test_nested_directory_config_search() {
2439 use std::fs;
2440 use tempfile::tempdir;
2441
2442 let temp_dir = tempdir().unwrap();
2443 let temp_path = temp_dir.path();
2444
2445 let project = temp_path.join("project");
2446 fs::create_dir(&project).unwrap();
2447
2448 let config = project.join(".rumdl.toml");
2450 fs::write(
2451 &config,
2452 r#"
2453[global]
2454
2455[MD013]
2456line_length = 110
2457"#,
2458 )
2459 .unwrap();
2460
2461 let deep_dir = project.join("src").join("docs").join("guides");
2463 fs::create_dir_all(&deep_dir).unwrap();
2464 let deep_file = deep_dir.join("test.md");
2465 fs::write(&deep_file, "# Test\n").unwrap();
2466
2467 let server = create_test_server();
2468 {
2469 let mut roots = server.workspace_roots.write().await;
2470 roots.push(project.clone());
2471 }
2472
2473 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2474 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2475
2476 assert_eq!(
2477 line_length,
2478 Some(110),
2479 "Should find config by searching upward from deep directory"
2480 );
2481 }
2482
2483 #[tokio::test]
2485 async fn test_fallback_to_default_config() {
2486 use std::fs;
2487 use tempfile::tempdir;
2488
2489 let temp_dir = tempdir().unwrap();
2490 let temp_path = temp_dir.path();
2491
2492 let project = temp_path.join("project");
2493 fs::create_dir(&project).unwrap();
2494
2495 let test_file = project.join("test.md");
2498 fs::write(&test_file, "# Test\n").unwrap();
2499
2500 let server = create_test_server();
2501 {
2502 let mut roots = server.workspace_roots.write().await;
2503 roots.push(project.clone());
2504 }
2505
2506 let config = server.resolve_config_for_file(&test_file).await;
2507
2508 assert_eq!(
2510 config.global.line_length.get(),
2511 80,
2512 "Should fall back to default config when no config file found"
2513 );
2514 }
2515
2516 #[tokio::test]
2518 async fn test_config_priority_closer_wins() {
2519 use std::fs;
2520 use tempfile::tempdir;
2521
2522 let temp_dir = tempdir().unwrap();
2523 let temp_path = temp_dir.path();
2524
2525 let project = temp_path.join("project");
2526 fs::create_dir(&project).unwrap();
2527
2528 let parent_config = project.join(".rumdl.toml");
2530 fs::write(
2531 &parent_config,
2532 r#"
2533[global]
2534
2535[MD013]
2536line_length = 100
2537"#,
2538 )
2539 .unwrap();
2540
2541 let subdir = project.join("subdir");
2543 fs::create_dir(&subdir).unwrap();
2544
2545 let subdir_config = subdir.join(".rumdl.toml");
2546 fs::write(
2547 &subdir_config,
2548 r#"
2549[global]
2550
2551[MD013]
2552line_length = 50
2553"#,
2554 )
2555 .unwrap();
2556
2557 let server = create_test_server();
2558 {
2559 let mut roots = server.workspace_roots.write().await;
2560 roots.push(project.clone());
2561 }
2562
2563 let test_file = subdir.join("test.md");
2565 fs::write(&test_file, "# Test\n").unwrap();
2566
2567 let config = server.resolve_config_for_file(&test_file).await;
2568 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2569
2570 assert_eq!(
2571 line_length,
2572 Some(50),
2573 "Closer config (subdir) should override parent config"
2574 );
2575 }
2576
2577 #[tokio::test]
2583 async fn test_issue_131_pyproject_without_rumdl_section() {
2584 use std::fs;
2585 use tempfile::tempdir;
2586
2587 let parent_dir = tempdir().unwrap();
2589
2590 let project_dir = parent_dir.path().join("project");
2592 fs::create_dir(&project_dir).unwrap();
2593
2594 fs::write(
2596 project_dir.join("pyproject.toml"),
2597 r#"
2598[project]
2599name = "test-project"
2600version = "0.1.0"
2601"#,
2602 )
2603 .unwrap();
2604
2605 fs::write(
2608 parent_dir.path().join(".rumdl.toml"),
2609 r#"
2610[global]
2611disable = ["MD013"]
2612"#,
2613 )
2614 .unwrap();
2615
2616 let test_file = project_dir.join("test.md");
2617 fs::write(&test_file, "# Test\n").unwrap();
2618
2619 let server = create_test_server();
2620
2621 {
2623 let mut roots = server.workspace_roots.write().await;
2624 roots.push(parent_dir.path().to_path_buf());
2625 }
2626
2627 let config = server.resolve_config_for_file(&test_file).await;
2629
2630 assert!(
2633 config.global.disable.contains(&"MD013".to_string()),
2634 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2635 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2636 );
2637
2638 let cache = server.config_cache.read().await;
2641 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2642
2643 assert!(
2644 cache_entry.config_file.is_some(),
2645 "Should have found a config file (parent .rumdl.toml)"
2646 );
2647
2648 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2649 assert!(
2650 found_config_path.ends_with(".rumdl.toml"),
2651 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2652 );
2653 assert!(
2654 found_config_path.parent().unwrap() == parent_dir.path(),
2655 "Should have loaded config from parent directory, not project_dir"
2656 );
2657 }
2658
2659 #[tokio::test]
2664 async fn test_issue_131_pyproject_with_rumdl_section() {
2665 use std::fs;
2666 use tempfile::tempdir;
2667
2668 let parent_dir = tempdir().unwrap();
2670
2671 let project_dir = parent_dir.path().join("project");
2673 fs::create_dir(&project_dir).unwrap();
2674
2675 fs::write(
2677 project_dir.join("pyproject.toml"),
2678 r#"
2679[project]
2680name = "test-project"
2681
2682[tool.rumdl.global]
2683disable = ["MD033"]
2684"#,
2685 )
2686 .unwrap();
2687
2688 fs::write(
2690 parent_dir.path().join(".rumdl.toml"),
2691 r#"
2692[global]
2693disable = ["MD041"]
2694"#,
2695 )
2696 .unwrap();
2697
2698 let test_file = project_dir.join("test.md");
2699 fs::write(&test_file, "# Test\n").unwrap();
2700
2701 let server = create_test_server();
2702
2703 {
2705 let mut roots = server.workspace_roots.write().await;
2706 roots.push(parent_dir.path().to_path_buf());
2707 }
2708
2709 let config = server.resolve_config_for_file(&test_file).await;
2711
2712 assert!(
2714 config.global.disable.contains(&"MD033".to_string()),
2715 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2716 Expected MD033 from project_dir pyproject.toml to be disabled."
2717 );
2718
2719 assert!(
2721 !config.global.disable.contains(&"MD041".to_string()),
2722 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2723 );
2724
2725 let cache = server.config_cache.read().await;
2727 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2728
2729 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2730
2731 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2732 assert!(
2733 found_config_path.ends_with("pyproject.toml"),
2734 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2735 );
2736 assert!(
2737 found_config_path.parent().unwrap() == project_dir,
2738 "Should have loaded pyproject.toml from project_dir, not parent"
2739 );
2740 }
2741
2742 #[tokio::test]
2747 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2748 use std::fs;
2749 use tempfile::tempdir;
2750
2751 let temp_dir = tempdir().unwrap();
2752
2753 fs::write(
2755 temp_dir.path().join("pyproject.toml"),
2756 r#"
2757[project]
2758name = "test-project"
2759
2760[tool.rumdl.global]
2761disable = ["MD022"]
2762"#,
2763 )
2764 .unwrap();
2765
2766 let test_file = temp_dir.path().join("test.md");
2767 fs::write(&test_file, "# Test\n").unwrap();
2768
2769 let server = create_test_server();
2770
2771 {
2773 let mut roots = server.workspace_roots.write().await;
2774 roots.push(temp_dir.path().to_path_buf());
2775 }
2776
2777 let config = server.resolve_config_for_file(&test_file).await;
2779
2780 assert!(
2782 config.global.disable.contains(&"MD022".to_string()),
2783 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2784 );
2785
2786 let cache = server.config_cache.read().await;
2788 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2789 assert!(
2790 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2791 "Should have loaded pyproject.toml"
2792 );
2793 }
2794
2795 #[tokio::test]
2800 async fn test_issue_182_pull_diagnostics_capability_default() {
2801 let server = create_test_server();
2802
2803 assert!(
2805 !*server.client_supports_pull_diagnostics.read().await,
2806 "Default should be false - push diagnostics by default"
2807 );
2808 }
2809
2810 #[tokio::test]
2812 async fn test_issue_182_pull_diagnostics_flag_update() {
2813 let server = create_test_server();
2814
2815 *server.client_supports_pull_diagnostics.write().await = true;
2817
2818 assert!(
2819 *server.client_supports_pull_diagnostics.read().await,
2820 "Flag should be settable to true"
2821 );
2822 }
2823
2824 #[tokio::test]
2828 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2829 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2830
2831 let caps_with_diagnostic = ClientCapabilities {
2833 text_document: Some(TextDocumentClientCapabilities {
2834 diagnostic: Some(DiagnosticClientCapabilities {
2835 dynamic_registration: Some(true),
2836 related_document_support: Some(false),
2837 }),
2838 ..Default::default()
2839 }),
2840 ..Default::default()
2841 };
2842
2843 let supports_pull = caps_with_diagnostic
2845 .text_document
2846 .as_ref()
2847 .and_then(|td| td.diagnostic.as_ref())
2848 .is_some();
2849
2850 assert!(supports_pull, "Should detect pull diagnostic support");
2851 }
2852
2853 #[tokio::test]
2855 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2856 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2857
2858 let caps_without_diagnostic = ClientCapabilities {
2860 text_document: Some(TextDocumentClientCapabilities {
2861 diagnostic: None, ..Default::default()
2863 }),
2864 ..Default::default()
2865 };
2866
2867 let supports_pull = caps_without_diagnostic
2869 .text_document
2870 .as_ref()
2871 .and_then(|td| td.diagnostic.as_ref())
2872 .is_some();
2873
2874 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2875 }
2876
2877 #[tokio::test]
2879 async fn test_issue_182_capability_detection_no_text_document() {
2880 use tower_lsp::lsp_types::ClientCapabilities;
2881
2882 let caps_no_text_doc = ClientCapabilities {
2884 text_document: None,
2885 ..Default::default()
2886 };
2887
2888 let supports_pull = caps_no_text_doc
2890 .text_document
2891 .as_ref()
2892 .and_then(|td| td.diagnostic.as_ref())
2893 .is_some();
2894
2895 assert!(
2896 !supports_pull,
2897 "Should NOT detect pull diagnostic support when text_document is None"
2898 );
2899 }
2900
2901 #[test]
2902 fn test_resource_limit_constants() {
2903 assert_eq!(MAX_RULE_LIST_SIZE, 100);
2905 assert_eq!(MAX_LINE_LENGTH, 10_000);
2906 }
2907
2908 #[test]
2909 fn test_is_valid_rule_name_zero_alloc() {
2910 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")); }
2926}