1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use anyhow::Result;
11use tokio::sync::{RwLock, mpsc};
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::index_worker::IndexWorker;
19use crate::lsp::types::{IndexState, IndexUpdate, RumdlLspConfig, warning_to_code_actions, warning_to_diagnostic};
20use crate::rule::{FixCapability, Rule};
21use crate::rules;
22use crate::workspace_index::WorkspaceIndex;
23
24const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
26
27#[inline]
29fn is_markdown_extension(ext: &str) -> bool {
30 MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
31}
32
33#[derive(Clone, Debug, PartialEq)]
35struct DocumentEntry {
36 content: String,
38 version: Option<i32>,
40 from_disk: bool,
42}
43
44#[derive(Clone, Debug)]
46pub(crate) struct ConfigCacheEntry {
47 pub(crate) config: Config,
49 pub(crate) config_file: Option<PathBuf>,
51 pub(crate) from_global_fallback: bool,
53}
54
55#[derive(Clone)]
65pub struct RumdlLanguageServer {
66 client: Client,
67 config: Arc<RwLock<RumdlLspConfig>>,
69 #[cfg_attr(test, allow(dead_code))]
71 pub(crate) rumdl_config: Arc<RwLock<Config>>,
72 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
74 #[cfg_attr(test, allow(dead_code))]
76 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
77 #[cfg_attr(test, allow(dead_code))]
80 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
81 workspace_index: Arc<RwLock<WorkspaceIndex>>,
83 index_state: Arc<RwLock<IndexState>>,
85 update_tx: mpsc::Sender<IndexUpdate>,
87}
88
89impl RumdlLanguageServer {
90 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
91 let mut initial_config = RumdlLspConfig::default();
93 if let Some(path) = cli_config_path {
94 initial_config.config_path = Some(path.to_string());
95 }
96
97 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
99 let index_state = Arc::new(RwLock::new(IndexState::default()));
100 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
101
102 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
104 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
105
106 let worker = IndexWorker::new(
108 update_rx,
109 workspace_index.clone(),
110 index_state.clone(),
111 client.clone(),
112 workspace_roots.clone(),
113 relint_tx,
114 );
115 tokio::spawn(worker.run());
116
117 Self {
118 client,
119 config: Arc::new(RwLock::new(initial_config)),
120 rumdl_config: Arc::new(RwLock::new(Config::default())),
121 documents: Arc::new(RwLock::new(HashMap::new())),
122 workspace_roots,
123 config_cache: Arc::new(RwLock::new(HashMap::new())),
124 workspace_index,
125 index_state,
126 update_tx,
127 }
128 }
129
130 async fn get_document_content(&self, uri: &Url) -> Option<String> {
136 {
138 let docs = self.documents.read().await;
139 if let Some(entry) = docs.get(uri) {
140 return Some(entry.content.clone());
141 }
142 }
143
144 if let Ok(path) = uri.to_file_path() {
146 if let Ok(content) = tokio::fs::read_to_string(&path).await {
147 let entry = DocumentEntry {
149 content: content.clone(),
150 version: None,
151 from_disk: true,
152 };
153
154 let mut docs = self.documents.write().await;
155 docs.insert(uri.clone(), entry);
156
157 log::debug!("Loaded document from disk and cached: {uri}");
158 return Some(content);
159 } else {
160 log::debug!("Failed to read file from disk: {uri}");
161 }
162 }
163
164 None
165 }
166
167 fn apply_lsp_config_overrides(
169 &self,
170 mut filtered_rules: Vec<Box<dyn Rule>>,
171 lsp_config: &RumdlLspConfig,
172 ) -> Vec<Box<dyn Rule>> {
173 if let Some(enable) = &lsp_config.enable_rules
175 && !enable.is_empty()
176 {
177 let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
178 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
179 }
180
181 if let Some(disable) = &lsp_config.disable_rules
183 && !disable.is_empty()
184 {
185 let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
186 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
187 }
188
189 filtered_rules
190 }
191
192 async fn should_exclude_uri(&self, uri: &Url) -> bool {
194 let file_path = match uri.to_file_path() {
196 Ok(path) => path,
197 Err(_) => return false, };
199
200 let rumdl_config = self.resolve_config_for_file(&file_path).await;
202 let exclude_patterns = &rumdl_config.global.exclude;
203
204 if exclude_patterns.is_empty() {
206 return false;
207 }
208
209 let path_to_check = if file_path.is_absolute() {
212 if let Ok(cwd) = std::env::current_dir() {
214 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
216 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
217 relative.to_string_lossy().to_string()
218 } else {
219 file_path.to_string_lossy().to_string()
221 }
222 } else {
223 file_path.to_string_lossy().to_string()
225 }
226 } else {
227 file_path.to_string_lossy().to_string()
228 }
229 } else {
230 file_path.to_string_lossy().to_string()
232 };
233
234 for pattern in exclude_patterns {
236 if let Ok(glob) = globset::Glob::new(pattern) {
237 let matcher = glob.compile_matcher();
238 if matcher.is_match(&path_to_check) {
239 log::debug!("Excluding file from LSP linting: {path_to_check}");
240 return true;
241 }
242 }
243 }
244
245 false
246 }
247
248 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
250 let config_guard = self.config.read().await;
251
252 if !config_guard.enable_linting {
254 return Ok(Vec::new());
255 }
256
257 let lsp_config = config_guard.clone();
258 drop(config_guard); if self.should_exclude_uri(uri).await {
262 return Ok(Vec::new());
263 }
264
265 let file_path = uri.to_file_path().ok();
267 let rumdl_config = if let Some(ref path) = file_path {
268 self.resolve_config_for_file(path).await
269 } else {
270 (*self.rumdl_config.read().await).clone()
272 };
273
274 let all_rules = rules::all_rules(&rumdl_config);
275 let flavor = rumdl_config.markdown_flavor();
276
277 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
279
280 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
282
283 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor) {
285 Ok(warnings) => warnings,
286 Err(e) => {
287 log::error!("Failed to lint document {uri}: {e}");
288 return Ok(Vec::new());
289 }
290 };
291
292 if let Some(ref path) = file_path {
294 let index_state = self.index_state.read().await.clone();
295 if matches!(index_state, IndexState::Ready) {
296 let workspace_index = self.workspace_index.read().await;
297 if let Some(file_index) = workspace_index.get_file(path) {
298 match crate::run_cross_file_checks(path, file_index, &filtered_rules, &workspace_index) {
299 Ok(cross_file_warnings) => {
300 all_warnings.extend(cross_file_warnings);
301 }
302 Err(e) => {
303 log::warn!("Failed to run cross-file checks for {uri}: {e}");
304 }
305 }
306 }
307 }
308 }
309
310 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
311 Ok(diagnostics)
312 }
313
314 async fn update_diagnostics(&self, uri: Url, text: String) {
316 let version = {
318 let docs = self.documents.read().await;
319 docs.get(&uri).and_then(|entry| entry.version)
320 };
321
322 match self.lint_document(&uri, &text).await {
323 Ok(diagnostics) => {
324 self.client.publish_diagnostics(uri, diagnostics, version).await;
325 }
326 Err(e) => {
327 log::error!("Failed to update diagnostics: {e}");
328 }
329 }
330 }
331
332 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
334 if self.should_exclude_uri(uri).await {
336 return Ok(None);
337 }
338
339 let config_guard = self.config.read().await;
340 let lsp_config = config_guard.clone();
341 drop(config_guard);
342
343 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
345 self.resolve_config_for_file(&file_path).await
346 } else {
347 (*self.rumdl_config.read().await).clone()
349 };
350
351 let all_rules = rules::all_rules(&rumdl_config);
352 let flavor = rumdl_config.markdown_flavor();
353
354 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
356
357 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
359
360 let mut rules_with_warnings = std::collections::HashSet::new();
363 let mut fixed_text = text.to_string();
364
365 match lint(&fixed_text, &filtered_rules, false, flavor) {
366 Ok(warnings) => {
367 for warning in warnings {
368 if let Some(rule_name) = &warning.rule_name {
369 rules_with_warnings.insert(rule_name.clone());
370 }
371 }
372 }
373 Err(e) => {
374 log::warn!("Failed to lint document for auto-fix: {e}");
375 return Ok(None);
376 }
377 }
378
379 if rules_with_warnings.is_empty() {
381 return Ok(None);
382 }
383
384 let mut any_changes = false;
386
387 for rule in &filtered_rules {
388 if !rules_with_warnings.contains(rule.name()) {
390 continue;
391 }
392
393 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
394 match rule.fix(&ctx) {
395 Ok(new_text) => {
396 if new_text != fixed_text {
397 fixed_text = new_text;
398 any_changes = true;
399 }
400 }
401 Err(e) => {
402 let msg = e.to_string();
404 if !msg.contains("does not support automatic fixing") {
405 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
406 }
407 }
408 }
409 }
410
411 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
412 }
413
414 fn get_end_position(&self, text: &str) -> Position {
416 let mut line = 0u32;
417 let mut character = 0u32;
418
419 for ch in text.chars() {
420 if ch == '\n' {
421 line += 1;
422 character = 0;
423 } else {
424 character += 1;
425 }
426 }
427
428 Position { line, character }
429 }
430
431 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
433 let config_guard = self.config.read().await;
434 let lsp_config = config_guard.clone();
435 drop(config_guard);
436
437 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
439 self.resolve_config_for_file(&file_path).await
440 } else {
441 (*self.rumdl_config.read().await).clone()
443 };
444
445 let all_rules = rules::all_rules(&rumdl_config);
446 let flavor = rumdl_config.markdown_flavor();
447
448 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
450
451 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
453
454 match crate::lint(text, &filtered_rules, false, flavor) {
455 Ok(warnings) => {
456 let mut actions = Vec::new();
457 let mut fixable_count = 0;
458
459 for warning in &warnings {
460 let warning_line = (warning.line.saturating_sub(1)) as u32;
462 if warning_line >= range.start.line && warning_line <= range.end.line {
463 let mut warning_actions = warning_to_code_actions(warning, uri, text);
465 actions.append(&mut warning_actions);
466
467 if warning.fix.is_some() {
468 fixable_count += 1;
469 }
470 }
471 }
472
473 if fixable_count > 1 {
475 let fixable_warnings: Vec<_> = warnings
478 .iter()
479 .filter(|w| {
480 if let Some(rule_name) = &w.rule_name {
481 filtered_rules
482 .iter()
483 .find(|r| r.name() == rule_name)
484 .map(|r| r.fix_capability() != FixCapability::Unfixable)
485 .unwrap_or(false)
486 } else {
487 false
488 }
489 })
490 .cloned()
491 .collect();
492
493 let total_fixable = fixable_warnings.len();
495
496 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
497 && fixed_content != text
498 {
499 let mut line = 0u32;
501 let mut character = 0u32;
502 for ch in text.chars() {
503 if ch == '\n' {
504 line += 1;
505 character = 0;
506 } else {
507 character += 1;
508 }
509 }
510
511 let fix_all_action = CodeAction {
512 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
513 kind: Some(CodeActionKind::QUICKFIX),
514 diagnostics: Some(Vec::new()),
515 edit: Some(WorkspaceEdit {
516 changes: Some(
517 [(
518 uri.clone(),
519 vec![TextEdit {
520 range: Range {
521 start: Position { line: 0, character: 0 },
522 end: Position { line, character },
523 },
524 new_text: fixed_content,
525 }],
526 )]
527 .into_iter()
528 .collect(),
529 ),
530 ..Default::default()
531 }),
532 command: None,
533 is_preferred: Some(true),
534 disabled: None,
535 data: None,
536 };
537
538 actions.insert(0, fix_all_action);
540 }
541 }
542
543 Ok(actions)
544 }
545 Err(e) => {
546 log::error!("Failed to get code actions: {e}");
547 Ok(Vec::new())
548 }
549 }
550 }
551
552 async fn load_configuration(&self, notify_client: bool) {
554 let config_guard = self.config.read().await;
555 let explicit_config_path = config_guard.config_path.clone();
556 drop(config_guard);
557
558 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
560 Ok(sourced_config) => {
561 let loaded_files = sourced_config.loaded_files.clone();
562 *self.rumdl_config.write().await = sourced_config.into();
563
564 if !loaded_files.is_empty() {
565 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
566 log::info!("{message}");
567 if notify_client {
568 self.client.log_message(MessageType::INFO, &message).await;
569 }
570 } else {
571 log::info!("Using default rumdl configuration (no config files found)");
572 }
573 }
574 Err(e) => {
575 let message = format!("Failed to load rumdl config: {e}");
576 log::warn!("{message}");
577 if notify_client {
578 self.client.log_message(MessageType::WARNING, &message).await;
579 }
580 *self.rumdl_config.write().await = crate::config::Config::default();
582 }
583 }
584 }
585
586 async fn reload_configuration(&self) {
588 self.load_configuration(true).await;
589 }
590
591 fn load_config_for_lsp(
593 config_path: Option<&str>,
594 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
595 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
597 }
598
599 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
606 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
608
609 {
611 let cache = self.config_cache.read().await;
612 if let Some(entry) = cache.get(&search_dir) {
613 let source_owned: String; let source: &str = if entry.from_global_fallback {
615 "global/user fallback"
616 } else if let Some(path) = &entry.config_file {
617 source_owned = path.to_string_lossy().to_string();
618 &source_owned
619 } else {
620 "<unknown>"
621 };
622 log::debug!(
623 "Config cache hit for directory: {} (loaded from: {})",
624 search_dir.display(),
625 source
626 );
627 return entry.config.clone();
628 }
629 }
630
631 log::debug!(
633 "Config cache miss for directory: {}, searching for config...",
634 search_dir.display()
635 );
636
637 let workspace_root = {
639 let workspace_roots = self.workspace_roots.read().await;
640 workspace_roots
641 .iter()
642 .find(|root| search_dir.starts_with(root))
643 .map(|p| p.to_path_buf())
644 };
645
646 let mut current_dir = search_dir.clone();
648 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
649
650 loop {
651 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
653
654 for config_file_name in CONFIG_FILES {
655 let config_path = current_dir.join(config_file_name);
656 if config_path.exists() {
657 if *config_file_name == "pyproject.toml" {
659 if let Ok(content) = std::fs::read_to_string(&config_path) {
660 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
661 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
662 } else {
663 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
664 continue;
665 }
666 } else {
667 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
668 continue;
669 }
670 } else {
671 log::debug!("Found config file: {}", config_path.display());
672 }
673
674 if let Some(config_path_str) = config_path.to_str() {
676 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
677 found_config = Some((sourced.into(), Some(config_path)));
678 break;
679 }
680 } else {
681 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
682 }
683 }
684 }
685
686 if found_config.is_some() {
687 break;
688 }
689
690 if let Some(ref root) = workspace_root
692 && ¤t_dir == root
693 {
694 log::debug!("Hit workspace root without finding config: {}", root.display());
695 break;
696 }
697
698 if let Some(parent) = current_dir.parent() {
700 current_dir = parent.to_path_buf();
701 } else {
702 break;
704 }
705 }
706
707 let (config, config_file) = if let Some((cfg, path)) = found_config {
709 (cfg, path)
710 } else {
711 log::debug!("No project config found; using global/user fallback config");
712 let fallback = self.rumdl_config.read().await.clone();
713 (fallback, None)
714 };
715
716 let from_global = config_file.is_none();
718 let entry = ConfigCacheEntry {
719 config: config.clone(),
720 config_file,
721 from_global_fallback: from_global,
722 };
723
724 self.config_cache.write().await.insert(search_dir, entry);
725
726 config
727 }
728}
729
730#[tower_lsp::async_trait]
731impl LanguageServer for RumdlLanguageServer {
732 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
733 log::info!("Initializing rumdl Language Server");
734
735 if let Some(options) = params.initialization_options
737 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
738 {
739 *self.config.write().await = config;
740 }
741
742 let mut roots = Vec::new();
744 if let Some(workspace_folders) = params.workspace_folders {
745 for folder in workspace_folders {
746 if let Ok(path) = folder.uri.to_file_path() {
747 log::info!("Workspace root: {}", path.display());
748 roots.push(path);
749 }
750 }
751 } else if let Some(root_uri) = params.root_uri
752 && let Ok(path) = root_uri.to_file_path()
753 {
754 log::info!("Workspace root: {}", path.display());
755 roots.push(path);
756 }
757 *self.workspace_roots.write().await = roots;
758
759 self.load_configuration(false).await;
761
762 Ok(InitializeResult {
763 capabilities: ServerCapabilities {
764 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
765 open_close: Some(true),
766 change: Some(TextDocumentSyncKind::FULL),
767 will_save: Some(false),
768 will_save_wait_until: Some(true),
769 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
770 include_text: Some(false),
771 })),
772 })),
773 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
774 document_formatting_provider: Some(OneOf::Left(true)),
775 document_range_formatting_provider: Some(OneOf::Left(true)),
776 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
777 identifier: Some("rumdl".to_string()),
778 inter_file_dependencies: true,
779 workspace_diagnostics: true,
780 work_done_progress_options: WorkDoneProgressOptions::default(),
781 })),
782 workspace: Some(WorkspaceServerCapabilities {
783 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
784 supported: Some(true),
785 change_notifications: Some(OneOf::Left(true)),
786 }),
787 file_operations: None,
788 }),
789 ..Default::default()
790 },
791 server_info: Some(ServerInfo {
792 name: "rumdl".to_string(),
793 version: Some(env!("CARGO_PKG_VERSION").to_string()),
794 }),
795 })
796 }
797
798 async fn initialized(&self, _: InitializedParams) {
799 let version = env!("CARGO_PKG_VERSION");
800
801 let (binary_path, build_time) = std::env::current_exe()
803 .ok()
804 .map(|path| {
805 let path_str = path.to_str().unwrap_or("unknown").to_string();
806 let build_time = std::fs::metadata(&path)
807 .ok()
808 .and_then(|metadata| metadata.modified().ok())
809 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
810 .and_then(|duration| {
811 let secs = duration.as_secs();
812 chrono::DateTime::from_timestamp(secs as i64, 0)
813 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
814 })
815 .unwrap_or_else(|| "unknown".to_string());
816 (path_str, build_time)
817 })
818 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
819
820 let working_dir = std::env::current_dir()
821 .ok()
822 .and_then(|p| p.to_str().map(|s| s.to_string()))
823 .unwrap_or_else(|| "unknown".to_string());
824
825 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
826 log::info!("Working directory: {working_dir}");
827
828 self.client
829 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
830 .await;
831
832 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
834 log::warn!("Failed to trigger initial workspace indexing");
835 } else {
836 log::info!("Triggered initial workspace indexing for cross-file analysis");
837 }
838
839 let markdown_patterns = [
842 "**/*.md",
843 "**/*.markdown",
844 "**/*.mdx",
845 "**/*.mkd",
846 "**/*.mkdn",
847 "**/*.mdown",
848 "**/*.mdwn",
849 "**/*.qmd",
850 "**/*.rmd",
851 ];
852 let watchers: Vec<_> = markdown_patterns
853 .iter()
854 .map(|pattern| FileSystemWatcher {
855 glob_pattern: GlobPattern::String((*pattern).to_string()),
856 kind: Some(WatchKind::all()),
857 })
858 .collect();
859
860 let registration = Registration {
861 id: "markdown-watcher".to_string(),
862 method: "workspace/didChangeWatchedFiles".to_string(),
863 register_options: Some(
864 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
865 ),
866 };
867
868 if self.client.register_capability(vec![registration]).await.is_err() {
869 log::debug!("Client does not support file watching capability");
870 }
871 }
872
873 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
874 let mut roots = self.workspace_roots.write().await;
876
877 for removed in ¶ms.event.removed {
879 if let Ok(path) = removed.uri.to_file_path() {
880 roots.retain(|r| r != &path);
881 log::info!("Removed workspace root: {}", path.display());
882 }
883 }
884
885 for added in ¶ms.event.added {
887 if let Ok(path) = added.uri.to_file_path()
888 && !roots.contains(&path)
889 {
890 log::info!("Added workspace root: {}", path.display());
891 roots.push(path);
892 }
893 }
894 drop(roots);
895
896 self.config_cache.write().await.clear();
898
899 self.reload_configuration().await;
901
902 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
904 log::warn!("Failed to trigger workspace rescan after folder change");
905 }
906 }
907
908 async fn shutdown(&self) -> JsonRpcResult<()> {
909 log::info!("Shutting down rumdl Language Server");
910
911 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
913
914 Ok(())
915 }
916
917 async fn did_open(&self, params: DidOpenTextDocumentParams) {
918 let uri = params.text_document.uri;
919 let text = params.text_document.text;
920 let version = params.text_document.version;
921
922 let entry = DocumentEntry {
923 content: text.clone(),
924 version: Some(version),
925 from_disk: false,
926 };
927 self.documents.write().await.insert(uri.clone(), entry);
928
929 if let Ok(path) = uri.to_file_path() {
931 let _ = self
932 .update_tx
933 .send(IndexUpdate::FileChanged {
934 path,
935 content: text.clone(),
936 })
937 .await;
938 }
939
940 self.update_diagnostics(uri, text).await;
941 }
942
943 async fn did_change(&self, params: DidChangeTextDocumentParams) {
944 let uri = params.text_document.uri;
945 let version = params.text_document.version;
946
947 if let Some(change) = params.content_changes.into_iter().next() {
948 let text = change.text;
949
950 let entry = DocumentEntry {
951 content: text.clone(),
952 version: Some(version),
953 from_disk: false,
954 };
955 self.documents.write().await.insert(uri.clone(), entry);
956
957 if let Ok(path) = uri.to_file_path() {
959 let _ = self
960 .update_tx
961 .send(IndexUpdate::FileChanged {
962 path,
963 content: text.clone(),
964 })
965 .await;
966 }
967
968 self.update_diagnostics(uri, text).await;
969 }
970 }
971
972 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
973 let config_guard = self.config.read().await;
974 let enable_auto_fix = config_guard.enable_auto_fix;
975 drop(config_guard);
976
977 if !enable_auto_fix {
978 return Ok(None);
979 }
980
981 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
983 return Ok(None);
984 };
985
986 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
988 Ok(Some(fixed_text)) => {
989 Ok(Some(vec![TextEdit {
991 range: Range {
992 start: Position { line: 0, character: 0 },
993 end: self.get_end_position(&text),
994 },
995 new_text: fixed_text,
996 }]))
997 }
998 Ok(None) => Ok(None),
999 Err(e) => {
1000 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1001 Ok(None)
1002 }
1003 }
1004 }
1005
1006 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1007 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1010 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1011 .await;
1012 }
1013 }
1014
1015 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1016 self.documents.write().await.remove(¶ms.text_document.uri);
1018
1019 self.client
1021 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1022 .await;
1023 }
1024
1025 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1026 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1028
1029 let mut config_changed = false;
1030
1031 for change in ¶ms.changes {
1032 if let Ok(path) = change.uri.to_file_path() {
1033 let file_name = path.file_name().and_then(|f| f.to_str());
1034 let extension = path.extension().and_then(|e| e.to_str());
1035
1036 if let Some(name) = file_name
1038 && CONFIG_FILES.contains(&name)
1039 && !config_changed
1040 {
1041 log::info!("Config file changed: {}, invalidating config cache", path.display());
1042
1043 let mut cache = self.config_cache.write().await;
1045 cache.retain(|_, entry| {
1046 if let Some(config_file) = &entry.config_file {
1047 config_file != &path
1048 } else {
1049 true
1050 }
1051 });
1052
1053 drop(cache);
1055 self.reload_configuration().await;
1056 config_changed = true;
1057 }
1058
1059 if let Some(ext) = extension
1061 && is_markdown_extension(ext)
1062 {
1063 match change.typ {
1064 FileChangeType::CREATED | FileChangeType::CHANGED => {
1065 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1067 let _ = self
1068 .update_tx
1069 .send(IndexUpdate::FileChanged {
1070 path: path.clone(),
1071 content,
1072 })
1073 .await;
1074 }
1075 }
1076 FileChangeType::DELETED => {
1077 let _ = self
1078 .update_tx
1079 .send(IndexUpdate::FileDeleted { path: path.clone() })
1080 .await;
1081 }
1082 _ => {}
1083 }
1084 }
1085 }
1086 }
1087
1088 if config_changed {
1090 let docs_to_update: Vec<(Url, String)> = {
1091 let docs = self.documents.read().await;
1092 docs.iter()
1093 .filter(|(_, entry)| !entry.from_disk)
1094 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1095 .collect()
1096 };
1097
1098 for (uri, text) in docs_to_update {
1099 self.update_diagnostics(uri, text).await;
1100 }
1101 }
1102 }
1103
1104 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1105 let uri = params.text_document.uri;
1106 let range = params.range;
1107
1108 if let Some(text) = self.get_document_content(&uri).await {
1109 match self.get_code_actions(&uri, &text, range).await {
1110 Ok(actions) => {
1111 let response: Vec<CodeActionOrCommand> =
1112 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1113 Ok(Some(response))
1114 }
1115 Err(e) => {
1116 log::error!("Failed to get code actions: {e}");
1117 Ok(None)
1118 }
1119 }
1120 } else {
1121 Ok(None)
1122 }
1123 }
1124
1125 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1126 log::debug!(
1131 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1132 params.range
1133 );
1134
1135 let formatting_params = DocumentFormattingParams {
1136 text_document: params.text_document,
1137 options: params.options,
1138 work_done_progress_params: params.work_done_progress_params,
1139 };
1140
1141 self.formatting(formatting_params).await
1142 }
1143
1144 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1145 let uri = params.text_document.uri;
1146
1147 log::debug!("Formatting request for: {uri}");
1148
1149 if let Some(text) = self.get_document_content(&uri).await {
1150 let config_guard = self.config.read().await;
1152 let lsp_config = config_guard.clone();
1153 drop(config_guard);
1154
1155 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
1157 self.resolve_config_for_file(&file_path).await
1158 } else {
1159 self.rumdl_config.read().await.clone()
1161 };
1162
1163 let all_rules = rules::all_rules(&rumdl_config);
1164 let flavor = rumdl_config.markdown_flavor();
1165
1166 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1168
1169 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1171
1172 match crate::lint(&text, &filtered_rules, false, flavor) {
1174 Ok(warnings) => {
1175 log::debug!(
1176 "Found {} warnings, {} with fixes",
1177 warnings.len(),
1178 warnings.iter().filter(|w| w.fix.is_some()).count()
1179 );
1180
1181 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1182 if has_fixes {
1183 let fixable_warnings: Vec<_> = warnings
1187 .iter()
1188 .filter(|w| {
1189 if let Some(rule_name) = &w.rule_name {
1190 filtered_rules
1191 .iter()
1192 .find(|r| r.name() == rule_name)
1193 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1194 .unwrap_or(false)
1195 } else {
1196 false
1197 }
1198 })
1199 .cloned()
1200 .collect();
1201
1202 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1203 Ok(fixed_content) => {
1204 if fixed_content != text {
1205 log::debug!("Returning formatting edits");
1206 let end_position = self.get_end_position(&text);
1207 let edit = TextEdit {
1208 range: Range {
1209 start: Position { line: 0, character: 0 },
1210 end: end_position,
1211 },
1212 new_text: fixed_content,
1213 };
1214 return Ok(Some(vec![edit]));
1215 }
1216 }
1217 Err(e) => {
1218 log::error!("Failed to apply fixes: {e}");
1219 }
1220 }
1221 }
1222 Ok(Some(Vec::new()))
1223 }
1224 Err(e) => {
1225 log::error!("Failed to format document: {e}");
1226 Ok(Some(Vec::new()))
1227 }
1228 }
1229 } else {
1230 log::warn!("Document not found: {uri}");
1231 Ok(None)
1232 }
1233 }
1234
1235 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1236 let uri = params.text_document.uri;
1237
1238 if let Some(text) = self.get_document_content(&uri).await {
1239 match self.lint_document(&uri, &text).await {
1240 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1241 RelatedFullDocumentDiagnosticReport {
1242 related_documents: None,
1243 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1244 result_id: None,
1245 items: diagnostics,
1246 },
1247 },
1248 ))),
1249 Err(e) => {
1250 log::error!("Failed to get diagnostics: {e}");
1251 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1252 RelatedFullDocumentDiagnosticReport {
1253 related_documents: None,
1254 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1255 result_id: None,
1256 items: Vec::new(),
1257 },
1258 },
1259 )))
1260 }
1261 }
1262 } else {
1263 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1264 RelatedFullDocumentDiagnosticReport {
1265 related_documents: None,
1266 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1267 result_id: None,
1268 items: Vec::new(),
1269 },
1270 },
1271 )))
1272 }
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use crate::rule::LintWarning;
1280 use tower_lsp::LspService;
1281
1282 fn create_test_server() -> RumdlLanguageServer {
1283 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1284 service.inner().clone()
1285 }
1286
1287 #[tokio::test]
1288 async fn test_server_creation() {
1289 let server = create_test_server();
1290
1291 let config = server.config.read().await;
1293 assert!(config.enable_linting);
1294 assert!(!config.enable_auto_fix);
1295 }
1296
1297 #[tokio::test]
1298 async fn test_lint_document() {
1299 let server = create_test_server();
1300
1301 let uri = Url::parse("file:///test.md").unwrap();
1303 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1304
1305 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1306
1307 assert!(!diagnostics.is_empty());
1309 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1310 }
1311
1312 #[tokio::test]
1313 async fn test_lint_document_disabled() {
1314 let server = create_test_server();
1315
1316 server.config.write().await.enable_linting = false;
1318
1319 let uri = Url::parse("file:///test.md").unwrap();
1320 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1321
1322 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1323
1324 assert!(diagnostics.is_empty());
1326 }
1327
1328 #[tokio::test]
1329 async fn test_get_code_actions() {
1330 let server = create_test_server();
1331
1332 let uri = Url::parse("file:///test.md").unwrap();
1333 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1334
1335 let range = Range {
1337 start: Position { line: 0, character: 0 },
1338 end: Position { line: 3, character: 21 },
1339 };
1340
1341 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1342
1343 assert!(!actions.is_empty());
1345 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1346 }
1347
1348 #[tokio::test]
1349 async fn test_get_code_actions_outside_range() {
1350 let server = create_test_server();
1351
1352 let uri = Url::parse("file:///test.md").unwrap();
1353 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1354
1355 let range = Range {
1357 start: Position { line: 0, character: 0 },
1358 end: Position { line: 0, character: 6 },
1359 };
1360
1361 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1362
1363 assert!(actions.is_empty());
1365 }
1366
1367 #[tokio::test]
1368 async fn test_document_storage() {
1369 let server = create_test_server();
1370
1371 let uri = Url::parse("file:///test.md").unwrap();
1372 let text = "# Test Document";
1373
1374 let entry = DocumentEntry {
1376 content: text.to_string(),
1377 version: Some(1),
1378 from_disk: false,
1379 };
1380 server.documents.write().await.insert(uri.clone(), entry);
1381
1382 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1384 assert_eq!(stored, Some(text.to_string()));
1385
1386 server.documents.write().await.remove(&uri);
1388
1389 let stored = server.documents.read().await.get(&uri).cloned();
1391 assert_eq!(stored, None);
1392 }
1393
1394 #[tokio::test]
1395 async fn test_configuration_loading() {
1396 let server = create_test_server();
1397
1398 server.load_configuration(false).await;
1400
1401 let rumdl_config = server.rumdl_config.read().await;
1404 drop(rumdl_config); }
1407
1408 #[tokio::test]
1409 async fn test_load_config_for_lsp() {
1410 let result = RumdlLanguageServer::load_config_for_lsp(None);
1412 assert!(result.is_ok());
1413
1414 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1416 assert!(result.is_err());
1417 }
1418
1419 #[tokio::test]
1420 async fn test_warning_conversion() {
1421 let warning = LintWarning {
1422 message: "Test warning".to_string(),
1423 line: 1,
1424 column: 1,
1425 end_line: 1,
1426 end_column: 10,
1427 severity: crate::rule::Severity::Warning,
1428 fix: None,
1429 rule_name: Some("MD001".to_string()),
1430 };
1431
1432 let diagnostic = warning_to_diagnostic(&warning);
1434 assert_eq!(diagnostic.message, "Test warning");
1435 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1436 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1437
1438 let uri = Url::parse("file:///test.md").unwrap();
1440 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1441 assert_eq!(actions.len(), 1);
1443 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1444 }
1445
1446 #[tokio::test]
1447 async fn test_multiple_documents() {
1448 let server = create_test_server();
1449
1450 let uri1 = Url::parse("file:///test1.md").unwrap();
1451 let uri2 = Url::parse("file:///test2.md").unwrap();
1452 let text1 = "# Document 1";
1453 let text2 = "# Document 2";
1454
1455 {
1457 let mut docs = server.documents.write().await;
1458 let entry1 = DocumentEntry {
1459 content: text1.to_string(),
1460 version: Some(1),
1461 from_disk: false,
1462 };
1463 let entry2 = DocumentEntry {
1464 content: text2.to_string(),
1465 version: Some(1),
1466 from_disk: false,
1467 };
1468 docs.insert(uri1.clone(), entry1);
1469 docs.insert(uri2.clone(), entry2);
1470 }
1471
1472 let docs = server.documents.read().await;
1474 assert_eq!(docs.len(), 2);
1475 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1476 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1477 }
1478
1479 #[tokio::test]
1480 async fn test_auto_fix_on_save() {
1481 let server = create_test_server();
1482
1483 {
1485 let mut config = server.config.write().await;
1486 config.enable_auto_fix = true;
1487 }
1488
1489 let uri = Url::parse("file:///test.md").unwrap();
1490 let text = "#Heading without space"; let entry = DocumentEntry {
1494 content: text.to_string(),
1495 version: Some(1),
1496 from_disk: false,
1497 };
1498 server.documents.write().await.insert(uri.clone(), entry);
1499
1500 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1502 assert!(fixed.is_some());
1503 assert_eq!(fixed.unwrap(), "# Heading without space\n");
1505 }
1506
1507 #[tokio::test]
1508 async fn test_get_end_position() {
1509 let server = create_test_server();
1510
1511 let pos = server.get_end_position("Hello");
1513 assert_eq!(pos.line, 0);
1514 assert_eq!(pos.character, 5);
1515
1516 let pos = server.get_end_position("Hello\nWorld\nTest");
1518 assert_eq!(pos.line, 2);
1519 assert_eq!(pos.character, 4);
1520
1521 let pos = server.get_end_position("");
1523 assert_eq!(pos.line, 0);
1524 assert_eq!(pos.character, 0);
1525
1526 let pos = server.get_end_position("Hello\n");
1528 assert_eq!(pos.line, 1);
1529 assert_eq!(pos.character, 0);
1530 }
1531
1532 #[tokio::test]
1533 async fn test_empty_document_handling() {
1534 let server = create_test_server();
1535
1536 let uri = Url::parse("file:///empty.md").unwrap();
1537 let text = "";
1538
1539 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1541 assert!(diagnostics.is_empty());
1542
1543 let range = Range {
1545 start: Position { line: 0, character: 0 },
1546 end: Position { line: 0, character: 0 },
1547 };
1548 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1549 assert!(actions.is_empty());
1550 }
1551
1552 #[tokio::test]
1553 async fn test_config_update() {
1554 let server = create_test_server();
1555
1556 {
1558 let mut config = server.config.write().await;
1559 config.enable_auto_fix = true;
1560 config.config_path = Some("/custom/path.toml".to_string());
1561 }
1562
1563 let config = server.config.read().await;
1565 assert!(config.enable_auto_fix);
1566 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1567 }
1568
1569 #[tokio::test]
1570 async fn test_document_formatting() {
1571 let server = create_test_server();
1572 let uri = Url::parse("file:///test.md").unwrap();
1573 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1574
1575 let entry = DocumentEntry {
1577 content: text.to_string(),
1578 version: Some(1),
1579 from_disk: false,
1580 };
1581 server.documents.write().await.insert(uri.clone(), entry);
1582
1583 let params = DocumentFormattingParams {
1585 text_document: TextDocumentIdentifier { uri: uri.clone() },
1586 options: FormattingOptions {
1587 tab_size: 4,
1588 insert_spaces: true,
1589 properties: HashMap::new(),
1590 trim_trailing_whitespace: Some(true),
1591 insert_final_newline: Some(true),
1592 trim_final_newlines: Some(true),
1593 },
1594 work_done_progress_params: WorkDoneProgressParams::default(),
1595 };
1596
1597 let result = server.formatting(params).await.unwrap();
1599
1600 assert!(result.is_some());
1602 let edits = result.unwrap();
1603 assert!(!edits.is_empty());
1604
1605 let edit = &edits[0];
1607 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
1610 assert_eq!(edit.new_text, expected);
1611 }
1612
1613 #[tokio::test]
1616 async fn test_unfixable_rules_excluded_from_formatting() {
1617 let server = create_test_server();
1618 let uri = Url::parse("file:///test.md").unwrap();
1619
1620 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
1622
1623 let entry = DocumentEntry {
1625 content: text.to_string(),
1626 version: Some(1),
1627 from_disk: false,
1628 };
1629 server.documents.write().await.insert(uri.clone(), entry);
1630
1631 let format_params = DocumentFormattingParams {
1633 text_document: TextDocumentIdentifier { uri: uri.clone() },
1634 options: FormattingOptions {
1635 tab_size: 4,
1636 insert_spaces: true,
1637 properties: HashMap::new(),
1638 trim_trailing_whitespace: Some(true),
1639 insert_final_newline: Some(true),
1640 trim_final_newlines: Some(true),
1641 },
1642 work_done_progress_params: WorkDoneProgressParams::default(),
1643 };
1644
1645 let format_result = server.formatting(format_params).await.unwrap();
1646 assert!(format_result.is_some(), "Should return formatting edits");
1647
1648 let edits = format_result.unwrap();
1649 assert!(!edits.is_empty(), "Should have formatting edits");
1650
1651 let formatted = &edits[0].new_text;
1652 assert!(
1653 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
1654 "HTML should be preserved during formatting (Unfixable rule)"
1655 );
1656 assert!(
1657 !formatted.contains("spaces "),
1658 "Trailing spaces should be removed (fixable rule)"
1659 );
1660
1661 let range = Range {
1663 start: Position { line: 0, character: 0 },
1664 end: Position { line: 10, character: 0 },
1665 };
1666
1667 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
1668
1669 let html_fix_actions: Vec<_> = code_actions
1671 .iter()
1672 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
1673 .collect();
1674
1675 assert!(
1676 !html_fix_actions.is_empty(),
1677 "Quick Fix actions should be available for HTML (Unfixable rules)"
1678 );
1679
1680 let fix_all_actions: Vec<_> = code_actions
1682 .iter()
1683 .filter(|action| action.title.contains("Fix all"))
1684 .collect();
1685
1686 if let Some(fix_all_action) = fix_all_actions.first()
1687 && let Some(ref edit) = fix_all_action.edit
1688 && let Some(ref changes) = edit.changes
1689 && let Some(text_edits) = changes.get(&uri)
1690 && let Some(text_edit) = text_edits.first()
1691 {
1692 let fixed_all = &text_edit.new_text;
1693 assert!(
1694 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
1695 "Fix All should preserve HTML (Unfixable rules)"
1696 );
1697 assert!(
1698 !fixed_all.contains("spaces "),
1699 "Fix All should remove trailing spaces (fixable rules)"
1700 );
1701 }
1702 }
1703
1704 #[tokio::test]
1706 async fn test_resolve_config_for_file_multi_root() {
1707 use std::fs;
1708 use tempfile::tempdir;
1709
1710 let temp_dir = tempdir().unwrap();
1711 let temp_path = temp_dir.path();
1712
1713 let project_a = temp_path.join("project_a");
1715 let project_a_docs = project_a.join("docs");
1716 fs::create_dir_all(&project_a_docs).unwrap();
1717
1718 let config_a = project_a.join(".rumdl.toml");
1719 fs::write(
1720 &config_a,
1721 r#"
1722[global]
1723
1724[MD013]
1725line_length = 60
1726"#,
1727 )
1728 .unwrap();
1729
1730 let project_b = temp_path.join("project_b");
1732 fs::create_dir(&project_b).unwrap();
1733
1734 let config_b = project_b.join(".rumdl.toml");
1735 fs::write(
1736 &config_b,
1737 r#"
1738[global]
1739
1740[MD013]
1741line_length = 120
1742"#,
1743 )
1744 .unwrap();
1745
1746 let server = create_test_server();
1748
1749 {
1751 let mut roots = server.workspace_roots.write().await;
1752 roots.push(project_a.clone());
1753 roots.push(project_b.clone());
1754 }
1755
1756 let file_a = project_a_docs.join("test.md");
1758 fs::write(&file_a, "# Test A\n").unwrap();
1759
1760 let config_for_a = server.resolve_config_for_file(&file_a).await;
1761 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1762 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1763
1764 let file_b = project_b.join("test.md");
1766 fs::write(&file_b, "# Test B\n").unwrap();
1767
1768 let config_for_b = server.resolve_config_for_file(&file_b).await;
1769 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1770 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1771 }
1772
1773 #[tokio::test]
1775 async fn test_config_resolution_respects_workspace_boundaries() {
1776 use std::fs;
1777 use tempfile::tempdir;
1778
1779 let temp_dir = tempdir().unwrap();
1780 let temp_path = temp_dir.path();
1781
1782 let parent_config = temp_path.join(".rumdl.toml");
1784 fs::write(
1785 &parent_config,
1786 r#"
1787[global]
1788
1789[MD013]
1790line_length = 80
1791"#,
1792 )
1793 .unwrap();
1794
1795 let workspace_root = temp_path.join("workspace");
1797 let workspace_subdir = workspace_root.join("subdir");
1798 fs::create_dir_all(&workspace_subdir).unwrap();
1799
1800 let workspace_config = workspace_root.join(".rumdl.toml");
1801 fs::write(
1802 &workspace_config,
1803 r#"
1804[global]
1805
1806[MD013]
1807line_length = 100
1808"#,
1809 )
1810 .unwrap();
1811
1812 let server = create_test_server();
1813
1814 {
1816 let mut roots = server.workspace_roots.write().await;
1817 roots.push(workspace_root.clone());
1818 }
1819
1820 let test_file = workspace_subdir.join("deep").join("test.md");
1822 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1823 fs::write(&test_file, "# Test\n").unwrap();
1824
1825 let config = server.resolve_config_for_file(&test_file).await;
1826 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1827
1828 assert_eq!(
1830 line_length,
1831 Some(100),
1832 "Should find workspace config, not parent config outside workspace"
1833 );
1834 }
1835
1836 #[tokio::test]
1838 async fn test_config_cache_hit() {
1839 use std::fs;
1840 use tempfile::tempdir;
1841
1842 let temp_dir = tempdir().unwrap();
1843 let temp_path = temp_dir.path();
1844
1845 let project = temp_path.join("project");
1846 fs::create_dir(&project).unwrap();
1847
1848 let config_file = project.join(".rumdl.toml");
1849 fs::write(
1850 &config_file,
1851 r#"
1852[global]
1853
1854[MD013]
1855line_length = 75
1856"#,
1857 )
1858 .unwrap();
1859
1860 let server = create_test_server();
1861 {
1862 let mut roots = server.workspace_roots.write().await;
1863 roots.push(project.clone());
1864 }
1865
1866 let test_file = project.join("test.md");
1867 fs::write(&test_file, "# Test\n").unwrap();
1868
1869 let config1 = server.resolve_config_for_file(&test_file).await;
1871 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1872 assert_eq!(line_length1, Some(75));
1873
1874 {
1876 let cache = server.config_cache.read().await;
1877 let search_dir = test_file.parent().unwrap();
1878 assert!(
1879 cache.contains_key(search_dir),
1880 "Cache should be populated after first call"
1881 );
1882 }
1883
1884 let config2 = server.resolve_config_for_file(&test_file).await;
1886 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1887 assert_eq!(line_length2, Some(75));
1888 }
1889
1890 #[tokio::test]
1892 async fn test_nested_directory_config_search() {
1893 use std::fs;
1894 use tempfile::tempdir;
1895
1896 let temp_dir = tempdir().unwrap();
1897 let temp_path = temp_dir.path();
1898
1899 let project = temp_path.join("project");
1900 fs::create_dir(&project).unwrap();
1901
1902 let config = project.join(".rumdl.toml");
1904 fs::write(
1905 &config,
1906 r#"
1907[global]
1908
1909[MD013]
1910line_length = 110
1911"#,
1912 )
1913 .unwrap();
1914
1915 let deep_dir = project.join("src").join("docs").join("guides");
1917 fs::create_dir_all(&deep_dir).unwrap();
1918 let deep_file = deep_dir.join("test.md");
1919 fs::write(&deep_file, "# Test\n").unwrap();
1920
1921 let server = create_test_server();
1922 {
1923 let mut roots = server.workspace_roots.write().await;
1924 roots.push(project.clone());
1925 }
1926
1927 let resolved_config = server.resolve_config_for_file(&deep_file).await;
1928 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1929
1930 assert_eq!(
1931 line_length,
1932 Some(110),
1933 "Should find config by searching upward from deep directory"
1934 );
1935 }
1936
1937 #[tokio::test]
1939 async fn test_fallback_to_default_config() {
1940 use std::fs;
1941 use tempfile::tempdir;
1942
1943 let temp_dir = tempdir().unwrap();
1944 let temp_path = temp_dir.path();
1945
1946 let project = temp_path.join("project");
1947 fs::create_dir(&project).unwrap();
1948
1949 let test_file = project.join("test.md");
1952 fs::write(&test_file, "# Test\n").unwrap();
1953
1954 let server = create_test_server();
1955 {
1956 let mut roots = server.workspace_roots.write().await;
1957 roots.push(project.clone());
1958 }
1959
1960 let config = server.resolve_config_for_file(&test_file).await;
1961
1962 assert_eq!(
1964 config.global.line_length, 80,
1965 "Should fall back to default config when no config file found"
1966 );
1967 }
1968
1969 #[tokio::test]
1971 async fn test_config_priority_closer_wins() {
1972 use std::fs;
1973 use tempfile::tempdir;
1974
1975 let temp_dir = tempdir().unwrap();
1976 let temp_path = temp_dir.path();
1977
1978 let project = temp_path.join("project");
1979 fs::create_dir(&project).unwrap();
1980
1981 let parent_config = project.join(".rumdl.toml");
1983 fs::write(
1984 &parent_config,
1985 r#"
1986[global]
1987
1988[MD013]
1989line_length = 100
1990"#,
1991 )
1992 .unwrap();
1993
1994 let subdir = project.join("subdir");
1996 fs::create_dir(&subdir).unwrap();
1997
1998 let subdir_config = subdir.join(".rumdl.toml");
1999 fs::write(
2000 &subdir_config,
2001 r#"
2002[global]
2003
2004[MD013]
2005line_length = 50
2006"#,
2007 )
2008 .unwrap();
2009
2010 let server = create_test_server();
2011 {
2012 let mut roots = server.workspace_roots.write().await;
2013 roots.push(project.clone());
2014 }
2015
2016 let test_file = subdir.join("test.md");
2018 fs::write(&test_file, "# Test\n").unwrap();
2019
2020 let config = server.resolve_config_for_file(&test_file).await;
2021 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2022
2023 assert_eq!(
2024 line_length,
2025 Some(50),
2026 "Closer config (subdir) should override parent config"
2027 );
2028 }
2029
2030 #[tokio::test]
2036 async fn test_issue_131_pyproject_without_rumdl_section() {
2037 use std::fs;
2038 use tempfile::tempdir;
2039
2040 let parent_dir = tempdir().unwrap();
2042
2043 let project_dir = parent_dir.path().join("project");
2045 fs::create_dir(&project_dir).unwrap();
2046
2047 fs::write(
2049 project_dir.join("pyproject.toml"),
2050 r#"
2051[project]
2052name = "test-project"
2053version = "0.1.0"
2054"#,
2055 )
2056 .unwrap();
2057
2058 fs::write(
2061 parent_dir.path().join(".rumdl.toml"),
2062 r#"
2063[global]
2064disable = ["MD013"]
2065"#,
2066 )
2067 .unwrap();
2068
2069 let test_file = project_dir.join("test.md");
2070 fs::write(&test_file, "# Test\n").unwrap();
2071
2072 let server = create_test_server();
2073
2074 {
2076 let mut roots = server.workspace_roots.write().await;
2077 roots.push(parent_dir.path().to_path_buf());
2078 }
2079
2080 let config = server.resolve_config_for_file(&test_file).await;
2082
2083 assert!(
2086 config.global.disable.contains(&"MD013".to_string()),
2087 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2088 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2089 );
2090
2091 let cache = server.config_cache.read().await;
2094 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2095
2096 assert!(
2097 cache_entry.config_file.is_some(),
2098 "Should have found a config file (parent .rumdl.toml)"
2099 );
2100
2101 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2102 assert!(
2103 found_config_path.ends_with(".rumdl.toml"),
2104 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2105 );
2106 assert!(
2107 found_config_path.parent().unwrap() == parent_dir.path(),
2108 "Should have loaded config from parent directory, not project_dir"
2109 );
2110 }
2111
2112 #[tokio::test]
2117 async fn test_issue_131_pyproject_with_rumdl_section() {
2118 use std::fs;
2119 use tempfile::tempdir;
2120
2121 let parent_dir = tempdir().unwrap();
2123
2124 let project_dir = parent_dir.path().join("project");
2126 fs::create_dir(&project_dir).unwrap();
2127
2128 fs::write(
2130 project_dir.join("pyproject.toml"),
2131 r#"
2132[project]
2133name = "test-project"
2134
2135[tool.rumdl.global]
2136disable = ["MD033"]
2137"#,
2138 )
2139 .unwrap();
2140
2141 fs::write(
2143 parent_dir.path().join(".rumdl.toml"),
2144 r#"
2145[global]
2146disable = ["MD041"]
2147"#,
2148 )
2149 .unwrap();
2150
2151 let test_file = project_dir.join("test.md");
2152 fs::write(&test_file, "# Test\n").unwrap();
2153
2154 let server = create_test_server();
2155
2156 {
2158 let mut roots = server.workspace_roots.write().await;
2159 roots.push(parent_dir.path().to_path_buf());
2160 }
2161
2162 let config = server.resolve_config_for_file(&test_file).await;
2164
2165 assert!(
2167 config.global.disable.contains(&"MD033".to_string()),
2168 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2169 Expected MD033 from project_dir pyproject.toml to be disabled."
2170 );
2171
2172 assert!(
2174 !config.global.disable.contains(&"MD041".to_string()),
2175 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2176 );
2177
2178 let cache = server.config_cache.read().await;
2180 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2181
2182 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2183
2184 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2185 assert!(
2186 found_config_path.ends_with("pyproject.toml"),
2187 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2188 );
2189 assert!(
2190 found_config_path.parent().unwrap() == project_dir,
2191 "Should have loaded pyproject.toml from project_dir, not parent"
2192 );
2193 }
2194
2195 #[tokio::test]
2200 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2201 use std::fs;
2202 use tempfile::tempdir;
2203
2204 let temp_dir = tempdir().unwrap();
2205
2206 fs::write(
2208 temp_dir.path().join("pyproject.toml"),
2209 r#"
2210[project]
2211name = "test-project"
2212
2213[tool.rumdl.global]
2214disable = ["MD022"]
2215"#,
2216 )
2217 .unwrap();
2218
2219 let test_file = temp_dir.path().join("test.md");
2220 fs::write(&test_file, "# Test\n").unwrap();
2221
2222 let server = create_test_server();
2223
2224 {
2226 let mut roots = server.workspace_roots.write().await;
2227 roots.push(temp_dir.path().to_path_buf());
2228 }
2229
2230 let config = server.resolve_config_for_file(&test_file).await;
2232
2233 assert!(
2235 config.global.disable.contains(&"MD022".to_string()),
2236 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2237 );
2238
2239 let cache = server.config_cache.read().await;
2241 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2242 assert!(
2243 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2244 "Should have loaded pyproject.toml"
2245 );
2246 }
2247}