1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use anyhow::Result;
11use tokio::sync::RwLock;
12use tower_lsp::jsonrpc::Result as JsonRpcResult;
13use tower_lsp::lsp_types::*;
14use tower_lsp::{Client, LanguageServer};
15
16use crate::config::Config;
17use crate::lint;
18use crate::lsp::types::{RumdlLspConfig, warning_to_code_actions, warning_to_diagnostic};
19use crate::rule::{FixCapability, Rule};
20use crate::rules;
21
22#[derive(Clone, Debug, PartialEq)]
24struct DocumentEntry {
25 content: String,
27 version: Option<i32>,
29 from_disk: bool,
31}
32
33#[derive(Clone, Debug)]
35pub(crate) struct ConfigCacheEntry {
36 pub(crate) config: Config,
38 pub(crate) config_file: Option<PathBuf>,
40 pub(crate) from_global_fallback: bool,
42}
43
44#[derive(Clone)]
53pub struct RumdlLanguageServer {
54 client: Client,
55 config: Arc<RwLock<RumdlLspConfig>>,
57 #[cfg_attr(test, allow(dead_code))]
59 pub(crate) rumdl_config: Arc<RwLock<Config>>,
60 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
62 #[cfg_attr(test, allow(dead_code))]
64 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
65 #[cfg_attr(test, allow(dead_code))]
68 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
69}
70
71impl RumdlLanguageServer {
72 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
73 let mut initial_config = RumdlLspConfig::default();
75 if let Some(path) = cli_config_path {
76 initial_config.config_path = Some(path.to_string());
77 }
78
79 Self {
80 client,
81 config: Arc::new(RwLock::new(initial_config)),
82 rumdl_config: Arc::new(RwLock::new(Config::default())),
83 documents: Arc::new(RwLock::new(HashMap::new())),
84 workspace_roots: Arc::new(RwLock::new(Vec::new())),
85 config_cache: Arc::new(RwLock::new(HashMap::new())),
86 }
87 }
88
89 async fn get_document_content(&self, uri: &Url) -> Option<String> {
95 {
97 let docs = self.documents.read().await;
98 if let Some(entry) = docs.get(uri) {
99 return Some(entry.content.clone());
100 }
101 }
102
103 if let Ok(path) = uri.to_file_path() {
105 if let Ok(content) = tokio::fs::read_to_string(&path).await {
106 let entry = DocumentEntry {
108 content: content.clone(),
109 version: None,
110 from_disk: true,
111 };
112
113 let mut docs = self.documents.write().await;
114 docs.insert(uri.clone(), entry);
115
116 log::debug!("Loaded document from disk and cached: {uri}");
117 return Some(content);
118 } else {
119 log::debug!("Failed to read file from disk: {uri}");
120 }
121 }
122
123 None
124 }
125
126 fn apply_lsp_config_overrides(
128 &self,
129 mut filtered_rules: Vec<Box<dyn Rule>>,
130 lsp_config: &RumdlLspConfig,
131 ) -> Vec<Box<dyn Rule>> {
132 if let Some(enable) = &lsp_config.enable_rules
134 && !enable.is_empty()
135 {
136 let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
137 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
138 }
139
140 if let Some(disable) = &lsp_config.disable_rules
142 && !disable.is_empty()
143 {
144 let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
145 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
146 }
147
148 filtered_rules
149 }
150
151 async fn should_exclude_uri(&self, uri: &Url) -> bool {
153 let file_path = match uri.to_file_path() {
155 Ok(path) => path,
156 Err(_) => return false, };
158
159 let rumdl_config = self.resolve_config_for_file(&file_path).await;
161 let exclude_patterns = &rumdl_config.global.exclude;
162
163 if exclude_patterns.is_empty() {
165 return false;
166 }
167
168 let path_to_check = if file_path.is_absolute() {
171 if let Ok(cwd) = std::env::current_dir() {
173 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
175 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
176 relative.to_string_lossy().to_string()
177 } else {
178 file_path.to_string_lossy().to_string()
180 }
181 } else {
182 file_path.to_string_lossy().to_string()
184 }
185 } else {
186 file_path.to_string_lossy().to_string()
187 }
188 } else {
189 file_path.to_string_lossy().to_string()
191 };
192
193 for pattern in exclude_patterns {
195 if let Ok(glob) = globset::Glob::new(pattern) {
196 let matcher = glob.compile_matcher();
197 if matcher.is_match(&path_to_check) {
198 log::debug!("Excluding file from LSP linting: {path_to_check}");
199 return true;
200 }
201 }
202 }
203
204 false
205 }
206
207 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
209 let config_guard = self.config.read().await;
210
211 if !config_guard.enable_linting {
213 return Ok(Vec::new());
214 }
215
216 let lsp_config = config_guard.clone();
217 drop(config_guard); if self.should_exclude_uri(uri).await {
221 return Ok(Vec::new());
222 }
223
224 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
226 self.resolve_config_for_file(&file_path).await
227 } else {
228 (*self.rumdl_config.read().await).clone()
230 };
231
232 let all_rules = rules::all_rules(&rumdl_config);
233 let flavor = rumdl_config.markdown_flavor();
234
235 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
237
238 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
240
241 match crate::lint(text, &filtered_rules, false, flavor) {
243 Ok(warnings) => {
244 let diagnostics = warnings.iter().map(warning_to_diagnostic).collect();
245 Ok(diagnostics)
246 }
247 Err(e) => {
248 log::error!("Failed to lint document {uri}: {e}");
249 Ok(Vec::new())
250 }
251 }
252 }
253
254 async fn update_diagnostics(&self, uri: Url, text: String) {
256 let version = {
258 let docs = self.documents.read().await;
259 docs.get(&uri).and_then(|entry| entry.version)
260 };
261
262 match self.lint_document(&uri, &text).await {
263 Ok(diagnostics) => {
264 self.client.publish_diagnostics(uri, diagnostics, version).await;
265 }
266 Err(e) => {
267 log::error!("Failed to update diagnostics: {e}");
268 }
269 }
270 }
271
272 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
274 if self.should_exclude_uri(uri).await {
276 return Ok(None);
277 }
278
279 let config_guard = self.config.read().await;
280 let lsp_config = config_guard.clone();
281 drop(config_guard);
282
283 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
285 self.resolve_config_for_file(&file_path).await
286 } else {
287 (*self.rumdl_config.read().await).clone()
289 };
290
291 let all_rules = rules::all_rules(&rumdl_config);
292 let flavor = rumdl_config.markdown_flavor();
293
294 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
296
297 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
299
300 let mut rules_with_warnings = std::collections::HashSet::new();
303 let mut fixed_text = text.to_string();
304
305 match lint(&fixed_text, &filtered_rules, false, flavor) {
306 Ok(warnings) => {
307 for warning in warnings {
308 if let Some(rule_name) = &warning.rule_name {
309 rules_with_warnings.insert(rule_name.clone());
310 }
311 }
312 }
313 Err(e) => {
314 log::warn!("Failed to lint document for auto-fix: {e}");
315 return Ok(None);
316 }
317 }
318
319 if rules_with_warnings.is_empty() {
321 return Ok(None);
322 }
323
324 let mut any_changes = false;
326
327 for rule in &filtered_rules {
328 if !rules_with_warnings.contains(rule.name()) {
330 continue;
331 }
332
333 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
334 match rule.fix(&ctx) {
335 Ok(new_text) => {
336 if new_text != fixed_text {
337 fixed_text = new_text;
338 any_changes = true;
339 }
340 }
341 Err(e) => {
342 let msg = e.to_string();
344 if !msg.contains("does not support automatic fixing") {
345 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
346 }
347 }
348 }
349 }
350
351 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
352 }
353
354 fn get_end_position(&self, text: &str) -> Position {
356 let mut line = 0u32;
357 let mut character = 0u32;
358
359 for ch in text.chars() {
360 if ch == '\n' {
361 line += 1;
362 character = 0;
363 } else {
364 character += 1;
365 }
366 }
367
368 Position { line, character }
369 }
370
371 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
373 let config_guard = self.config.read().await;
374 let lsp_config = config_guard.clone();
375 drop(config_guard);
376
377 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
379 self.resolve_config_for_file(&file_path).await
380 } else {
381 (*self.rumdl_config.read().await).clone()
383 };
384
385 let all_rules = rules::all_rules(&rumdl_config);
386 let flavor = rumdl_config.markdown_flavor();
387
388 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
390
391 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
393
394 match crate::lint(text, &filtered_rules, false, flavor) {
395 Ok(warnings) => {
396 let mut actions = Vec::new();
397 let mut fixable_count = 0;
398
399 for warning in &warnings {
400 let warning_line = (warning.line.saturating_sub(1)) as u32;
402 if warning_line >= range.start.line && warning_line <= range.end.line {
403 let mut warning_actions = warning_to_code_actions(warning, uri, text);
405 actions.append(&mut warning_actions);
406
407 if warning.fix.is_some() {
408 fixable_count += 1;
409 }
410 }
411 }
412
413 if fixable_count > 1 {
415 let fixable_warnings: Vec<_> = warnings
418 .iter()
419 .filter(|w| {
420 if let Some(rule_name) = &w.rule_name {
421 filtered_rules
422 .iter()
423 .find(|r| r.name() == rule_name)
424 .map(|r| r.fix_capability() != FixCapability::Unfixable)
425 .unwrap_or(false)
426 } else {
427 false
428 }
429 })
430 .cloned()
431 .collect();
432
433 let total_fixable = fixable_warnings.len();
435
436 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
437 && fixed_content != text
438 {
439 let mut line = 0u32;
441 let mut character = 0u32;
442 for ch in text.chars() {
443 if ch == '\n' {
444 line += 1;
445 character = 0;
446 } else {
447 character += 1;
448 }
449 }
450
451 let fix_all_action = CodeAction {
452 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
453 kind: Some(CodeActionKind::QUICKFIX),
454 diagnostics: Some(Vec::new()),
455 edit: Some(WorkspaceEdit {
456 changes: Some(
457 [(
458 uri.clone(),
459 vec![TextEdit {
460 range: Range {
461 start: Position { line: 0, character: 0 },
462 end: Position { line, character },
463 },
464 new_text: fixed_content,
465 }],
466 )]
467 .into_iter()
468 .collect(),
469 ),
470 ..Default::default()
471 }),
472 command: None,
473 is_preferred: Some(true),
474 disabled: None,
475 data: None,
476 };
477
478 actions.insert(0, fix_all_action);
480 }
481 }
482
483 Ok(actions)
484 }
485 Err(e) => {
486 log::error!("Failed to get code actions: {e}");
487 Ok(Vec::new())
488 }
489 }
490 }
491
492 async fn load_configuration(&self, notify_client: bool) {
494 let config_guard = self.config.read().await;
495 let explicit_config_path = config_guard.config_path.clone();
496 drop(config_guard);
497
498 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
500 Ok(sourced_config) => {
501 let loaded_files = sourced_config.loaded_files.clone();
502 *self.rumdl_config.write().await = sourced_config.into();
503
504 if !loaded_files.is_empty() {
505 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
506 log::info!("{message}");
507 if notify_client {
508 self.client.log_message(MessageType::INFO, &message).await;
509 }
510 } else {
511 log::info!("Using default rumdl configuration (no config files found)");
512 }
513 }
514 Err(e) => {
515 let message = format!("Failed to load rumdl config: {e}");
516 log::warn!("{message}");
517 if notify_client {
518 self.client.log_message(MessageType::WARNING, &message).await;
519 }
520 *self.rumdl_config.write().await = crate::config::Config::default();
522 }
523 }
524 }
525
526 async fn reload_configuration(&self) {
528 self.load_configuration(true).await;
529 }
530
531 fn load_config_for_lsp(
533 config_path: Option<&str>,
534 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
535 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
537 }
538
539 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
546 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
548
549 {
551 let cache = self.config_cache.read().await;
552 if let Some(entry) = cache.get(&search_dir) {
553 let source_owned: String; let source: &str = if entry.from_global_fallback {
555 "global/user fallback"
556 } else if let Some(path) = &entry.config_file {
557 source_owned = path.to_string_lossy().to_string();
558 &source_owned
559 } else {
560 "<unknown>"
561 };
562 log::debug!(
563 "Config cache hit for directory: {} (loaded from: {})",
564 search_dir.display(),
565 source
566 );
567 return entry.config.clone();
568 }
569 }
570
571 log::debug!(
573 "Config cache miss for directory: {}, searching for config...",
574 search_dir.display()
575 );
576
577 let workspace_root = {
579 let workspace_roots = self.workspace_roots.read().await;
580 workspace_roots
581 .iter()
582 .find(|root| search_dir.starts_with(root))
583 .map(|p| p.to_path_buf())
584 };
585
586 let mut current_dir = search_dir.clone();
588 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
589
590 loop {
591 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
593
594 for config_file_name in CONFIG_FILES {
595 let config_path = current_dir.join(config_file_name);
596 if config_path.exists() {
597 if *config_file_name == "pyproject.toml" {
599 if let Ok(content) = std::fs::read_to_string(&config_path) {
600 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
601 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
602 } else {
603 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
604 continue;
605 }
606 } else {
607 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
608 continue;
609 }
610 } else {
611 log::debug!("Found config file: {}", config_path.display());
612 }
613
614 if let Some(config_path_str) = config_path.to_str() {
616 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
617 found_config = Some((sourced.into(), Some(config_path)));
618 break;
619 }
620 } else {
621 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
622 }
623 }
624 }
625
626 if found_config.is_some() {
627 break;
628 }
629
630 if let Some(ref root) = workspace_root
632 && ¤t_dir == root
633 {
634 log::debug!("Hit workspace root without finding config: {}", root.display());
635 break;
636 }
637
638 if let Some(parent) = current_dir.parent() {
640 current_dir = parent.to_path_buf();
641 } else {
642 break;
644 }
645 }
646
647 let (config, config_file) = if let Some((cfg, path)) = found_config {
649 (cfg, path)
650 } else {
651 log::debug!("No project config found; using global/user fallback config");
652 let fallback = self.rumdl_config.read().await.clone();
653 (fallback, None)
654 };
655
656 let from_global = config_file.is_none();
658 let entry = ConfigCacheEntry {
659 config: config.clone(),
660 config_file,
661 from_global_fallback: from_global,
662 };
663
664 self.config_cache.write().await.insert(search_dir, entry);
665
666 config
667 }
668}
669
670#[tower_lsp::async_trait]
671impl LanguageServer for RumdlLanguageServer {
672 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
673 log::info!("Initializing rumdl Language Server");
674
675 if let Some(options) = params.initialization_options
677 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
678 {
679 *self.config.write().await = config;
680 }
681
682 let mut roots = Vec::new();
684 if let Some(workspace_folders) = params.workspace_folders {
685 for folder in workspace_folders {
686 if let Ok(path) = folder.uri.to_file_path() {
687 log::info!("Workspace root: {}", path.display());
688 roots.push(path);
689 }
690 }
691 } else if let Some(root_uri) = params.root_uri
692 && let Ok(path) = root_uri.to_file_path()
693 {
694 log::info!("Workspace root: {}", path.display());
695 roots.push(path);
696 }
697 *self.workspace_roots.write().await = roots;
698
699 self.load_configuration(false).await;
701
702 Ok(InitializeResult {
703 capabilities: ServerCapabilities {
704 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
705 open_close: Some(true),
706 change: Some(TextDocumentSyncKind::FULL),
707 will_save: Some(false),
708 will_save_wait_until: Some(true),
709 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
710 include_text: Some(false),
711 })),
712 })),
713 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
714 document_formatting_provider: Some(OneOf::Left(true)),
715 document_range_formatting_provider: Some(OneOf::Left(true)),
716 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
717 identifier: Some("rumdl".to_string()),
718 inter_file_dependencies: false,
719 workspace_diagnostics: false,
720 work_done_progress_options: WorkDoneProgressOptions::default(),
721 })),
722 workspace: Some(WorkspaceServerCapabilities {
723 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
724 supported: Some(true),
725 change_notifications: Some(OneOf::Left(true)),
726 }),
727 file_operations: None,
728 }),
729 ..Default::default()
730 },
731 server_info: Some(ServerInfo {
732 name: "rumdl".to_string(),
733 version: Some(env!("CARGO_PKG_VERSION").to_string()),
734 }),
735 })
736 }
737
738 async fn initialized(&self, _: InitializedParams) {
739 let version = env!("CARGO_PKG_VERSION");
740
741 let (binary_path, build_time) = std::env::current_exe()
743 .ok()
744 .map(|path| {
745 let path_str = path.to_str().unwrap_or("unknown").to_string();
746 let build_time = std::fs::metadata(&path)
747 .ok()
748 .and_then(|metadata| metadata.modified().ok())
749 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
750 .and_then(|duration| {
751 let secs = duration.as_secs();
752 chrono::DateTime::from_timestamp(secs as i64, 0)
753 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
754 })
755 .unwrap_or_else(|| "unknown".to_string());
756 (path_str, build_time)
757 })
758 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
759
760 let working_dir = std::env::current_dir()
761 .ok()
762 .and_then(|p| p.to_str().map(|s| s.to_string()))
763 .unwrap_or_else(|| "unknown".to_string());
764
765 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
766 log::info!("Working directory: {working_dir}");
767
768 self.client
769 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
770 .await;
771 }
772
773 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
774 let mut roots = self.workspace_roots.write().await;
776
777 for removed in ¶ms.event.removed {
779 if let Ok(path) = removed.uri.to_file_path() {
780 roots.retain(|r| r != &path);
781 log::info!("Removed workspace root: {}", path.display());
782 }
783 }
784
785 for added in ¶ms.event.added {
787 if let Ok(path) = added.uri.to_file_path()
788 && !roots.contains(&path)
789 {
790 log::info!("Added workspace root: {}", path.display());
791 roots.push(path);
792 }
793 }
794 drop(roots);
795
796 self.config_cache.write().await.clear();
798
799 self.reload_configuration().await;
801 }
802
803 async fn shutdown(&self) -> JsonRpcResult<()> {
804 log::info!("Shutting down rumdl Language Server");
805 Ok(())
806 }
807
808 async fn did_open(&self, params: DidOpenTextDocumentParams) {
809 let uri = params.text_document.uri;
810 let text = params.text_document.text;
811 let version = params.text_document.version;
812
813 let entry = DocumentEntry {
814 content: text.clone(),
815 version: Some(version),
816 from_disk: false,
817 };
818 self.documents.write().await.insert(uri.clone(), entry);
819
820 self.update_diagnostics(uri, text).await;
821 }
822
823 async fn did_change(&self, params: DidChangeTextDocumentParams) {
824 let uri = params.text_document.uri;
825 let version = params.text_document.version;
826
827 if let Some(change) = params.content_changes.into_iter().next() {
828 let text = change.text;
829
830 let entry = DocumentEntry {
831 content: text.clone(),
832 version: Some(version),
833 from_disk: false,
834 };
835 self.documents.write().await.insert(uri.clone(), entry);
836
837 self.update_diagnostics(uri, text).await;
838 }
839 }
840
841 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
842 let config_guard = self.config.read().await;
843 let enable_auto_fix = config_guard.enable_auto_fix;
844 drop(config_guard);
845
846 if !enable_auto_fix {
847 return Ok(None);
848 }
849
850 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
852 return Ok(None);
853 };
854
855 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
857 Ok(Some(fixed_text)) => {
858 Ok(Some(vec![TextEdit {
860 range: Range {
861 start: Position { line: 0, character: 0 },
862 end: self.get_end_position(&text),
863 },
864 new_text: fixed_text,
865 }]))
866 }
867 Ok(None) => Ok(None),
868 Err(e) => {
869 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
870 Ok(None)
871 }
872 }
873 }
874
875 async fn did_save(&self, params: DidSaveTextDocumentParams) {
876 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
879 self.update_diagnostics(params.text_document.uri, entry.content.clone())
880 .await;
881 }
882 }
883
884 async fn did_close(&self, params: DidCloseTextDocumentParams) {
885 self.documents.write().await.remove(¶ms.text_document.uri);
887
888 self.client
890 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
891 .await;
892 }
893
894 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
895 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
897
898 for change in ¶ms.changes {
899 if let Ok(path) = change.uri.to_file_path()
900 && let Some(file_name) = path.file_name().and_then(|f| f.to_str())
901 && CONFIG_FILES.contains(&file_name)
902 {
903 log::info!("Config file changed: {}, invalidating config cache", path.display());
904
905 let mut cache = self.config_cache.write().await;
907 cache.retain(|_, entry| {
908 if let Some(config_file) = &entry.config_file {
909 config_file != &path
910 } else {
911 true
912 }
913 });
914
915 drop(cache);
917 self.reload_configuration().await;
918
919 let docs_to_update: Vec<(Url, String)> = {
922 let docs = self.documents.read().await;
923 docs.iter()
924 .filter(|(_, entry)| !entry.from_disk)
925 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
926 .collect()
927 };
928
929 for (uri, text) in docs_to_update {
931 self.update_diagnostics(uri, text).await;
932 }
933
934 break;
935 }
936 }
937 }
938
939 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
940 let uri = params.text_document.uri;
941 let range = params.range;
942
943 if let Some(text) = self.get_document_content(&uri).await {
944 match self.get_code_actions(&uri, &text, range).await {
945 Ok(actions) => {
946 let response: Vec<CodeActionOrCommand> =
947 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
948 Ok(Some(response))
949 }
950 Err(e) => {
951 log::error!("Failed to get code actions: {e}");
952 Ok(None)
953 }
954 }
955 } else {
956 Ok(None)
957 }
958 }
959
960 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
961 log::debug!(
966 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
967 params.range
968 );
969
970 let formatting_params = DocumentFormattingParams {
971 text_document: params.text_document,
972 options: params.options,
973 work_done_progress_params: params.work_done_progress_params,
974 };
975
976 self.formatting(formatting_params).await
977 }
978
979 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
980 let uri = params.text_document.uri;
981
982 log::debug!("Formatting request for: {uri}");
983
984 if let Some(text) = self.get_document_content(&uri).await {
985 let config_guard = self.config.read().await;
987 let lsp_config = config_guard.clone();
988 drop(config_guard);
989
990 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
992 self.resolve_config_for_file(&file_path).await
993 } else {
994 self.rumdl_config.read().await.clone()
996 };
997
998 let all_rules = rules::all_rules(&rumdl_config);
999 let flavor = rumdl_config.markdown_flavor();
1000
1001 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1003
1004 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1006
1007 match crate::lint(&text, &filtered_rules, false, flavor) {
1009 Ok(warnings) => {
1010 log::debug!(
1011 "Found {} warnings, {} with fixes",
1012 warnings.len(),
1013 warnings.iter().filter(|w| w.fix.is_some()).count()
1014 );
1015
1016 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1017 if has_fixes {
1018 let fixable_warnings: Vec<_> = warnings
1022 .iter()
1023 .filter(|w| {
1024 if let Some(rule_name) = &w.rule_name {
1025 filtered_rules
1026 .iter()
1027 .find(|r| r.name() == rule_name)
1028 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1029 .unwrap_or(false)
1030 } else {
1031 false
1032 }
1033 })
1034 .cloned()
1035 .collect();
1036
1037 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1038 Ok(fixed_content) => {
1039 if fixed_content != text {
1040 log::debug!("Returning formatting edits");
1041 let end_position = self.get_end_position(&text);
1042 let edit = TextEdit {
1043 range: Range {
1044 start: Position { line: 0, character: 0 },
1045 end: end_position,
1046 },
1047 new_text: fixed_content,
1048 };
1049 return Ok(Some(vec![edit]));
1050 }
1051 }
1052 Err(e) => {
1053 log::error!("Failed to apply fixes: {e}");
1054 }
1055 }
1056 }
1057 Ok(Some(Vec::new()))
1058 }
1059 Err(e) => {
1060 log::error!("Failed to format document: {e}");
1061 Ok(Some(Vec::new()))
1062 }
1063 }
1064 } else {
1065 log::warn!("Document not found: {uri}");
1066 Ok(None)
1067 }
1068 }
1069
1070 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1071 let uri = params.text_document.uri;
1072
1073 if let Some(text) = self.get_document_content(&uri).await {
1074 match self.lint_document(&uri, &text).await {
1075 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1076 RelatedFullDocumentDiagnosticReport {
1077 related_documents: None,
1078 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1079 result_id: None,
1080 items: diagnostics,
1081 },
1082 },
1083 ))),
1084 Err(e) => {
1085 log::error!("Failed to get diagnostics: {e}");
1086 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1087 RelatedFullDocumentDiagnosticReport {
1088 related_documents: None,
1089 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1090 result_id: None,
1091 items: Vec::new(),
1092 },
1093 },
1094 )))
1095 }
1096 }
1097 } else {
1098 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1099 RelatedFullDocumentDiagnosticReport {
1100 related_documents: None,
1101 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1102 result_id: None,
1103 items: Vec::new(),
1104 },
1105 },
1106 )))
1107 }
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use crate::rule::LintWarning;
1115 use tower_lsp::LspService;
1116
1117 fn create_test_server() -> RumdlLanguageServer {
1118 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1119 service.inner().clone()
1120 }
1121
1122 #[tokio::test]
1123 async fn test_server_creation() {
1124 let server = create_test_server();
1125
1126 let config = server.config.read().await;
1128 assert!(config.enable_linting);
1129 assert!(!config.enable_auto_fix);
1130 }
1131
1132 #[tokio::test]
1133 async fn test_lint_document() {
1134 let server = create_test_server();
1135
1136 let uri = Url::parse("file:///test.md").unwrap();
1138 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1139
1140 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1141
1142 assert!(!diagnostics.is_empty());
1144 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1145 }
1146
1147 #[tokio::test]
1148 async fn test_lint_document_disabled() {
1149 let server = create_test_server();
1150
1151 server.config.write().await.enable_linting = false;
1153
1154 let uri = Url::parse("file:///test.md").unwrap();
1155 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1156
1157 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1158
1159 assert!(diagnostics.is_empty());
1161 }
1162
1163 #[tokio::test]
1164 async fn test_get_code_actions() {
1165 let server = create_test_server();
1166
1167 let uri = Url::parse("file:///test.md").unwrap();
1168 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1169
1170 let range = Range {
1172 start: Position { line: 0, character: 0 },
1173 end: Position { line: 3, character: 21 },
1174 };
1175
1176 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1177
1178 assert!(!actions.is_empty());
1180 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1181 }
1182
1183 #[tokio::test]
1184 async fn test_get_code_actions_outside_range() {
1185 let server = create_test_server();
1186
1187 let uri = Url::parse("file:///test.md").unwrap();
1188 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1189
1190 let range = Range {
1192 start: Position { line: 0, character: 0 },
1193 end: Position { line: 0, character: 6 },
1194 };
1195
1196 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1197
1198 assert!(actions.is_empty());
1200 }
1201
1202 #[tokio::test]
1203 async fn test_document_storage() {
1204 let server = create_test_server();
1205
1206 let uri = Url::parse("file:///test.md").unwrap();
1207 let text = "# Test Document";
1208
1209 let entry = DocumentEntry {
1211 content: text.to_string(),
1212 version: Some(1),
1213 from_disk: false,
1214 };
1215 server.documents.write().await.insert(uri.clone(), entry);
1216
1217 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1219 assert_eq!(stored, Some(text.to_string()));
1220
1221 server.documents.write().await.remove(&uri);
1223
1224 let stored = server.documents.read().await.get(&uri).cloned();
1226 assert_eq!(stored, None);
1227 }
1228
1229 #[tokio::test]
1230 async fn test_configuration_loading() {
1231 let server = create_test_server();
1232
1233 server.load_configuration(false).await;
1235
1236 let rumdl_config = server.rumdl_config.read().await;
1239 drop(rumdl_config); }
1242
1243 #[tokio::test]
1244 async fn test_load_config_for_lsp() {
1245 let result = RumdlLanguageServer::load_config_for_lsp(None);
1247 assert!(result.is_ok());
1248
1249 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1251 assert!(result.is_err());
1252 }
1253
1254 #[tokio::test]
1255 async fn test_warning_conversion() {
1256 let warning = LintWarning {
1257 message: "Test warning".to_string(),
1258 line: 1,
1259 column: 1,
1260 end_line: 1,
1261 end_column: 10,
1262 severity: crate::rule::Severity::Warning,
1263 fix: None,
1264 rule_name: Some("MD001".to_string()),
1265 };
1266
1267 let diagnostic = warning_to_diagnostic(&warning);
1269 assert_eq!(diagnostic.message, "Test warning");
1270 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1271 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1272
1273 let uri = Url::parse("file:///test.md").unwrap();
1275 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1276 assert_eq!(actions.len(), 1);
1278 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1279 }
1280
1281 #[tokio::test]
1282 async fn test_multiple_documents() {
1283 let server = create_test_server();
1284
1285 let uri1 = Url::parse("file:///test1.md").unwrap();
1286 let uri2 = Url::parse("file:///test2.md").unwrap();
1287 let text1 = "# Document 1";
1288 let text2 = "# Document 2";
1289
1290 {
1292 let mut docs = server.documents.write().await;
1293 let entry1 = DocumentEntry {
1294 content: text1.to_string(),
1295 version: Some(1),
1296 from_disk: false,
1297 };
1298 let entry2 = DocumentEntry {
1299 content: text2.to_string(),
1300 version: Some(1),
1301 from_disk: false,
1302 };
1303 docs.insert(uri1.clone(), entry1);
1304 docs.insert(uri2.clone(), entry2);
1305 }
1306
1307 let docs = server.documents.read().await;
1309 assert_eq!(docs.len(), 2);
1310 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1311 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1312 }
1313
1314 #[tokio::test]
1315 async fn test_auto_fix_on_save() {
1316 let server = create_test_server();
1317
1318 {
1320 let mut config = server.config.write().await;
1321 config.enable_auto_fix = true;
1322 }
1323
1324 let uri = Url::parse("file:///test.md").unwrap();
1325 let text = "#Heading without space"; let entry = DocumentEntry {
1329 content: text.to_string(),
1330 version: Some(1),
1331 from_disk: false,
1332 };
1333 server.documents.write().await.insert(uri.clone(), entry);
1334
1335 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1337 assert!(fixed.is_some());
1338 assert_eq!(fixed.unwrap(), "# Heading without space\n");
1340 }
1341
1342 #[tokio::test]
1343 async fn test_get_end_position() {
1344 let server = create_test_server();
1345
1346 let pos = server.get_end_position("Hello");
1348 assert_eq!(pos.line, 0);
1349 assert_eq!(pos.character, 5);
1350
1351 let pos = server.get_end_position("Hello\nWorld\nTest");
1353 assert_eq!(pos.line, 2);
1354 assert_eq!(pos.character, 4);
1355
1356 let pos = server.get_end_position("");
1358 assert_eq!(pos.line, 0);
1359 assert_eq!(pos.character, 0);
1360
1361 let pos = server.get_end_position("Hello\n");
1363 assert_eq!(pos.line, 1);
1364 assert_eq!(pos.character, 0);
1365 }
1366
1367 #[tokio::test]
1368 async fn test_empty_document_handling() {
1369 let server = create_test_server();
1370
1371 let uri = Url::parse("file:///empty.md").unwrap();
1372 let text = "";
1373
1374 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1376 assert!(diagnostics.is_empty());
1377
1378 let range = Range {
1380 start: Position { line: 0, character: 0 },
1381 end: Position { line: 0, character: 0 },
1382 };
1383 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1384 assert!(actions.is_empty());
1385 }
1386
1387 #[tokio::test]
1388 async fn test_config_update() {
1389 let server = create_test_server();
1390
1391 {
1393 let mut config = server.config.write().await;
1394 config.enable_auto_fix = true;
1395 config.config_path = Some("/custom/path.toml".to_string());
1396 }
1397
1398 let config = server.config.read().await;
1400 assert!(config.enable_auto_fix);
1401 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1402 }
1403
1404 #[tokio::test]
1405 async fn test_document_formatting() {
1406 let server = create_test_server();
1407 let uri = Url::parse("file:///test.md").unwrap();
1408 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1409
1410 let entry = DocumentEntry {
1412 content: text.to_string(),
1413 version: Some(1),
1414 from_disk: false,
1415 };
1416 server.documents.write().await.insert(uri.clone(), entry);
1417
1418 let params = DocumentFormattingParams {
1420 text_document: TextDocumentIdentifier { uri: uri.clone() },
1421 options: FormattingOptions {
1422 tab_size: 4,
1423 insert_spaces: true,
1424 properties: HashMap::new(),
1425 trim_trailing_whitespace: Some(true),
1426 insert_final_newline: Some(true),
1427 trim_final_newlines: Some(true),
1428 },
1429 work_done_progress_params: WorkDoneProgressParams::default(),
1430 };
1431
1432 let result = server.formatting(params).await.unwrap();
1434
1435 assert!(result.is_some());
1437 let edits = result.unwrap();
1438 assert!(!edits.is_empty());
1439
1440 let edit = &edits[0];
1442 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
1445 assert_eq!(edit.new_text, expected);
1446 }
1447
1448 #[tokio::test]
1451 async fn test_unfixable_rules_excluded_from_formatting() {
1452 let server = create_test_server();
1453 let uri = Url::parse("file:///test.md").unwrap();
1454
1455 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
1457
1458 let entry = DocumentEntry {
1460 content: text.to_string(),
1461 version: Some(1),
1462 from_disk: false,
1463 };
1464 server.documents.write().await.insert(uri.clone(), entry);
1465
1466 let format_params = DocumentFormattingParams {
1468 text_document: TextDocumentIdentifier { uri: uri.clone() },
1469 options: FormattingOptions {
1470 tab_size: 4,
1471 insert_spaces: true,
1472 properties: HashMap::new(),
1473 trim_trailing_whitespace: Some(true),
1474 insert_final_newline: Some(true),
1475 trim_final_newlines: Some(true),
1476 },
1477 work_done_progress_params: WorkDoneProgressParams::default(),
1478 };
1479
1480 let format_result = server.formatting(format_params).await.unwrap();
1481 assert!(format_result.is_some(), "Should return formatting edits");
1482
1483 let edits = format_result.unwrap();
1484 assert!(!edits.is_empty(), "Should have formatting edits");
1485
1486 let formatted = &edits[0].new_text;
1487 assert!(
1488 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
1489 "HTML should be preserved during formatting (Unfixable rule)"
1490 );
1491 assert!(
1492 !formatted.contains("spaces "),
1493 "Trailing spaces should be removed (fixable rule)"
1494 );
1495
1496 let range = Range {
1498 start: Position { line: 0, character: 0 },
1499 end: Position { line: 10, character: 0 },
1500 };
1501
1502 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
1503
1504 let html_fix_actions: Vec<_> = code_actions
1506 .iter()
1507 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
1508 .collect();
1509
1510 assert!(
1511 !html_fix_actions.is_empty(),
1512 "Quick Fix actions should be available for HTML (Unfixable rules)"
1513 );
1514
1515 let fix_all_actions: Vec<_> = code_actions
1517 .iter()
1518 .filter(|action| action.title.contains("Fix all"))
1519 .collect();
1520
1521 if let Some(fix_all_action) = fix_all_actions.first()
1522 && let Some(ref edit) = fix_all_action.edit
1523 && let Some(ref changes) = edit.changes
1524 && let Some(text_edits) = changes.get(&uri)
1525 && let Some(text_edit) = text_edits.first()
1526 {
1527 let fixed_all = &text_edit.new_text;
1528 assert!(
1529 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
1530 "Fix All should preserve HTML (Unfixable rules)"
1531 );
1532 assert!(
1533 !fixed_all.contains("spaces "),
1534 "Fix All should remove trailing spaces (fixable rules)"
1535 );
1536 }
1537 }
1538
1539 #[tokio::test]
1541 async fn test_resolve_config_for_file_multi_root() {
1542 use std::fs;
1543 use tempfile::tempdir;
1544
1545 let temp_dir = tempdir().unwrap();
1546 let temp_path = temp_dir.path();
1547
1548 let project_a = temp_path.join("project_a");
1550 let project_a_docs = project_a.join("docs");
1551 fs::create_dir_all(&project_a_docs).unwrap();
1552
1553 let config_a = project_a.join(".rumdl.toml");
1554 fs::write(
1555 &config_a,
1556 r#"
1557[global]
1558
1559[MD013]
1560line_length = 60
1561"#,
1562 )
1563 .unwrap();
1564
1565 let project_b = temp_path.join("project_b");
1567 fs::create_dir(&project_b).unwrap();
1568
1569 let config_b = project_b.join(".rumdl.toml");
1570 fs::write(
1571 &config_b,
1572 r#"
1573[global]
1574
1575[MD013]
1576line_length = 120
1577"#,
1578 )
1579 .unwrap();
1580
1581 let server = create_test_server();
1583
1584 {
1586 let mut roots = server.workspace_roots.write().await;
1587 roots.push(project_a.clone());
1588 roots.push(project_b.clone());
1589 }
1590
1591 let file_a = project_a_docs.join("test.md");
1593 fs::write(&file_a, "# Test A\n").unwrap();
1594
1595 let config_for_a = server.resolve_config_for_file(&file_a).await;
1596 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1597 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1598
1599 let file_b = project_b.join("test.md");
1601 fs::write(&file_b, "# Test B\n").unwrap();
1602
1603 let config_for_b = server.resolve_config_for_file(&file_b).await;
1604 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1605 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1606 }
1607
1608 #[tokio::test]
1610 async fn test_config_resolution_respects_workspace_boundaries() {
1611 use std::fs;
1612 use tempfile::tempdir;
1613
1614 let temp_dir = tempdir().unwrap();
1615 let temp_path = temp_dir.path();
1616
1617 let parent_config = temp_path.join(".rumdl.toml");
1619 fs::write(
1620 &parent_config,
1621 r#"
1622[global]
1623
1624[MD013]
1625line_length = 80
1626"#,
1627 )
1628 .unwrap();
1629
1630 let workspace_root = temp_path.join("workspace");
1632 let workspace_subdir = workspace_root.join("subdir");
1633 fs::create_dir_all(&workspace_subdir).unwrap();
1634
1635 let workspace_config = workspace_root.join(".rumdl.toml");
1636 fs::write(
1637 &workspace_config,
1638 r#"
1639[global]
1640
1641[MD013]
1642line_length = 100
1643"#,
1644 )
1645 .unwrap();
1646
1647 let server = create_test_server();
1648
1649 {
1651 let mut roots = server.workspace_roots.write().await;
1652 roots.push(workspace_root.clone());
1653 }
1654
1655 let test_file = workspace_subdir.join("deep").join("test.md");
1657 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1658 fs::write(&test_file, "# Test\n").unwrap();
1659
1660 let config = server.resolve_config_for_file(&test_file).await;
1661 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1662
1663 assert_eq!(
1665 line_length,
1666 Some(100),
1667 "Should find workspace config, not parent config outside workspace"
1668 );
1669 }
1670
1671 #[tokio::test]
1673 async fn test_config_cache_hit() {
1674 use std::fs;
1675 use tempfile::tempdir;
1676
1677 let temp_dir = tempdir().unwrap();
1678 let temp_path = temp_dir.path();
1679
1680 let project = temp_path.join("project");
1681 fs::create_dir(&project).unwrap();
1682
1683 let config_file = project.join(".rumdl.toml");
1684 fs::write(
1685 &config_file,
1686 r#"
1687[global]
1688
1689[MD013]
1690line_length = 75
1691"#,
1692 )
1693 .unwrap();
1694
1695 let server = create_test_server();
1696 {
1697 let mut roots = server.workspace_roots.write().await;
1698 roots.push(project.clone());
1699 }
1700
1701 let test_file = project.join("test.md");
1702 fs::write(&test_file, "# Test\n").unwrap();
1703
1704 let config1 = server.resolve_config_for_file(&test_file).await;
1706 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1707 assert_eq!(line_length1, Some(75));
1708
1709 {
1711 let cache = server.config_cache.read().await;
1712 let search_dir = test_file.parent().unwrap();
1713 assert!(
1714 cache.contains_key(search_dir),
1715 "Cache should be populated after first call"
1716 );
1717 }
1718
1719 let config2 = server.resolve_config_for_file(&test_file).await;
1721 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1722 assert_eq!(line_length2, Some(75));
1723 }
1724
1725 #[tokio::test]
1727 async fn test_nested_directory_config_search() {
1728 use std::fs;
1729 use tempfile::tempdir;
1730
1731 let temp_dir = tempdir().unwrap();
1732 let temp_path = temp_dir.path();
1733
1734 let project = temp_path.join("project");
1735 fs::create_dir(&project).unwrap();
1736
1737 let config = project.join(".rumdl.toml");
1739 fs::write(
1740 &config,
1741 r#"
1742[global]
1743
1744[MD013]
1745line_length = 110
1746"#,
1747 )
1748 .unwrap();
1749
1750 let deep_dir = project.join("src").join("docs").join("guides");
1752 fs::create_dir_all(&deep_dir).unwrap();
1753 let deep_file = deep_dir.join("test.md");
1754 fs::write(&deep_file, "# Test\n").unwrap();
1755
1756 let server = create_test_server();
1757 {
1758 let mut roots = server.workspace_roots.write().await;
1759 roots.push(project.clone());
1760 }
1761
1762 let resolved_config = server.resolve_config_for_file(&deep_file).await;
1763 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1764
1765 assert_eq!(
1766 line_length,
1767 Some(110),
1768 "Should find config by searching upward from deep directory"
1769 );
1770 }
1771
1772 #[tokio::test]
1774 async fn test_fallback_to_default_config() {
1775 use std::fs;
1776 use tempfile::tempdir;
1777
1778 let temp_dir = tempdir().unwrap();
1779 let temp_path = temp_dir.path();
1780
1781 let project = temp_path.join("project");
1782 fs::create_dir(&project).unwrap();
1783
1784 let test_file = project.join("test.md");
1787 fs::write(&test_file, "# Test\n").unwrap();
1788
1789 let server = create_test_server();
1790 {
1791 let mut roots = server.workspace_roots.write().await;
1792 roots.push(project.clone());
1793 }
1794
1795 let config = server.resolve_config_for_file(&test_file).await;
1796
1797 assert_eq!(
1799 config.global.line_length, 80,
1800 "Should fall back to default config when no config file found"
1801 );
1802 }
1803
1804 #[tokio::test]
1806 async fn test_config_priority_closer_wins() {
1807 use std::fs;
1808 use tempfile::tempdir;
1809
1810 let temp_dir = tempdir().unwrap();
1811 let temp_path = temp_dir.path();
1812
1813 let project = temp_path.join("project");
1814 fs::create_dir(&project).unwrap();
1815
1816 let parent_config = project.join(".rumdl.toml");
1818 fs::write(
1819 &parent_config,
1820 r#"
1821[global]
1822
1823[MD013]
1824line_length = 100
1825"#,
1826 )
1827 .unwrap();
1828
1829 let subdir = project.join("subdir");
1831 fs::create_dir(&subdir).unwrap();
1832
1833 let subdir_config = subdir.join(".rumdl.toml");
1834 fs::write(
1835 &subdir_config,
1836 r#"
1837[global]
1838
1839[MD013]
1840line_length = 50
1841"#,
1842 )
1843 .unwrap();
1844
1845 let server = create_test_server();
1846 {
1847 let mut roots = server.workspace_roots.write().await;
1848 roots.push(project.clone());
1849 }
1850
1851 let test_file = subdir.join("test.md");
1853 fs::write(&test_file, "# Test\n").unwrap();
1854
1855 let config = server.resolve_config_for_file(&test_file).await;
1856 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1857
1858 assert_eq!(
1859 line_length,
1860 Some(50),
1861 "Closer config (subdir) should override parent config"
1862 );
1863 }
1864
1865 #[tokio::test]
1871 async fn test_issue_131_pyproject_without_rumdl_section() {
1872 use std::fs;
1873 use tempfile::tempdir;
1874
1875 let parent_dir = tempdir().unwrap();
1877
1878 let project_dir = parent_dir.path().join("project");
1880 fs::create_dir(&project_dir).unwrap();
1881
1882 fs::write(
1884 project_dir.join("pyproject.toml"),
1885 r#"
1886[project]
1887name = "test-project"
1888version = "0.1.0"
1889"#,
1890 )
1891 .unwrap();
1892
1893 fs::write(
1896 parent_dir.path().join(".rumdl.toml"),
1897 r#"
1898[global]
1899disable = ["MD013"]
1900"#,
1901 )
1902 .unwrap();
1903
1904 let test_file = project_dir.join("test.md");
1905 fs::write(&test_file, "# Test\n").unwrap();
1906
1907 let server = create_test_server();
1908
1909 {
1911 let mut roots = server.workspace_roots.write().await;
1912 roots.push(parent_dir.path().to_path_buf());
1913 }
1914
1915 let config = server.resolve_config_for_file(&test_file).await;
1917
1918 assert!(
1921 config.global.disable.contains(&"MD013".to_string()),
1922 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
1923 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
1924 );
1925
1926 let cache = server.config_cache.read().await;
1929 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1930
1931 assert!(
1932 cache_entry.config_file.is_some(),
1933 "Should have found a config file (parent .rumdl.toml)"
1934 );
1935
1936 let found_config_path = cache_entry.config_file.as_ref().unwrap();
1937 assert!(
1938 found_config_path.ends_with(".rumdl.toml"),
1939 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
1940 );
1941 assert!(
1942 found_config_path.parent().unwrap() == parent_dir.path(),
1943 "Should have loaded config from parent directory, not project_dir"
1944 );
1945 }
1946
1947 #[tokio::test]
1952 async fn test_issue_131_pyproject_with_rumdl_section() {
1953 use std::fs;
1954 use tempfile::tempdir;
1955
1956 let parent_dir = tempdir().unwrap();
1958
1959 let project_dir = parent_dir.path().join("project");
1961 fs::create_dir(&project_dir).unwrap();
1962
1963 fs::write(
1965 project_dir.join("pyproject.toml"),
1966 r#"
1967[project]
1968name = "test-project"
1969
1970[tool.rumdl.global]
1971disable = ["MD033"]
1972"#,
1973 )
1974 .unwrap();
1975
1976 fs::write(
1978 parent_dir.path().join(".rumdl.toml"),
1979 r#"
1980[global]
1981disable = ["MD041"]
1982"#,
1983 )
1984 .unwrap();
1985
1986 let test_file = project_dir.join("test.md");
1987 fs::write(&test_file, "# Test\n").unwrap();
1988
1989 let server = create_test_server();
1990
1991 {
1993 let mut roots = server.workspace_roots.write().await;
1994 roots.push(parent_dir.path().to_path_buf());
1995 }
1996
1997 let config = server.resolve_config_for_file(&test_file).await;
1999
2000 assert!(
2002 config.global.disable.contains(&"MD033".to_string()),
2003 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2004 Expected MD033 from project_dir pyproject.toml to be disabled."
2005 );
2006
2007 assert!(
2009 !config.global.disable.contains(&"MD041".to_string()),
2010 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2011 );
2012
2013 let cache = server.config_cache.read().await;
2015 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2016
2017 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2018
2019 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2020 assert!(
2021 found_config_path.ends_with("pyproject.toml"),
2022 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2023 );
2024 assert!(
2025 found_config_path.parent().unwrap() == project_dir,
2026 "Should have loaded pyproject.toml from project_dir, not parent"
2027 );
2028 }
2029
2030 #[tokio::test]
2035 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2036 use std::fs;
2037 use tempfile::tempdir;
2038
2039 let temp_dir = tempdir().unwrap();
2040
2041 fs::write(
2043 temp_dir.path().join("pyproject.toml"),
2044 r#"
2045[project]
2046name = "test-project"
2047
2048[tool.rumdl.global]
2049disable = ["MD022"]
2050"#,
2051 )
2052 .unwrap();
2053
2054 let test_file = temp_dir.path().join("test.md");
2055 fs::write(&test_file, "# Test\n").unwrap();
2056
2057 let server = create_test_server();
2058
2059 {
2061 let mut roots = server.workspace_roots.write().await;
2062 roots.push(temp_dir.path().to_path_buf());
2063 }
2064
2065 let config = server.resolve_config_for_file(&test_file).await;
2067
2068 assert!(
2070 config.global.disable.contains(&"MD022".to_string()),
2071 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2072 );
2073
2074 let cache = server.config_cache.read().await;
2076 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2077 assert!(
2078 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2079 "Should have loaded pyproject.toml"
2080 );
2081 }
2082}