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) -> Self {
73 Self {
74 client,
75 config: Arc::new(RwLock::new(RumdlLspConfig::default())),
76 rumdl_config: Arc::new(RwLock::new(Config::default())),
77 documents: Arc::new(RwLock::new(HashMap::new())),
78 workspace_roots: Arc::new(RwLock::new(Vec::new())),
79 config_cache: Arc::new(RwLock::new(HashMap::new())),
80 }
81 }
82
83 async fn get_document_content(&self, uri: &Url) -> Option<String> {
89 {
91 let docs = self.documents.read().await;
92 if let Some(entry) = docs.get(uri) {
93 return Some(entry.content.clone());
94 }
95 }
96
97 if let Ok(path) = uri.to_file_path() {
99 if let Ok(content) = tokio::fs::read_to_string(&path).await {
100 let entry = DocumentEntry {
102 content: content.clone(),
103 version: None,
104 from_disk: true,
105 };
106
107 let mut docs = self.documents.write().await;
108 docs.insert(uri.clone(), entry);
109
110 log::debug!("Loaded document from disk and cached: {uri}");
111 return Some(content);
112 } else {
113 log::debug!("Failed to read file from disk: {uri}");
114 }
115 }
116
117 None
118 }
119
120 fn apply_lsp_config_overrides(
122 &self,
123 mut filtered_rules: Vec<Box<dyn Rule>>,
124 lsp_config: &RumdlLspConfig,
125 ) -> Vec<Box<dyn Rule>> {
126 if let Some(enable) = &lsp_config.enable_rules
128 && !enable.is_empty()
129 {
130 let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
131 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
132 }
133
134 if let Some(disable) = &lsp_config.disable_rules
136 && !disable.is_empty()
137 {
138 let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
139 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
140 }
141
142 filtered_rules
143 }
144
145 async fn should_exclude_uri(&self, uri: &Url) -> bool {
147 let file_path = match uri.to_file_path() {
149 Ok(path) => path,
150 Err(_) => return false, };
152
153 let rumdl_config = self.resolve_config_for_file(&file_path).await;
155 let exclude_patterns = &rumdl_config.global.exclude;
156
157 if exclude_patterns.is_empty() {
159 return false;
160 }
161
162 let path_to_check = if file_path.is_absolute() {
165 if let Ok(cwd) = std::env::current_dir() {
167 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
169 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
170 relative.to_string_lossy().to_string()
171 } else {
172 file_path.to_string_lossy().to_string()
174 }
175 } else {
176 file_path.to_string_lossy().to_string()
178 }
179 } else {
180 file_path.to_string_lossy().to_string()
181 }
182 } else {
183 file_path.to_string_lossy().to_string()
185 };
186
187 for pattern in exclude_patterns {
189 if let Ok(glob) = globset::Glob::new(pattern) {
190 let matcher = glob.compile_matcher();
191 if matcher.is_match(&path_to_check) {
192 log::debug!("Excluding file from LSP linting: {path_to_check}");
193 return true;
194 }
195 }
196 }
197
198 false
199 }
200
201 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
203 let config_guard = self.config.read().await;
204
205 if !config_guard.enable_linting {
207 return Ok(Vec::new());
208 }
209
210 let lsp_config = config_guard.clone();
211 drop(config_guard); if self.should_exclude_uri(uri).await {
215 return Ok(Vec::new());
216 }
217
218 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
220 self.resolve_config_for_file(&file_path).await
221 } else {
222 (*self.rumdl_config.read().await).clone()
224 };
225
226 let all_rules = rules::all_rules(&rumdl_config);
227 let flavor = rumdl_config.markdown_flavor();
228
229 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
231
232 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
234
235 match crate::lint(text, &filtered_rules, false, flavor) {
237 Ok(warnings) => {
238 let diagnostics = warnings.iter().map(warning_to_diagnostic).collect();
239 Ok(diagnostics)
240 }
241 Err(e) => {
242 log::error!("Failed to lint document {uri}: {e}");
243 Ok(Vec::new())
244 }
245 }
246 }
247
248 async fn update_diagnostics(&self, uri: Url, text: String) {
250 let version = {
252 let docs = self.documents.read().await;
253 docs.get(&uri).and_then(|entry| entry.version)
254 };
255
256 match self.lint_document(&uri, &text).await {
257 Ok(diagnostics) => {
258 self.client.publish_diagnostics(uri, diagnostics, version).await;
259 }
260 Err(e) => {
261 log::error!("Failed to update diagnostics: {e}");
262 }
263 }
264 }
265
266 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
268 if self.should_exclude_uri(uri).await {
270 return Ok(None);
271 }
272
273 let config_guard = self.config.read().await;
274 let lsp_config = config_guard.clone();
275 drop(config_guard);
276
277 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
279 self.resolve_config_for_file(&file_path).await
280 } else {
281 (*self.rumdl_config.read().await).clone()
283 };
284
285 let all_rules = rules::all_rules(&rumdl_config);
286 let flavor = rumdl_config.markdown_flavor();
287
288 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
290
291 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
293
294 let mut rules_with_warnings = std::collections::HashSet::new();
297 let mut fixed_text = text.to_string();
298
299 match lint(&fixed_text, &filtered_rules, false, flavor) {
300 Ok(warnings) => {
301 for warning in warnings {
302 if let Some(rule_name) = &warning.rule_name {
303 rules_with_warnings.insert(rule_name.clone());
304 }
305 }
306 }
307 Err(e) => {
308 log::warn!("Failed to lint document for auto-fix: {e}");
309 return Ok(None);
310 }
311 }
312
313 if rules_with_warnings.is_empty() {
315 return Ok(None);
316 }
317
318 let mut any_changes = false;
320
321 for rule in &filtered_rules {
322 if !rules_with_warnings.contains(rule.name()) {
324 continue;
325 }
326
327 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
328 match rule.fix(&ctx) {
329 Ok(new_text) => {
330 if new_text != fixed_text {
331 fixed_text = new_text;
332 any_changes = true;
333 }
334 }
335 Err(e) => {
336 let msg = e.to_string();
338 if !msg.contains("does not support automatic fixing") {
339 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
340 }
341 }
342 }
343 }
344
345 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
346 }
347
348 fn get_end_position(&self, text: &str) -> Position {
350 let mut line = 0u32;
351 let mut character = 0u32;
352
353 for ch in text.chars() {
354 if ch == '\n' {
355 line += 1;
356 character = 0;
357 } else {
358 character += 1;
359 }
360 }
361
362 Position { line, character }
363 }
364
365 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
367 let config_guard = self.config.read().await;
368 let lsp_config = config_guard.clone();
369 drop(config_guard);
370
371 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
373 self.resolve_config_for_file(&file_path).await
374 } else {
375 (*self.rumdl_config.read().await).clone()
377 };
378
379 let all_rules = rules::all_rules(&rumdl_config);
380 let flavor = rumdl_config.markdown_flavor();
381
382 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
384
385 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
387
388 match crate::lint(text, &filtered_rules, false, flavor) {
389 Ok(warnings) => {
390 let mut actions = Vec::new();
391 let mut fixable_count = 0;
392
393 for warning in &warnings {
394 let warning_line = (warning.line.saturating_sub(1)) as u32;
396 if warning_line >= range.start.line && warning_line <= range.end.line {
397 let mut warning_actions = warning_to_code_actions(warning, uri, text);
399 actions.append(&mut warning_actions);
400
401 if warning.fix.is_some() {
402 fixable_count += 1;
403 }
404 }
405 }
406
407 if fixable_count > 1 {
409 let total_fixable = warnings.iter().filter(|w| w.fix.is_some()).count();
411
412 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &warnings)
413 && fixed_content != text
414 {
415 let mut line = 0u32;
417 let mut character = 0u32;
418 for ch in text.chars() {
419 if ch == '\n' {
420 line += 1;
421 character = 0;
422 } else {
423 character += 1;
424 }
425 }
426
427 let fix_all_action = CodeAction {
428 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
429 kind: Some(CodeActionKind::QUICKFIX),
430 diagnostics: Some(Vec::new()),
431 edit: Some(WorkspaceEdit {
432 changes: Some(
433 [(
434 uri.clone(),
435 vec![TextEdit {
436 range: Range {
437 start: Position { line: 0, character: 0 },
438 end: Position { line, character },
439 },
440 new_text: fixed_content,
441 }],
442 )]
443 .into_iter()
444 .collect(),
445 ),
446 ..Default::default()
447 }),
448 command: None,
449 is_preferred: Some(true),
450 disabled: None,
451 data: None,
452 };
453
454 actions.insert(0, fix_all_action);
456 }
457 }
458
459 Ok(actions)
460 }
461 Err(e) => {
462 log::error!("Failed to get code actions: {e}");
463 Ok(Vec::new())
464 }
465 }
466 }
467
468 async fn load_configuration(&self, notify_client: bool) {
470 let config_guard = self.config.read().await;
471 let explicit_config_path = config_guard.config_path.clone();
472 drop(config_guard);
473
474 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
476 Ok(sourced_config) => {
477 let loaded_files = sourced_config.loaded_files.clone();
478 *self.rumdl_config.write().await = sourced_config.into();
479
480 if !loaded_files.is_empty() {
481 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
482 log::info!("{message}");
483 if notify_client {
484 self.client.log_message(MessageType::INFO, &message).await;
485 }
486 } else {
487 log::info!("Using default rumdl configuration (no config files found)");
488 }
489 }
490 Err(e) => {
491 let message = format!("Failed to load rumdl config: {e}");
492 log::warn!("{message}");
493 if notify_client {
494 self.client.log_message(MessageType::WARNING, &message).await;
495 }
496 *self.rumdl_config.write().await = crate::config::Config::default();
498 }
499 }
500 }
501
502 async fn reload_configuration(&self) {
504 self.load_configuration(true).await;
505 }
506
507 fn load_config_for_lsp(
509 config_path: Option<&str>,
510 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
511 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
513 }
514
515 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
522 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
524
525 {
527 let cache = self.config_cache.read().await;
528 if let Some(entry) = cache.get(&search_dir) {
529 let source_owned: String; let source: &str = if entry.from_global_fallback {
531 "global/user fallback"
532 } else if let Some(path) = &entry.config_file {
533 source_owned = path.to_string_lossy().to_string();
534 &source_owned
535 } else {
536 "<unknown>"
537 };
538 log::debug!(
539 "Config cache hit for directory: {} (loaded from: {})",
540 search_dir.display(),
541 source
542 );
543 return entry.config.clone();
544 }
545 }
546
547 log::debug!(
549 "Config cache miss for directory: {}, searching for config...",
550 search_dir.display()
551 );
552
553 let workspace_root = {
555 let workspace_roots = self.workspace_roots.read().await;
556 workspace_roots
557 .iter()
558 .find(|root| search_dir.starts_with(root))
559 .map(|p| p.to_path_buf())
560 };
561
562 let mut current_dir = search_dir.clone();
564 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
565
566 loop {
567 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
569
570 for config_file_name in CONFIG_FILES {
571 let config_path = current_dir.join(config_file_name);
572 if config_path.exists() {
573 if *config_file_name == "pyproject.toml" {
575 if let Ok(content) = std::fs::read_to_string(&config_path) {
576 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
577 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
578 } else {
579 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
580 continue;
581 }
582 } else {
583 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
584 continue;
585 }
586 } else {
587 log::debug!("Found config file: {}", config_path.display());
588 }
589
590 if let Some(config_path_str) = config_path.to_str() {
592 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
593 found_config = Some((sourced.into(), Some(config_path)));
594 break;
595 }
596 } else {
597 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
598 }
599 }
600 }
601
602 if found_config.is_some() {
603 break;
604 }
605
606 if let Some(ref root) = workspace_root
608 && ¤t_dir == root
609 {
610 log::debug!("Hit workspace root without finding config: {}", root.display());
611 break;
612 }
613
614 if let Some(parent) = current_dir.parent() {
616 current_dir = parent.to_path_buf();
617 } else {
618 break;
620 }
621 }
622
623 let (config, config_file) = if let Some((cfg, path)) = found_config {
625 (cfg, path)
626 } else {
627 log::debug!("No project config found; using global/user fallback config");
628 let fallback = self.rumdl_config.read().await.clone();
629 (fallback, None)
630 };
631
632 let from_global = config_file.is_none();
634 let entry = ConfigCacheEntry {
635 config: config.clone(),
636 config_file,
637 from_global_fallback: from_global,
638 };
639
640 self.config_cache.write().await.insert(search_dir, entry);
641
642 config
643 }
644}
645
646#[tower_lsp::async_trait]
647impl LanguageServer for RumdlLanguageServer {
648 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
649 log::info!("Initializing rumdl Language Server");
650
651 if let Some(options) = params.initialization_options
653 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
654 {
655 *self.config.write().await = config;
656 }
657
658 let mut roots = Vec::new();
660 if let Some(workspace_folders) = params.workspace_folders {
661 for folder in workspace_folders {
662 if let Ok(path) = folder.uri.to_file_path() {
663 log::info!("Workspace root: {}", path.display());
664 roots.push(path);
665 }
666 }
667 } else if let Some(root_uri) = params.root_uri
668 && let Ok(path) = root_uri.to_file_path()
669 {
670 log::info!("Workspace root: {}", path.display());
671 roots.push(path);
672 }
673 *self.workspace_roots.write().await = roots;
674
675 self.load_configuration(false).await;
677
678 Ok(InitializeResult {
679 capabilities: ServerCapabilities {
680 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
681 open_close: Some(true),
682 change: Some(TextDocumentSyncKind::FULL),
683 will_save: Some(false),
684 will_save_wait_until: Some(true),
685 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
686 include_text: Some(false),
687 })),
688 })),
689 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
690 document_formatting_provider: Some(OneOf::Left(true)),
691 document_range_formatting_provider: Some(OneOf::Left(true)),
692 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
693 identifier: Some("rumdl".to_string()),
694 inter_file_dependencies: false,
695 workspace_diagnostics: false,
696 work_done_progress_options: WorkDoneProgressOptions::default(),
697 })),
698 workspace: Some(WorkspaceServerCapabilities {
699 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
700 supported: Some(true),
701 change_notifications: Some(OneOf::Left(true)),
702 }),
703 file_operations: None,
704 }),
705 ..Default::default()
706 },
707 server_info: Some(ServerInfo {
708 name: "rumdl".to_string(),
709 version: Some(env!("CARGO_PKG_VERSION").to_string()),
710 }),
711 })
712 }
713
714 async fn initialized(&self, _: InitializedParams) {
715 let version = env!("CARGO_PKG_VERSION");
716
717 let (binary_path, build_time) = std::env::current_exe()
719 .ok()
720 .map(|path| {
721 let path_str = path.to_str().unwrap_or("unknown").to_string();
722 let build_time = std::fs::metadata(&path)
723 .ok()
724 .and_then(|metadata| metadata.modified().ok())
725 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
726 .and_then(|duration| {
727 let secs = duration.as_secs();
728 chrono::DateTime::from_timestamp(secs as i64, 0)
729 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
730 })
731 .unwrap_or_else(|| "unknown".to_string());
732 (path_str, build_time)
733 })
734 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
735
736 let working_dir = std::env::current_dir()
737 .ok()
738 .and_then(|p| p.to_str().map(|s| s.to_string()))
739 .unwrap_or_else(|| "unknown".to_string());
740
741 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
742 log::info!("Working directory: {working_dir}");
743
744 self.client
745 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
746 .await;
747 }
748
749 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
750 let mut roots = self.workspace_roots.write().await;
752
753 for removed in ¶ms.event.removed {
755 if let Ok(path) = removed.uri.to_file_path() {
756 roots.retain(|r| r != &path);
757 log::info!("Removed workspace root: {}", path.display());
758 }
759 }
760
761 for added in ¶ms.event.added {
763 if let Ok(path) = added.uri.to_file_path()
764 && !roots.contains(&path)
765 {
766 log::info!("Added workspace root: {}", path.display());
767 roots.push(path);
768 }
769 }
770 drop(roots);
771
772 self.config_cache.write().await.clear();
774
775 self.reload_configuration().await;
777 }
778
779 async fn shutdown(&self) -> JsonRpcResult<()> {
780 log::info!("Shutting down rumdl Language Server");
781 Ok(())
782 }
783
784 async fn did_open(&self, params: DidOpenTextDocumentParams) {
785 let uri = params.text_document.uri;
786 let text = params.text_document.text;
787 let version = params.text_document.version;
788
789 let entry = DocumentEntry {
790 content: text.clone(),
791 version: Some(version),
792 from_disk: false,
793 };
794 self.documents.write().await.insert(uri.clone(), entry);
795
796 self.update_diagnostics(uri, text).await;
797 }
798
799 async fn did_change(&self, params: DidChangeTextDocumentParams) {
800 let uri = params.text_document.uri;
801 let version = params.text_document.version;
802
803 if let Some(change) = params.content_changes.into_iter().next() {
804 let text = change.text;
805
806 let entry = DocumentEntry {
807 content: text.clone(),
808 version: Some(version),
809 from_disk: false,
810 };
811 self.documents.write().await.insert(uri.clone(), entry);
812
813 self.update_diagnostics(uri, text).await;
814 }
815 }
816
817 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
818 let config_guard = self.config.read().await;
819 let enable_auto_fix = config_guard.enable_auto_fix;
820 drop(config_guard);
821
822 if !enable_auto_fix {
823 return Ok(None);
824 }
825
826 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
828 return Ok(None);
829 };
830
831 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
833 Ok(Some(fixed_text)) => {
834 Ok(Some(vec![TextEdit {
836 range: Range {
837 start: Position { line: 0, character: 0 },
838 end: self.get_end_position(&text),
839 },
840 new_text: fixed_text,
841 }]))
842 }
843 Ok(None) => Ok(None),
844 Err(e) => {
845 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
846 Ok(None)
847 }
848 }
849 }
850
851 async fn did_save(&self, params: DidSaveTextDocumentParams) {
852 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
855 self.update_diagnostics(params.text_document.uri, entry.content.clone())
856 .await;
857 }
858 }
859
860 async fn did_close(&self, params: DidCloseTextDocumentParams) {
861 self.documents.write().await.remove(¶ms.text_document.uri);
863
864 self.client
866 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
867 .await;
868 }
869
870 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
871 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
873
874 for change in ¶ms.changes {
875 if let Ok(path) = change.uri.to_file_path()
876 && let Some(file_name) = path.file_name().and_then(|f| f.to_str())
877 && CONFIG_FILES.contains(&file_name)
878 {
879 log::info!("Config file changed: {}, invalidating config cache", path.display());
880
881 let mut cache = self.config_cache.write().await;
883 cache.retain(|_, entry| {
884 if let Some(config_file) = &entry.config_file {
885 config_file != &path
886 } else {
887 true
888 }
889 });
890
891 drop(cache);
893 self.reload_configuration().await;
894
895 let docs_to_update: Vec<(Url, String)> = {
898 let docs = self.documents.read().await;
899 docs.iter()
900 .filter(|(_, entry)| !entry.from_disk)
901 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
902 .collect()
903 };
904
905 for (uri, text) in docs_to_update {
907 self.update_diagnostics(uri, text).await;
908 }
909
910 break;
911 }
912 }
913 }
914
915 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
916 let uri = params.text_document.uri;
917 let range = params.range;
918
919 if let Some(text) = self.get_document_content(&uri).await {
920 match self.get_code_actions(&uri, &text, range).await {
921 Ok(actions) => {
922 let response: Vec<CodeActionOrCommand> =
923 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
924 Ok(Some(response))
925 }
926 Err(e) => {
927 log::error!("Failed to get code actions: {e}");
928 Ok(None)
929 }
930 }
931 } else {
932 Ok(None)
933 }
934 }
935
936 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
937 log::debug!(
942 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
943 params.range
944 );
945
946 let formatting_params = DocumentFormattingParams {
947 text_document: params.text_document,
948 options: params.options,
949 work_done_progress_params: params.work_done_progress_params,
950 };
951
952 self.formatting(formatting_params).await
953 }
954
955 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
956 let uri = params.text_document.uri;
957
958 log::debug!("Formatting request for: {uri}");
959
960 if let Some(text) = self.get_document_content(&uri).await {
961 let config_guard = self.config.read().await;
963 let lsp_config = config_guard.clone();
964 drop(config_guard);
965
966 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
968 self.resolve_config_for_file(&file_path).await
969 } else {
970 self.rumdl_config.read().await.clone()
972 };
973
974 let all_rules = rules::all_rules(&rumdl_config);
975 let flavor = rumdl_config.markdown_flavor();
976
977 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
979
980 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
982
983 match crate::lint(&text, &filtered_rules, false, flavor) {
985 Ok(warnings) => {
986 log::debug!(
987 "Found {} warnings, {} with fixes",
988 warnings.len(),
989 warnings.iter().filter(|w| w.fix.is_some()).count()
990 );
991
992 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
993 if has_fixes {
994 match crate::utils::fix_utils::apply_warning_fixes(&text, &warnings) {
995 Ok(fixed_content) => {
996 if fixed_content != text {
997 log::debug!("Returning formatting edits");
998 let end_position = self.get_end_position(&text);
999 let edit = TextEdit {
1000 range: Range {
1001 start: Position { line: 0, character: 0 },
1002 end: end_position,
1003 },
1004 new_text: fixed_content,
1005 };
1006 return Ok(Some(vec![edit]));
1007 }
1008 }
1009 Err(e) => {
1010 log::error!("Failed to apply fixes: {e}");
1011 }
1012 }
1013 }
1014 Ok(Some(Vec::new()))
1015 }
1016 Err(e) => {
1017 log::error!("Failed to format document: {e}");
1018 Ok(Some(Vec::new()))
1019 }
1020 }
1021 } else {
1022 log::warn!("Document not found: {uri}");
1023 Ok(None)
1024 }
1025 }
1026
1027 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1028 let uri = params.text_document.uri;
1029
1030 if let Some(text) = self.get_document_content(&uri).await {
1031 match self.lint_document(&uri, &text).await {
1032 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1033 RelatedFullDocumentDiagnosticReport {
1034 related_documents: None,
1035 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1036 result_id: None,
1037 items: diagnostics,
1038 },
1039 },
1040 ))),
1041 Err(e) => {
1042 log::error!("Failed to get diagnostics: {e}");
1043 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1044 RelatedFullDocumentDiagnosticReport {
1045 related_documents: None,
1046 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1047 result_id: None,
1048 items: Vec::new(),
1049 },
1050 },
1051 )))
1052 }
1053 }
1054 } else {
1055 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1056 RelatedFullDocumentDiagnosticReport {
1057 related_documents: None,
1058 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1059 result_id: None,
1060 items: Vec::new(),
1061 },
1062 },
1063 )))
1064 }
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use crate::rule::LintWarning;
1072 use tower_lsp::LspService;
1073
1074 fn create_test_server() -> RumdlLanguageServer {
1075 let (service, _socket) = LspService::new(RumdlLanguageServer::new);
1076 service.inner().clone()
1077 }
1078
1079 #[tokio::test]
1080 async fn test_server_creation() {
1081 let server = create_test_server();
1082
1083 let config = server.config.read().await;
1085 assert!(config.enable_linting);
1086 assert!(!config.enable_auto_fix);
1087 }
1088
1089 #[tokio::test]
1090 async fn test_lint_document() {
1091 let server = create_test_server();
1092
1093 let uri = Url::parse("file:///test.md").unwrap();
1095 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1096
1097 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1098
1099 assert!(!diagnostics.is_empty());
1101 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1102 }
1103
1104 #[tokio::test]
1105 async fn test_lint_document_disabled() {
1106 let server = create_test_server();
1107
1108 server.config.write().await.enable_linting = false;
1110
1111 let uri = Url::parse("file:///test.md").unwrap();
1112 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1113
1114 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1115
1116 assert!(diagnostics.is_empty());
1118 }
1119
1120 #[tokio::test]
1121 async fn test_get_code_actions() {
1122 let server = create_test_server();
1123
1124 let uri = Url::parse("file:///test.md").unwrap();
1125 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1126
1127 let range = Range {
1129 start: Position { line: 0, character: 0 },
1130 end: Position { line: 3, character: 21 },
1131 };
1132
1133 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1134
1135 assert!(!actions.is_empty());
1137 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1138 }
1139
1140 #[tokio::test]
1141 async fn test_get_code_actions_outside_range() {
1142 let server = create_test_server();
1143
1144 let uri = Url::parse("file:///test.md").unwrap();
1145 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1146
1147 let range = Range {
1149 start: Position { line: 0, character: 0 },
1150 end: Position { line: 0, character: 6 },
1151 };
1152
1153 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1154
1155 assert!(actions.is_empty());
1157 }
1158
1159 #[tokio::test]
1160 async fn test_document_storage() {
1161 let server = create_test_server();
1162
1163 let uri = Url::parse("file:///test.md").unwrap();
1164 let text = "# Test Document";
1165
1166 let entry = DocumentEntry {
1168 content: text.to_string(),
1169 version: Some(1),
1170 from_disk: false,
1171 };
1172 server.documents.write().await.insert(uri.clone(), entry);
1173
1174 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1176 assert_eq!(stored, Some(text.to_string()));
1177
1178 server.documents.write().await.remove(&uri);
1180
1181 let stored = server.documents.read().await.get(&uri).cloned();
1183 assert_eq!(stored, None);
1184 }
1185
1186 #[tokio::test]
1187 async fn test_configuration_loading() {
1188 let server = create_test_server();
1189
1190 server.load_configuration(false).await;
1192
1193 let rumdl_config = server.rumdl_config.read().await;
1196 drop(rumdl_config); }
1199
1200 #[tokio::test]
1201 async fn test_load_config_for_lsp() {
1202 let result = RumdlLanguageServer::load_config_for_lsp(None);
1204 assert!(result.is_ok());
1205
1206 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1208 assert!(result.is_err());
1209 }
1210
1211 #[tokio::test]
1212 async fn test_warning_conversion() {
1213 let warning = LintWarning {
1214 message: "Test warning".to_string(),
1215 line: 1,
1216 column: 1,
1217 end_line: 1,
1218 end_column: 10,
1219 severity: crate::rule::Severity::Warning,
1220 fix: None,
1221 rule_name: Some("MD001".to_string()),
1222 };
1223
1224 let diagnostic = warning_to_diagnostic(&warning);
1226 assert_eq!(diagnostic.message, "Test warning");
1227 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1228 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1229
1230 let uri = Url::parse("file:///test.md").unwrap();
1232 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1233 assert_eq!(actions.len(), 1);
1235 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1236 }
1237
1238 #[tokio::test]
1239 async fn test_multiple_documents() {
1240 let server = create_test_server();
1241
1242 let uri1 = Url::parse("file:///test1.md").unwrap();
1243 let uri2 = Url::parse("file:///test2.md").unwrap();
1244 let text1 = "# Document 1";
1245 let text2 = "# Document 2";
1246
1247 {
1249 let mut docs = server.documents.write().await;
1250 let entry1 = DocumentEntry {
1251 content: text1.to_string(),
1252 version: Some(1),
1253 from_disk: false,
1254 };
1255 let entry2 = DocumentEntry {
1256 content: text2.to_string(),
1257 version: Some(1),
1258 from_disk: false,
1259 };
1260 docs.insert(uri1.clone(), entry1);
1261 docs.insert(uri2.clone(), entry2);
1262 }
1263
1264 let docs = server.documents.read().await;
1266 assert_eq!(docs.len(), 2);
1267 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1268 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1269 }
1270
1271 #[tokio::test]
1272 async fn test_auto_fix_on_save() {
1273 let server = create_test_server();
1274
1275 {
1277 let mut config = server.config.write().await;
1278 config.enable_auto_fix = true;
1279 }
1280
1281 let uri = Url::parse("file:///test.md").unwrap();
1282 let text = "#Heading without space"; let entry = DocumentEntry {
1286 content: text.to_string(),
1287 version: Some(1),
1288 from_disk: false,
1289 };
1290 server.documents.write().await.insert(uri.clone(), entry);
1291
1292 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1294 assert!(fixed.is_some());
1295 assert_eq!(fixed.unwrap(), "# Heading without space\n");
1297 }
1298
1299 #[tokio::test]
1300 async fn test_get_end_position() {
1301 let server = create_test_server();
1302
1303 let pos = server.get_end_position("Hello");
1305 assert_eq!(pos.line, 0);
1306 assert_eq!(pos.character, 5);
1307
1308 let pos = server.get_end_position("Hello\nWorld\nTest");
1310 assert_eq!(pos.line, 2);
1311 assert_eq!(pos.character, 4);
1312
1313 let pos = server.get_end_position("");
1315 assert_eq!(pos.line, 0);
1316 assert_eq!(pos.character, 0);
1317
1318 let pos = server.get_end_position("Hello\n");
1320 assert_eq!(pos.line, 1);
1321 assert_eq!(pos.character, 0);
1322 }
1323
1324 #[tokio::test]
1325 async fn test_empty_document_handling() {
1326 let server = create_test_server();
1327
1328 let uri = Url::parse("file:///empty.md").unwrap();
1329 let text = "";
1330
1331 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1333 assert!(diagnostics.is_empty());
1334
1335 let range = Range {
1337 start: Position { line: 0, character: 0 },
1338 end: Position { line: 0, character: 0 },
1339 };
1340 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1341 assert!(actions.is_empty());
1342 }
1343
1344 #[tokio::test]
1345 async fn test_config_update() {
1346 let server = create_test_server();
1347
1348 {
1350 let mut config = server.config.write().await;
1351 config.enable_auto_fix = true;
1352 config.config_path = Some("/custom/path.toml".to_string());
1353 }
1354
1355 let config = server.config.read().await;
1357 assert!(config.enable_auto_fix);
1358 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1359 }
1360
1361 #[tokio::test]
1362 async fn test_document_formatting() {
1363 let server = create_test_server();
1364 let uri = Url::parse("file:///test.md").unwrap();
1365 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1366
1367 let entry = DocumentEntry {
1369 content: text.to_string(),
1370 version: Some(1),
1371 from_disk: false,
1372 };
1373 server.documents.write().await.insert(uri.clone(), entry);
1374
1375 let params = DocumentFormattingParams {
1377 text_document: TextDocumentIdentifier { uri: uri.clone() },
1378 options: FormattingOptions {
1379 tab_size: 4,
1380 insert_spaces: true,
1381 properties: HashMap::new(),
1382 trim_trailing_whitespace: Some(true),
1383 insert_final_newline: Some(true),
1384 trim_final_newlines: Some(true),
1385 },
1386 work_done_progress_params: WorkDoneProgressParams::default(),
1387 };
1388
1389 let result = server.formatting(params).await.unwrap();
1391
1392 assert!(result.is_some());
1394 let edits = result.unwrap();
1395 assert!(!edits.is_empty());
1396
1397 let edit = &edits[0];
1399 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
1402 assert_eq!(edit.new_text, expected);
1403 }
1404
1405 #[tokio::test]
1407 async fn test_resolve_config_for_file_multi_root() {
1408 use std::fs;
1409 use tempfile::tempdir;
1410
1411 let temp_dir = tempdir().unwrap();
1412 let temp_path = temp_dir.path();
1413
1414 let project_a = temp_path.join("project_a");
1416 let project_a_docs = project_a.join("docs");
1417 fs::create_dir_all(&project_a_docs).unwrap();
1418
1419 let config_a = project_a.join(".rumdl.toml");
1420 fs::write(
1421 &config_a,
1422 r#"
1423[global]
1424
1425[MD013]
1426line_length = 60
1427"#,
1428 )
1429 .unwrap();
1430
1431 let project_b = temp_path.join("project_b");
1433 fs::create_dir(&project_b).unwrap();
1434
1435 let config_b = project_b.join(".rumdl.toml");
1436 fs::write(
1437 &config_b,
1438 r#"
1439[global]
1440
1441[MD013]
1442line_length = 120
1443"#,
1444 )
1445 .unwrap();
1446
1447 let server = create_test_server();
1449
1450 {
1452 let mut roots = server.workspace_roots.write().await;
1453 roots.push(project_a.clone());
1454 roots.push(project_b.clone());
1455 }
1456
1457 let file_a = project_a_docs.join("test.md");
1459 fs::write(&file_a, "# Test A\n").unwrap();
1460
1461 let config_for_a = server.resolve_config_for_file(&file_a).await;
1462 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1463 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1464
1465 let file_b = project_b.join("test.md");
1467 fs::write(&file_b, "# Test B\n").unwrap();
1468
1469 let config_for_b = server.resolve_config_for_file(&file_b).await;
1470 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1471 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1472 }
1473
1474 #[tokio::test]
1476 async fn test_config_resolution_respects_workspace_boundaries() {
1477 use std::fs;
1478 use tempfile::tempdir;
1479
1480 let temp_dir = tempdir().unwrap();
1481 let temp_path = temp_dir.path();
1482
1483 let parent_config = temp_path.join(".rumdl.toml");
1485 fs::write(
1486 &parent_config,
1487 r#"
1488[global]
1489
1490[MD013]
1491line_length = 80
1492"#,
1493 )
1494 .unwrap();
1495
1496 let workspace_root = temp_path.join("workspace");
1498 let workspace_subdir = workspace_root.join("subdir");
1499 fs::create_dir_all(&workspace_subdir).unwrap();
1500
1501 let workspace_config = workspace_root.join(".rumdl.toml");
1502 fs::write(
1503 &workspace_config,
1504 r#"
1505[global]
1506
1507[MD013]
1508line_length = 100
1509"#,
1510 )
1511 .unwrap();
1512
1513 let server = create_test_server();
1514
1515 {
1517 let mut roots = server.workspace_roots.write().await;
1518 roots.push(workspace_root.clone());
1519 }
1520
1521 let test_file = workspace_subdir.join("deep").join("test.md");
1523 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1524 fs::write(&test_file, "# Test\n").unwrap();
1525
1526 let config = server.resolve_config_for_file(&test_file).await;
1527 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1528
1529 assert_eq!(
1531 line_length,
1532 Some(100),
1533 "Should find workspace config, not parent config outside workspace"
1534 );
1535 }
1536
1537 #[tokio::test]
1539 async fn test_config_cache_hit() {
1540 use std::fs;
1541 use tempfile::tempdir;
1542
1543 let temp_dir = tempdir().unwrap();
1544 let temp_path = temp_dir.path();
1545
1546 let project = temp_path.join("project");
1547 fs::create_dir(&project).unwrap();
1548
1549 let config_file = project.join(".rumdl.toml");
1550 fs::write(
1551 &config_file,
1552 r#"
1553[global]
1554
1555[MD013]
1556line_length = 75
1557"#,
1558 )
1559 .unwrap();
1560
1561 let server = create_test_server();
1562 {
1563 let mut roots = server.workspace_roots.write().await;
1564 roots.push(project.clone());
1565 }
1566
1567 let test_file = project.join("test.md");
1568 fs::write(&test_file, "# Test\n").unwrap();
1569
1570 let config1 = server.resolve_config_for_file(&test_file).await;
1572 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1573 assert_eq!(line_length1, Some(75));
1574
1575 {
1577 let cache = server.config_cache.read().await;
1578 let search_dir = test_file.parent().unwrap();
1579 assert!(
1580 cache.contains_key(search_dir),
1581 "Cache should be populated after first call"
1582 );
1583 }
1584
1585 let config2 = server.resolve_config_for_file(&test_file).await;
1587 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1588 assert_eq!(line_length2, Some(75));
1589 }
1590
1591 #[tokio::test]
1593 async fn test_nested_directory_config_search() {
1594 use std::fs;
1595 use tempfile::tempdir;
1596
1597 let temp_dir = tempdir().unwrap();
1598 let temp_path = temp_dir.path();
1599
1600 let project = temp_path.join("project");
1601 fs::create_dir(&project).unwrap();
1602
1603 let config = project.join(".rumdl.toml");
1605 fs::write(
1606 &config,
1607 r#"
1608[global]
1609
1610[MD013]
1611line_length = 110
1612"#,
1613 )
1614 .unwrap();
1615
1616 let deep_dir = project.join("src").join("docs").join("guides");
1618 fs::create_dir_all(&deep_dir).unwrap();
1619 let deep_file = deep_dir.join("test.md");
1620 fs::write(&deep_file, "# Test\n").unwrap();
1621
1622 let server = create_test_server();
1623 {
1624 let mut roots = server.workspace_roots.write().await;
1625 roots.push(project.clone());
1626 }
1627
1628 let resolved_config = server.resolve_config_for_file(&deep_file).await;
1629 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1630
1631 assert_eq!(
1632 line_length,
1633 Some(110),
1634 "Should find config by searching upward from deep directory"
1635 );
1636 }
1637
1638 #[tokio::test]
1640 async fn test_fallback_to_default_config() {
1641 use std::fs;
1642 use tempfile::tempdir;
1643
1644 let temp_dir = tempdir().unwrap();
1645 let temp_path = temp_dir.path();
1646
1647 let project = temp_path.join("project");
1648 fs::create_dir(&project).unwrap();
1649
1650 let test_file = project.join("test.md");
1653 fs::write(&test_file, "# Test\n").unwrap();
1654
1655 let server = create_test_server();
1656 {
1657 let mut roots = server.workspace_roots.write().await;
1658 roots.push(project.clone());
1659 }
1660
1661 let config = server.resolve_config_for_file(&test_file).await;
1662
1663 assert_eq!(
1665 config.global.line_length, 80,
1666 "Should fall back to default config when no config file found"
1667 );
1668 }
1669
1670 #[tokio::test]
1672 async fn test_config_priority_closer_wins() {
1673 use std::fs;
1674 use tempfile::tempdir;
1675
1676 let temp_dir = tempdir().unwrap();
1677 let temp_path = temp_dir.path();
1678
1679 let project = temp_path.join("project");
1680 fs::create_dir(&project).unwrap();
1681
1682 let parent_config = project.join(".rumdl.toml");
1684 fs::write(
1685 &parent_config,
1686 r#"
1687[global]
1688
1689[MD013]
1690line_length = 100
1691"#,
1692 )
1693 .unwrap();
1694
1695 let subdir = project.join("subdir");
1697 fs::create_dir(&subdir).unwrap();
1698
1699 let subdir_config = subdir.join(".rumdl.toml");
1700 fs::write(
1701 &subdir_config,
1702 r#"
1703[global]
1704
1705[MD013]
1706line_length = 50
1707"#,
1708 )
1709 .unwrap();
1710
1711 let server = create_test_server();
1712 {
1713 let mut roots = server.workspace_roots.write().await;
1714 roots.push(project.clone());
1715 }
1716
1717 let test_file = subdir.join("test.md");
1719 fs::write(&test_file, "# Test\n").unwrap();
1720
1721 let config = server.resolve_config_for_file(&test_file).await;
1722 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1723
1724 assert_eq!(
1725 line_length,
1726 Some(50),
1727 "Closer config (subdir) should override parent config"
1728 );
1729 }
1730
1731 #[tokio::test]
1737 async fn test_issue_131_pyproject_without_rumdl_section() {
1738 use std::fs;
1739 use tempfile::tempdir;
1740
1741 let parent_dir = tempdir().unwrap();
1743
1744 let project_dir = parent_dir.path().join("project");
1746 fs::create_dir(&project_dir).unwrap();
1747
1748 fs::write(
1750 project_dir.join("pyproject.toml"),
1751 r#"
1752[project]
1753name = "test-project"
1754version = "0.1.0"
1755"#,
1756 )
1757 .unwrap();
1758
1759 fs::write(
1762 parent_dir.path().join(".rumdl.toml"),
1763 r#"
1764[global]
1765disable = ["MD013"]
1766"#,
1767 )
1768 .unwrap();
1769
1770 let test_file = project_dir.join("test.md");
1771 fs::write(&test_file, "# Test\n").unwrap();
1772
1773 let server = create_test_server();
1774
1775 {
1777 let mut roots = server.workspace_roots.write().await;
1778 roots.push(parent_dir.path().to_path_buf());
1779 }
1780
1781 let config = server.resolve_config_for_file(&test_file).await;
1783
1784 assert!(
1787 config.global.disable.contains(&"MD013".to_string()),
1788 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
1789 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
1790 );
1791
1792 let cache = server.config_cache.read().await;
1795 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1796
1797 assert!(
1798 cache_entry.config_file.is_some(),
1799 "Should have found a config file (parent .rumdl.toml)"
1800 );
1801
1802 let found_config_path = cache_entry.config_file.as_ref().unwrap();
1803 assert!(
1804 found_config_path.ends_with(".rumdl.toml"),
1805 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
1806 );
1807 assert!(
1808 found_config_path.parent().unwrap() == parent_dir.path(),
1809 "Should have loaded config from parent directory, not project_dir"
1810 );
1811 }
1812
1813 #[tokio::test]
1818 async fn test_issue_131_pyproject_with_rumdl_section() {
1819 use std::fs;
1820 use tempfile::tempdir;
1821
1822 let parent_dir = tempdir().unwrap();
1824
1825 let project_dir = parent_dir.path().join("project");
1827 fs::create_dir(&project_dir).unwrap();
1828
1829 fs::write(
1831 project_dir.join("pyproject.toml"),
1832 r#"
1833[project]
1834name = "test-project"
1835
1836[tool.rumdl.global]
1837disable = ["MD033"]
1838"#,
1839 )
1840 .unwrap();
1841
1842 fs::write(
1844 parent_dir.path().join(".rumdl.toml"),
1845 r#"
1846[global]
1847disable = ["MD041"]
1848"#,
1849 )
1850 .unwrap();
1851
1852 let test_file = project_dir.join("test.md");
1853 fs::write(&test_file, "# Test\n").unwrap();
1854
1855 let server = create_test_server();
1856
1857 {
1859 let mut roots = server.workspace_roots.write().await;
1860 roots.push(parent_dir.path().to_path_buf());
1861 }
1862
1863 let config = server.resolve_config_for_file(&test_file).await;
1865
1866 assert!(
1868 config.global.disable.contains(&"MD033".to_string()),
1869 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
1870 Expected MD033 from project_dir pyproject.toml to be disabled."
1871 );
1872
1873 assert!(
1875 !config.global.disable.contains(&"MD041".to_string()),
1876 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
1877 );
1878
1879 let cache = server.config_cache.read().await;
1881 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
1882
1883 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
1884
1885 let found_config_path = cache_entry.config_file.as_ref().unwrap();
1886 assert!(
1887 found_config_path.ends_with("pyproject.toml"),
1888 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
1889 );
1890 assert!(
1891 found_config_path.parent().unwrap() == project_dir,
1892 "Should have loaded pyproject.toml from project_dir, not parent"
1893 );
1894 }
1895
1896 #[tokio::test]
1901 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
1902 use std::fs;
1903 use tempfile::tempdir;
1904
1905 let temp_dir = tempdir().unwrap();
1906
1907 fs::write(
1909 temp_dir.path().join("pyproject.toml"),
1910 r#"
1911[project]
1912name = "test-project"
1913
1914[tool.rumdl.global]
1915disable = ["MD022"]
1916"#,
1917 )
1918 .unwrap();
1919
1920 let test_file = temp_dir.path().join("test.md");
1921 fs::write(&test_file, "# Test\n").unwrap();
1922
1923 let server = create_test_server();
1924
1925 {
1927 let mut roots = server.workspace_roots.write().await;
1928 roots.push(temp_dir.path().to_path_buf());
1929 }
1930
1931 let config = server.resolve_config_for_file(&test_file).await;
1933
1934 assert!(
1936 config.global.disable.contains(&"MD022".to_string()),
1937 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
1938 );
1939
1940 let cache = server.config_cache.read().await;
1942 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
1943 assert!(
1944 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
1945 "Should have loaded pyproject.toml"
1946 );
1947 }
1948}