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::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 total_fixable = warnings.iter().filter(|w| w.fix.is_some()).count();
417
418 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &warnings)
419 && fixed_content != text
420 {
421 let mut line = 0u32;
423 let mut character = 0u32;
424 for ch in text.chars() {
425 if ch == '\n' {
426 line += 1;
427 character = 0;
428 } else {
429 character += 1;
430 }
431 }
432
433 let fix_all_action = CodeAction {
434 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
435 kind: Some(CodeActionKind::QUICKFIX),
436 diagnostics: Some(Vec::new()),
437 edit: Some(WorkspaceEdit {
438 changes: Some(
439 [(
440 uri.clone(),
441 vec![TextEdit {
442 range: Range {
443 start: Position { line: 0, character: 0 },
444 end: Position { line, character },
445 },
446 new_text: fixed_content,
447 }],
448 )]
449 .into_iter()
450 .collect(),
451 ),
452 ..Default::default()
453 }),
454 command: None,
455 is_preferred: Some(true),
456 disabled: None,
457 data: None,
458 };
459
460 actions.insert(0, fix_all_action);
462 }
463 }
464
465 Ok(actions)
466 }
467 Err(e) => {
468 log::error!("Failed to get code actions: {e}");
469 Ok(Vec::new())
470 }
471 }
472 }
473
474 async fn load_configuration(&self, notify_client: bool) {
476 let config_guard = self.config.read().await;
477 let explicit_config_path = config_guard.config_path.clone();
478 drop(config_guard);
479
480 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
482 Ok(sourced_config) => {
483 let loaded_files = sourced_config.loaded_files.clone();
484 *self.rumdl_config.write().await = sourced_config.into();
485
486 if !loaded_files.is_empty() {
487 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
488 log::info!("{message}");
489 if notify_client {
490 self.client.log_message(MessageType::INFO, &message).await;
491 }
492 } else {
493 log::info!("Using default rumdl configuration (no config files found)");
494 }
495 }
496 Err(e) => {
497 let message = format!("Failed to load rumdl config: {e}");
498 log::warn!("{message}");
499 if notify_client {
500 self.client.log_message(MessageType::WARNING, &message).await;
501 }
502 *self.rumdl_config.write().await = crate::config::Config::default();
504 }
505 }
506 }
507
508 async fn reload_configuration(&self) {
510 self.load_configuration(true).await;
511 }
512
513 fn load_config_for_lsp(
515 config_path: Option<&str>,
516 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
517 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
519 }
520
521 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
528 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
530
531 {
533 let cache = self.config_cache.read().await;
534 if let Some(entry) = cache.get(&search_dir) {
535 let source_owned: String; let source: &str = if entry.from_global_fallback {
537 "global/user fallback"
538 } else if let Some(path) = &entry.config_file {
539 source_owned = path.to_string_lossy().to_string();
540 &source_owned
541 } else {
542 "<unknown>"
543 };
544 log::debug!(
545 "Config cache hit for directory: {} (loaded from: {})",
546 search_dir.display(),
547 source
548 );
549 return entry.config.clone();
550 }
551 }
552
553 log::debug!(
555 "Config cache miss for directory: {}, searching for config...",
556 search_dir.display()
557 );
558
559 let workspace_root = {
561 let workspace_roots = self.workspace_roots.read().await;
562 workspace_roots
563 .iter()
564 .find(|root| search_dir.starts_with(root))
565 .map(|p| p.to_path_buf())
566 };
567
568 let mut current_dir = search_dir.clone();
570 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
571
572 loop {
573 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
575
576 for config_file_name in CONFIG_FILES {
577 let config_path = current_dir.join(config_file_name);
578 if config_path.exists() {
579 if *config_file_name == "pyproject.toml" {
581 if let Ok(content) = std::fs::read_to_string(&config_path) {
582 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
583 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
584 } else {
585 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
586 continue;
587 }
588 } else {
589 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
590 continue;
591 }
592 } else {
593 log::debug!("Found config file: {}", config_path.display());
594 }
595
596 if let Some(config_path_str) = config_path.to_str() {
598 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
599 found_config = Some((sourced.into(), Some(config_path)));
600 break;
601 }
602 } else {
603 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
604 }
605 }
606 }
607
608 if found_config.is_some() {
609 break;
610 }
611
612 if let Some(ref root) = workspace_root
614 && ¤t_dir == root
615 {
616 log::debug!("Hit workspace root without finding config: {}", root.display());
617 break;
618 }
619
620 if let Some(parent) = current_dir.parent() {
622 current_dir = parent.to_path_buf();
623 } else {
624 break;
626 }
627 }
628
629 let (config, config_file) = if let Some((cfg, path)) = found_config {
631 (cfg, path)
632 } else {
633 log::debug!("No project config found; using global/user fallback config");
634 let fallback = self.rumdl_config.read().await.clone();
635 (fallback, None)
636 };
637
638 let from_global = config_file.is_none();
640 let entry = ConfigCacheEntry {
641 config: config.clone(),
642 config_file,
643 from_global_fallback: from_global,
644 };
645
646 self.config_cache.write().await.insert(search_dir, entry);
647
648 config
649 }
650}
651
652#[tower_lsp::async_trait]
653impl LanguageServer for RumdlLanguageServer {
654 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
655 log::info!("Initializing rumdl Language Server");
656
657 if let Some(options) = params.initialization_options
659 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
660 {
661 *self.config.write().await = config;
662 }
663
664 let mut roots = Vec::new();
666 if let Some(workspace_folders) = params.workspace_folders {
667 for folder in workspace_folders {
668 if let Ok(path) = folder.uri.to_file_path() {
669 log::info!("Workspace root: {}", path.display());
670 roots.push(path);
671 }
672 }
673 } else if let Some(root_uri) = params.root_uri
674 && let Ok(path) = root_uri.to_file_path()
675 {
676 log::info!("Workspace root: {}", path.display());
677 roots.push(path);
678 }
679 *self.workspace_roots.write().await = roots;
680
681 self.load_configuration(false).await;
683
684 Ok(InitializeResult {
685 capabilities: ServerCapabilities {
686 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
687 open_close: Some(true),
688 change: Some(TextDocumentSyncKind::FULL),
689 will_save: Some(false),
690 will_save_wait_until: Some(true),
691 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
692 include_text: Some(false),
693 })),
694 })),
695 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
696 document_formatting_provider: Some(OneOf::Left(true)),
697 document_range_formatting_provider: Some(OneOf::Left(true)),
698 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
699 identifier: Some("rumdl".to_string()),
700 inter_file_dependencies: false,
701 workspace_diagnostics: false,
702 work_done_progress_options: WorkDoneProgressOptions::default(),
703 })),
704 workspace: Some(WorkspaceServerCapabilities {
705 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
706 supported: Some(true),
707 change_notifications: Some(OneOf::Left(true)),
708 }),
709 file_operations: None,
710 }),
711 ..Default::default()
712 },
713 server_info: Some(ServerInfo {
714 name: "rumdl".to_string(),
715 version: Some(env!("CARGO_PKG_VERSION").to_string()),
716 }),
717 })
718 }
719
720 async fn initialized(&self, _: InitializedParams) {
721 let version = env!("CARGO_PKG_VERSION");
722
723 let (binary_path, build_time) = std::env::current_exe()
725 .ok()
726 .map(|path| {
727 let path_str = path.to_str().unwrap_or("unknown").to_string();
728 let build_time = std::fs::metadata(&path)
729 .ok()
730 .and_then(|metadata| metadata.modified().ok())
731 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
732 .and_then(|duration| {
733 let secs = duration.as_secs();
734 chrono::DateTime::from_timestamp(secs as i64, 0)
735 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
736 })
737 .unwrap_or_else(|| "unknown".to_string());
738 (path_str, build_time)
739 })
740 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
741
742 let working_dir = std::env::current_dir()
743 .ok()
744 .and_then(|p| p.to_str().map(|s| s.to_string()))
745 .unwrap_or_else(|| "unknown".to_string());
746
747 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
748 log::info!("Working directory: {working_dir}");
749
750 self.client
751 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
752 .await;
753 }
754
755 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
756 let mut roots = self.workspace_roots.write().await;
758
759 for removed in ¶ms.event.removed {
761 if let Ok(path) = removed.uri.to_file_path() {
762 roots.retain(|r| r != &path);
763 log::info!("Removed workspace root: {}", path.display());
764 }
765 }
766
767 for added in ¶ms.event.added {
769 if let Ok(path) = added.uri.to_file_path()
770 && !roots.contains(&path)
771 {
772 log::info!("Added workspace root: {}", path.display());
773 roots.push(path);
774 }
775 }
776 drop(roots);
777
778 self.config_cache.write().await.clear();
780
781 self.reload_configuration().await;
783 }
784
785 async fn shutdown(&self) -> JsonRpcResult<()> {
786 log::info!("Shutting down rumdl Language Server");
787 Ok(())
788 }
789
790 async fn did_open(&self, params: DidOpenTextDocumentParams) {
791 let uri = params.text_document.uri;
792 let text = params.text_document.text;
793 let version = params.text_document.version;
794
795 let entry = DocumentEntry {
796 content: text.clone(),
797 version: Some(version),
798 from_disk: false,
799 };
800 self.documents.write().await.insert(uri.clone(), entry);
801
802 self.update_diagnostics(uri, text).await;
803 }
804
805 async fn did_change(&self, params: DidChangeTextDocumentParams) {
806 let uri = params.text_document.uri;
807 let version = params.text_document.version;
808
809 if let Some(change) = params.content_changes.into_iter().next() {
810 let text = change.text;
811
812 let entry = DocumentEntry {
813 content: text.clone(),
814 version: Some(version),
815 from_disk: false,
816 };
817 self.documents.write().await.insert(uri.clone(), entry);
818
819 self.update_diagnostics(uri, text).await;
820 }
821 }
822
823 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
824 let config_guard = self.config.read().await;
825 let enable_auto_fix = config_guard.enable_auto_fix;
826 drop(config_guard);
827
828 if !enable_auto_fix {
829 return Ok(None);
830 }
831
832 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
834 return Ok(None);
835 };
836
837 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
839 Ok(Some(fixed_text)) => {
840 Ok(Some(vec![TextEdit {
842 range: Range {
843 start: Position { line: 0, character: 0 },
844 end: self.get_end_position(&text),
845 },
846 new_text: fixed_text,
847 }]))
848 }
849 Ok(None) => Ok(None),
850 Err(e) => {
851 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
852 Ok(None)
853 }
854 }
855 }
856
857 async fn did_save(&self, params: DidSaveTextDocumentParams) {
858 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
861 self.update_diagnostics(params.text_document.uri, entry.content.clone())
862 .await;
863 }
864 }
865
866 async fn did_close(&self, params: DidCloseTextDocumentParams) {
867 self.documents.write().await.remove(¶ms.text_document.uri);
869
870 self.client
872 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
873 .await;
874 }
875
876 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
877 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
879
880 for change in ¶ms.changes {
881 if let Ok(path) = change.uri.to_file_path()
882 && let Some(file_name) = path.file_name().and_then(|f| f.to_str())
883 && CONFIG_FILES.contains(&file_name)
884 {
885 log::info!("Config file changed: {}, invalidating config cache", path.display());
886
887 let mut cache = self.config_cache.write().await;
889 cache.retain(|_, entry| {
890 if let Some(config_file) = &entry.config_file {
891 config_file != &path
892 } else {
893 true
894 }
895 });
896
897 drop(cache);
899 self.reload_configuration().await;
900
901 let docs_to_update: Vec<(Url, String)> = {
904 let docs = self.documents.read().await;
905 docs.iter()
906 .filter(|(_, entry)| !entry.from_disk)
907 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
908 .collect()
909 };
910
911 for (uri, text) in docs_to_update {
913 self.update_diagnostics(uri, text).await;
914 }
915
916 break;
917 }
918 }
919 }
920
921 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
922 let uri = params.text_document.uri;
923 let range = params.range;
924
925 if let Some(text) = self.get_document_content(&uri).await {
926 match self.get_code_actions(&uri, &text, range).await {
927 Ok(actions) => {
928 let response: Vec<CodeActionOrCommand> =
929 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
930 Ok(Some(response))
931 }
932 Err(e) => {
933 log::error!("Failed to get code actions: {e}");
934 Ok(None)
935 }
936 }
937 } else {
938 Ok(None)
939 }
940 }
941
942 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
943 log::debug!(
948 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
949 params.range
950 );
951
952 let formatting_params = DocumentFormattingParams {
953 text_document: params.text_document,
954 options: params.options,
955 work_done_progress_params: params.work_done_progress_params,
956 };
957
958 self.formatting(formatting_params).await
959 }
960
961 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
962 let uri = params.text_document.uri;
963
964 log::debug!("Formatting request for: {uri}");
965
966 if let Some(text) = self.get_document_content(&uri).await {
967 let config_guard = self.config.read().await;
969 let lsp_config = config_guard.clone();
970 drop(config_guard);
971
972 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
974 self.resolve_config_for_file(&file_path).await
975 } else {
976 self.rumdl_config.read().await.clone()
978 };
979
980 let all_rules = rules::all_rules(&rumdl_config);
981 let flavor = rumdl_config.markdown_flavor();
982
983 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
985
986 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
988
989 match crate::lint(&text, &filtered_rules, false, flavor) {
991 Ok(warnings) => {
992 log::debug!(
993 "Found {} warnings, {} with fixes",
994 warnings.len(),
995 warnings.iter().filter(|w| w.fix.is_some()).count()
996 );
997
998 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
999 if has_fixes {
1000 match crate::utils::fix_utils::apply_warning_fixes(&text, &warnings) {
1001 Ok(fixed_content) => {
1002 if fixed_content != text {
1003 log::debug!("Returning formatting edits");
1004 let end_position = self.get_end_position(&text);
1005 let edit = TextEdit {
1006 range: Range {
1007 start: Position { line: 0, character: 0 },
1008 end: end_position,
1009 },
1010 new_text: fixed_content,
1011 };
1012 return Ok(Some(vec![edit]));
1013 }
1014 }
1015 Err(e) => {
1016 log::error!("Failed to apply fixes: {e}");
1017 }
1018 }
1019 }
1020 Ok(Some(Vec::new()))
1021 }
1022 Err(e) => {
1023 log::error!("Failed to format document: {e}");
1024 Ok(Some(Vec::new()))
1025 }
1026 }
1027 } else {
1028 log::warn!("Document not found: {uri}");
1029 Ok(None)
1030 }
1031 }
1032
1033 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1034 let uri = params.text_document.uri;
1035
1036 if let Some(text) = self.get_document_content(&uri).await {
1037 match self.lint_document(&uri, &text).await {
1038 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1039 RelatedFullDocumentDiagnosticReport {
1040 related_documents: None,
1041 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1042 result_id: None,
1043 items: diagnostics,
1044 },
1045 },
1046 ))),
1047 Err(e) => {
1048 log::error!("Failed to get diagnostics: {e}");
1049 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1050 RelatedFullDocumentDiagnosticReport {
1051 related_documents: None,
1052 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1053 result_id: None,
1054 items: Vec::new(),
1055 },
1056 },
1057 )))
1058 }
1059 }
1060 } else {
1061 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1062 RelatedFullDocumentDiagnosticReport {
1063 related_documents: None,
1064 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1065 result_id: None,
1066 items: Vec::new(),
1067 },
1068 },
1069 )))
1070 }
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077 use crate::rule::LintWarning;
1078 use tower_lsp::LspService;
1079
1080 fn create_test_server() -> RumdlLanguageServer {
1081 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1082 service.inner().clone()
1083 }
1084
1085 #[tokio::test]
1086 async fn test_server_creation() {
1087 let server = create_test_server();
1088
1089 let config = server.config.read().await;
1091 assert!(config.enable_linting);
1092 assert!(!config.enable_auto_fix);
1093 }
1094
1095 #[tokio::test]
1096 async fn test_lint_document() {
1097 let server = create_test_server();
1098
1099 let uri = Url::parse("file:///test.md").unwrap();
1101 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1102
1103 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1104
1105 assert!(!diagnostics.is_empty());
1107 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1108 }
1109
1110 #[tokio::test]
1111 async fn test_lint_document_disabled() {
1112 let server = create_test_server();
1113
1114 server.config.write().await.enable_linting = false;
1116
1117 let uri = Url::parse("file:///test.md").unwrap();
1118 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1119
1120 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1121
1122 assert!(diagnostics.is_empty());
1124 }
1125
1126 #[tokio::test]
1127 async fn test_get_code_actions() {
1128 let server = create_test_server();
1129
1130 let uri = Url::parse("file:///test.md").unwrap();
1131 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1132
1133 let range = Range {
1135 start: Position { line: 0, character: 0 },
1136 end: Position { line: 3, character: 21 },
1137 };
1138
1139 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1140
1141 assert!(!actions.is_empty());
1143 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1144 }
1145
1146 #[tokio::test]
1147 async fn test_get_code_actions_outside_range() {
1148 let server = create_test_server();
1149
1150 let uri = Url::parse("file:///test.md").unwrap();
1151 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1152
1153 let range = Range {
1155 start: Position { line: 0, character: 0 },
1156 end: Position { line: 0, character: 6 },
1157 };
1158
1159 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1160
1161 assert!(actions.is_empty());
1163 }
1164
1165 #[tokio::test]
1166 async fn test_document_storage() {
1167 let server = create_test_server();
1168
1169 let uri = Url::parse("file:///test.md").unwrap();
1170 let text = "# Test Document";
1171
1172 let entry = DocumentEntry {
1174 content: text.to_string(),
1175 version: Some(1),
1176 from_disk: false,
1177 };
1178 server.documents.write().await.insert(uri.clone(), entry);
1179
1180 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1182 assert_eq!(stored, Some(text.to_string()));
1183
1184 server.documents.write().await.remove(&uri);
1186
1187 let stored = server.documents.read().await.get(&uri).cloned();
1189 assert_eq!(stored, None);
1190 }
1191
1192 #[tokio::test]
1193 async fn test_configuration_loading() {
1194 let server = create_test_server();
1195
1196 server.load_configuration(false).await;
1198
1199 let rumdl_config = server.rumdl_config.read().await;
1202 drop(rumdl_config); }
1205
1206 #[tokio::test]
1207 async fn test_load_config_for_lsp() {
1208 let result = RumdlLanguageServer::load_config_for_lsp(None);
1210 assert!(result.is_ok());
1211
1212 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1214 assert!(result.is_err());
1215 }
1216
1217 #[tokio::test]
1218 async fn test_warning_conversion() {
1219 let warning = LintWarning {
1220 message: "Test warning".to_string(),
1221 line: 1,
1222 column: 1,
1223 end_line: 1,
1224 end_column: 10,
1225 severity: crate::rule::Severity::Warning,
1226 fix: None,
1227 rule_name: Some("MD001".to_string()),
1228 };
1229
1230 let diagnostic = warning_to_diagnostic(&warning);
1232 assert_eq!(diagnostic.message, "Test warning");
1233 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1234 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1235
1236 let uri = Url::parse("file:///test.md").unwrap();
1238 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1239 assert_eq!(actions.len(), 1);
1241 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1242 }
1243
1244 #[tokio::test]
1245 async fn test_multiple_documents() {
1246 let server = create_test_server();
1247
1248 let uri1 = Url::parse("file:///test1.md").unwrap();
1249 let uri2 = Url::parse("file:///test2.md").unwrap();
1250 let text1 = "# Document 1";
1251 let text2 = "# Document 2";
1252
1253 {
1255 let mut docs = server.documents.write().await;
1256 let entry1 = DocumentEntry {
1257 content: text1.to_string(),
1258 version: Some(1),
1259 from_disk: false,
1260 };
1261 let entry2 = DocumentEntry {
1262 content: text2.to_string(),
1263 version: Some(1),
1264 from_disk: false,
1265 };
1266 docs.insert(uri1.clone(), entry1);
1267 docs.insert(uri2.clone(), entry2);
1268 }
1269
1270 let docs = server.documents.read().await;
1272 assert_eq!(docs.len(), 2);
1273 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1274 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1275 }
1276
1277 #[tokio::test]
1278 async fn test_auto_fix_on_save() {
1279 let server = create_test_server();
1280
1281 {
1283 let mut config = server.config.write().await;
1284 config.enable_auto_fix = true;
1285 }
1286
1287 let uri = Url::parse("file:///test.md").unwrap();
1288 let text = "#Heading without space"; let entry = DocumentEntry {
1292 content: text.to_string(),
1293 version: Some(1),
1294 from_disk: false,
1295 };
1296 server.documents.write().await.insert(uri.clone(), entry);
1297
1298 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1300 assert!(fixed.is_some());
1301 assert_eq!(fixed.unwrap(), "# Heading without space\n");
1303 }
1304
1305 #[tokio::test]
1306 async fn test_get_end_position() {
1307 let server = create_test_server();
1308
1309 let pos = server.get_end_position("Hello");
1311 assert_eq!(pos.line, 0);
1312 assert_eq!(pos.character, 5);
1313
1314 let pos = server.get_end_position("Hello\nWorld\nTest");
1316 assert_eq!(pos.line, 2);
1317 assert_eq!(pos.character, 4);
1318
1319 let pos = server.get_end_position("");
1321 assert_eq!(pos.line, 0);
1322 assert_eq!(pos.character, 0);
1323
1324 let pos = server.get_end_position("Hello\n");
1326 assert_eq!(pos.line, 1);
1327 assert_eq!(pos.character, 0);
1328 }
1329
1330 #[tokio::test]
1331 async fn test_empty_document_handling() {
1332 let server = create_test_server();
1333
1334 let uri = Url::parse("file:///empty.md").unwrap();
1335 let text = "";
1336
1337 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1339 assert!(diagnostics.is_empty());
1340
1341 let range = Range {
1343 start: Position { line: 0, character: 0 },
1344 end: Position { line: 0, character: 0 },
1345 };
1346 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1347 assert!(actions.is_empty());
1348 }
1349
1350 #[tokio::test]
1351 async fn test_config_update() {
1352 let server = create_test_server();
1353
1354 {
1356 let mut config = server.config.write().await;
1357 config.enable_auto_fix = true;
1358 config.config_path = Some("/custom/path.toml".to_string());
1359 }
1360
1361 let config = server.config.read().await;
1363 assert!(config.enable_auto_fix);
1364 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1365 }
1366
1367 #[tokio::test]
1368 async fn test_document_formatting() {
1369 let server = create_test_server();
1370 let uri = Url::parse("file:///test.md").unwrap();
1371 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1372
1373 let entry = DocumentEntry {
1375 content: text.to_string(),
1376 version: Some(1),
1377 from_disk: false,
1378 };
1379 server.documents.write().await.insert(uri.clone(), entry);
1380
1381 let params = DocumentFormattingParams {
1383 text_document: TextDocumentIdentifier { uri: uri.clone() },
1384 options: FormattingOptions {
1385 tab_size: 4,
1386 insert_spaces: true,
1387 properties: HashMap::new(),
1388 trim_trailing_whitespace: Some(true),
1389 insert_final_newline: Some(true),
1390 trim_final_newlines: Some(true),
1391 },
1392 work_done_progress_params: WorkDoneProgressParams::default(),
1393 };
1394
1395 let result = server.formatting(params).await.unwrap();
1397
1398 assert!(result.is_some());
1400 let edits = result.unwrap();
1401 assert!(!edits.is_empty());
1402
1403 let edit = &edits[0];
1405 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
1408 assert_eq!(edit.new_text, expected);
1409 }
1410
1411 #[tokio::test]
1413 async fn test_resolve_config_for_file_multi_root() {
1414 use std::fs;
1415 use tempfile::tempdir;
1416
1417 let temp_dir = tempdir().unwrap();
1418 let temp_path = temp_dir.path();
1419
1420 let project_a = temp_path.join("project_a");
1422 let project_a_docs = project_a.join("docs");
1423 fs::create_dir_all(&project_a_docs).unwrap();
1424
1425 let config_a = project_a.join(".rumdl.toml");
1426 fs::write(
1427 &config_a,
1428 r#"
1429[global]
1430
1431[MD013]
1432line_length = 60
1433"#,
1434 )
1435 .unwrap();
1436
1437 let project_b = temp_path.join("project_b");
1439 fs::create_dir(&project_b).unwrap();
1440
1441 let config_b = project_b.join(".rumdl.toml");
1442 fs::write(
1443 &config_b,
1444 r#"
1445[global]
1446
1447[MD013]
1448line_length = 120
1449"#,
1450 )
1451 .unwrap();
1452
1453 let server = create_test_server();
1455
1456 {
1458 let mut roots = server.workspace_roots.write().await;
1459 roots.push(project_a.clone());
1460 roots.push(project_b.clone());
1461 }
1462
1463 let file_a = project_a_docs.join("test.md");
1465 fs::write(&file_a, "# Test A\n").unwrap();
1466
1467 let config_for_a = server.resolve_config_for_file(&file_a).await;
1468 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1469 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1470
1471 let file_b = project_b.join("test.md");
1473 fs::write(&file_b, "# Test B\n").unwrap();
1474
1475 let config_for_b = server.resolve_config_for_file(&file_b).await;
1476 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1477 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1478 }
1479
1480 #[tokio::test]
1482 async fn test_config_resolution_respects_workspace_boundaries() {
1483 use std::fs;
1484 use tempfile::tempdir;
1485
1486 let temp_dir = tempdir().unwrap();
1487 let temp_path = temp_dir.path();
1488
1489 let parent_config = temp_path.join(".rumdl.toml");
1491 fs::write(
1492 &parent_config,
1493 r#"
1494[global]
1495
1496[MD013]
1497line_length = 80
1498"#,
1499 )
1500 .unwrap();
1501
1502 let workspace_root = temp_path.join("workspace");
1504 let workspace_subdir = workspace_root.join("subdir");
1505 fs::create_dir_all(&workspace_subdir).unwrap();
1506
1507 let workspace_config = workspace_root.join(".rumdl.toml");
1508 fs::write(
1509 &workspace_config,
1510 r#"
1511[global]
1512
1513[MD013]
1514line_length = 100
1515"#,
1516 )
1517 .unwrap();
1518
1519 let server = create_test_server();
1520
1521 {
1523 let mut roots = server.workspace_roots.write().await;
1524 roots.push(workspace_root.clone());
1525 }
1526
1527 let test_file = workspace_subdir.join("deep").join("test.md");
1529 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1530 fs::write(&test_file, "# Test\n").unwrap();
1531
1532 let config = server.resolve_config_for_file(&test_file).await;
1533 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1534
1535 assert_eq!(
1537 line_length,
1538 Some(100),
1539 "Should find workspace config, not parent config outside workspace"
1540 );
1541 }
1542
1543 #[tokio::test]
1545 async fn test_config_cache_hit() {
1546 use std::fs;
1547 use tempfile::tempdir;
1548
1549 let temp_dir = tempdir().unwrap();
1550 let temp_path = temp_dir.path();
1551
1552 let project = temp_path.join("project");
1553 fs::create_dir(&project).unwrap();
1554
1555 let config_file = project.join(".rumdl.toml");
1556 fs::write(
1557 &config_file,
1558 r#"
1559[global]
1560
1561[MD013]
1562line_length = 75
1563"#,
1564 )
1565 .unwrap();
1566
1567 let server = create_test_server();
1568 {
1569 let mut roots = server.workspace_roots.write().await;
1570 roots.push(project.clone());
1571 }
1572
1573 let test_file = project.join("test.md");
1574 fs::write(&test_file, "# Test\n").unwrap();
1575
1576 let config1 = server.resolve_config_for_file(&test_file).await;
1578 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1579 assert_eq!(line_length1, Some(75));
1580
1581 {
1583 let cache = server.config_cache.read().await;
1584 let search_dir = test_file.parent().unwrap();
1585 assert!(
1586 cache.contains_key(search_dir),
1587 "Cache should be populated after first call"
1588 );
1589 }
1590
1591 let config2 = server.resolve_config_for_file(&test_file).await;
1593 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1594 assert_eq!(line_length2, Some(75));
1595 }
1596
1597 #[tokio::test]
1599 async fn test_nested_directory_config_search() {
1600 use std::fs;
1601 use tempfile::tempdir;
1602
1603 let temp_dir = tempdir().unwrap();
1604 let temp_path = temp_dir.path();
1605
1606 let project = temp_path.join("project");
1607 fs::create_dir(&project).unwrap();
1608
1609 let config = project.join(".rumdl.toml");
1611 fs::write(
1612 &config,
1613 r#"
1614[global]
1615
1616[MD013]
1617line_length = 110
1618"#,
1619 )
1620 .unwrap();
1621
1622 let deep_dir = project.join("src").join("docs").join("guides");
1624 fs::create_dir_all(&deep_dir).unwrap();
1625 let deep_file = deep_dir.join("test.md");
1626 fs::write(&deep_file, "# Test\n").unwrap();
1627
1628 let server = create_test_server();
1629 {
1630 let mut roots = server.workspace_roots.write().await;
1631 roots.push(project.clone());
1632 }
1633
1634 let resolved_config = server.resolve_config_for_file(&deep_file).await;
1635 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1636
1637 assert_eq!(
1638 line_length,
1639 Some(110),
1640 "Should find config by searching upward from deep directory"
1641 );
1642 }
1643
1644 #[tokio::test]
1646 async fn test_fallback_to_default_config() {
1647 use std::fs;
1648 use tempfile::tempdir;
1649
1650 let temp_dir = tempdir().unwrap();
1651 let temp_path = temp_dir.path();
1652
1653 let project = temp_path.join("project");
1654 fs::create_dir(&project).unwrap();
1655
1656 let test_file = project.join("test.md");
1659 fs::write(&test_file, "# Test\n").unwrap();
1660
1661 let server = create_test_server();
1662 {
1663 let mut roots = server.workspace_roots.write().await;
1664 roots.push(project.clone());
1665 }
1666
1667 let config = server.resolve_config_for_file(&test_file).await;
1668
1669 assert_eq!(
1671 config.global.line_length, 80,
1672 "Should fall back to default config when no config file found"
1673 );
1674 }
1675
1676 #[tokio::test]
1678 async fn test_config_priority_closer_wins() {
1679 use std::fs;
1680 use tempfile::tempdir;
1681
1682 let temp_dir = tempdir().unwrap();
1683 let temp_path = temp_dir.path();
1684
1685 let project = temp_path.join("project");
1686 fs::create_dir(&project).unwrap();
1687
1688 let parent_config = project.join(".rumdl.toml");
1690 fs::write(
1691 &parent_config,
1692 r#"
1693[global]
1694
1695[MD013]
1696line_length = 100
1697"#,
1698 )
1699 .unwrap();
1700
1701 let subdir = project.join("subdir");
1703 fs::create_dir(&subdir).unwrap();
1704
1705 let subdir_config = subdir.join(".rumdl.toml");
1706 fs::write(
1707 &subdir_config,
1708 r#"
1709[global]
1710
1711[MD013]
1712line_length = 50
1713"#,
1714 )
1715 .unwrap();
1716
1717 let server = create_test_server();
1718 {
1719 let mut roots = server.workspace_roots.write().await;
1720 roots.push(project.clone());
1721 }
1722
1723 let test_file = subdir.join("test.md");
1725 fs::write(&test_file, "# Test\n").unwrap();
1726
1727 let config = server.resolve_config_for_file(&test_file).await;
1728 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1729
1730 assert_eq!(
1731 line_length,
1732 Some(50),
1733 "Closer config (subdir) should override parent config"
1734 );
1735 }
1736
1737 #[tokio::test]
1743 async fn test_issue_131_pyproject_without_rumdl_section() {
1744 use std::fs;
1745 use tempfile::tempdir;
1746
1747 let parent_dir = tempdir().unwrap();
1749
1750 let project_dir = parent_dir.path().join("project");
1752 fs::create_dir(&project_dir).unwrap();
1753
1754 fs::write(
1756 project_dir.join("pyproject.toml"),
1757 r#"
1758[project]
1759name = "test-project"
1760version = "0.1.0"
1761"#,
1762 )
1763 .unwrap();
1764
1765 fs::write(
1768 parent_dir.path().join(".rumdl.toml"),
1769 r#"
1770[global]
1771disable = ["MD013"]
1772"#,
1773 )
1774 .unwrap();
1775
1776 let test_file = project_dir.join("test.md");
1777 fs::write(&test_file, "# Test\n").unwrap();
1778
1779 let server = create_test_server();
1780
1781 {
1783 let mut roots = server.workspace_roots.write().await;
1784 roots.push(parent_dir.path().to_path_buf());
1785 }
1786
1787 let config = server.resolve_config_for_file(&test_file).await;
1789
1790 assert!(
1793 config.global.disable.contains(&"MD013".to_string()),
1794 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
1795 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
1796 );
1797
1798 let cache = server.config_cache.read().await;
1801 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1802
1803 assert!(
1804 cache_entry.config_file.is_some(),
1805 "Should have found a config file (parent .rumdl.toml)"
1806 );
1807
1808 let found_config_path = cache_entry.config_file.as_ref().unwrap();
1809 assert!(
1810 found_config_path.ends_with(".rumdl.toml"),
1811 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
1812 );
1813 assert!(
1814 found_config_path.parent().unwrap() == parent_dir.path(),
1815 "Should have loaded config from parent directory, not project_dir"
1816 );
1817 }
1818
1819 #[tokio::test]
1824 async fn test_issue_131_pyproject_with_rumdl_section() {
1825 use std::fs;
1826 use tempfile::tempdir;
1827
1828 let parent_dir = tempdir().unwrap();
1830
1831 let project_dir = parent_dir.path().join("project");
1833 fs::create_dir(&project_dir).unwrap();
1834
1835 fs::write(
1837 project_dir.join("pyproject.toml"),
1838 r#"
1839[project]
1840name = "test-project"
1841
1842[tool.rumdl.global]
1843disable = ["MD033"]
1844"#,
1845 )
1846 .unwrap();
1847
1848 fs::write(
1850 parent_dir.path().join(".rumdl.toml"),
1851 r#"
1852[global]
1853disable = ["MD041"]
1854"#,
1855 )
1856 .unwrap();
1857
1858 let test_file = project_dir.join("test.md");
1859 fs::write(&test_file, "# Test\n").unwrap();
1860
1861 let server = create_test_server();
1862
1863 {
1865 let mut roots = server.workspace_roots.write().await;
1866 roots.push(parent_dir.path().to_path_buf());
1867 }
1868
1869 let config = server.resolve_config_for_file(&test_file).await;
1871
1872 assert!(
1874 config.global.disable.contains(&"MD033".to_string()),
1875 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
1876 Expected MD033 from project_dir pyproject.toml to be disabled."
1877 );
1878
1879 assert!(
1881 !config.global.disable.contains(&"MD041".to_string()),
1882 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
1883 );
1884
1885 let cache = server.config_cache.read().await;
1887 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1888
1889 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
1890
1891 let found_config_path = cache_entry.config_file.as_ref().unwrap();
1892 assert!(
1893 found_config_path.ends_with("pyproject.toml"),
1894 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
1895 );
1896 assert!(
1897 found_config_path.parent().unwrap() == project_dir,
1898 "Should have loaded pyproject.toml from project_dir, not parent"
1899 );
1900 }
1901
1902 #[tokio::test]
1907 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
1908 use std::fs;
1909 use tempfile::tempdir;
1910
1911 let temp_dir = tempdir().unwrap();
1912
1913 fs::write(
1915 temp_dir.path().join("pyproject.toml"),
1916 r#"
1917[project]
1918name = "test-project"
1919
1920[tool.rumdl.global]
1921disable = ["MD022"]
1922"#,
1923 )
1924 .unwrap();
1925
1926 let test_file = temp_dir.path().join("test.md");
1927 fs::write(&test_file, "# Test\n").unwrap();
1928
1929 let server = create_test_server();
1930
1931 {
1933 let mut roots = server.workspace_roots.write().await;
1934 roots.push(temp_dir.path().to_path_buf());
1935 }
1936
1937 let config = server.resolve_config_for_file(&test_file).await;
1939
1940 assert!(
1942 config.global.disable.contains(&"MD022".to_string()),
1943 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
1944 );
1945
1946 let cache = server.config_cache.read().await;
1948 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
1949 assert!(
1950 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
1951 "Should have loaded pyproject.toml"
1952 );
1953 }
1954}