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 client_supports_pull_diagnostics: Arc<RwLock<bool>>,
90}
91
92impl RumdlLanguageServer {
93 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
94 let mut initial_config = RumdlLspConfig::default();
96 if let Some(path) = cli_config_path {
97 initial_config.config_path = Some(path.to_string());
98 }
99
100 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
102 let index_state = Arc::new(RwLock::new(IndexState::default()));
103 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
104
105 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
107 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
108
109 let worker = IndexWorker::new(
111 update_rx,
112 workspace_index.clone(),
113 index_state.clone(),
114 client.clone(),
115 workspace_roots.clone(),
116 relint_tx,
117 );
118 tokio::spawn(worker.run());
119
120 Self {
121 client,
122 config: Arc::new(RwLock::new(initial_config)),
123 rumdl_config: Arc::new(RwLock::new(Config::default())),
124 documents: Arc::new(RwLock::new(HashMap::new())),
125 workspace_roots,
126 config_cache: Arc::new(RwLock::new(HashMap::new())),
127 workspace_index,
128 index_state,
129 update_tx,
130 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
131 }
132 }
133
134 async fn get_document_content(&self, uri: &Url) -> Option<String> {
140 {
142 let docs = self.documents.read().await;
143 if let Some(entry) = docs.get(uri) {
144 return Some(entry.content.clone());
145 }
146 }
147
148 if let Ok(path) = uri.to_file_path() {
150 if let Ok(content) = tokio::fs::read_to_string(&path).await {
151 let entry = DocumentEntry {
153 content: content.clone(),
154 version: None,
155 from_disk: true,
156 };
157
158 let mut docs = self.documents.write().await;
159 docs.insert(uri.clone(), entry);
160
161 log::debug!("Loaded document from disk and cached: {uri}");
162 return Some(content);
163 } else {
164 log::debug!("Failed to read file from disk: {uri}");
165 }
166 }
167
168 None
169 }
170
171 fn apply_lsp_config_overrides(
173 &self,
174 mut filtered_rules: Vec<Box<dyn Rule>>,
175 lsp_config: &RumdlLspConfig,
176 ) -> Vec<Box<dyn Rule>> {
177 if let Some(enable) = &lsp_config.enable_rules
179 && !enable.is_empty()
180 {
181 let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
182 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
183 }
184
185 if let Some(disable) = &lsp_config.disable_rules
187 && !disable.is_empty()
188 {
189 let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
190 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
191 }
192
193 filtered_rules
194 }
195
196 async fn should_exclude_uri(&self, uri: &Url) -> bool {
198 let file_path = match uri.to_file_path() {
200 Ok(path) => path,
201 Err(_) => return false, };
203
204 let rumdl_config = self.resolve_config_for_file(&file_path).await;
206 let exclude_patterns = &rumdl_config.global.exclude;
207
208 if exclude_patterns.is_empty() {
210 return false;
211 }
212
213 let path_to_check = if file_path.is_absolute() {
216 if let Ok(cwd) = std::env::current_dir() {
218 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
220 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
221 relative.to_string_lossy().to_string()
222 } else {
223 file_path.to_string_lossy().to_string()
225 }
226 } else {
227 file_path.to_string_lossy().to_string()
229 }
230 } else {
231 file_path.to_string_lossy().to_string()
232 }
233 } else {
234 file_path.to_string_lossy().to_string()
236 };
237
238 for pattern in exclude_patterns {
240 if let Ok(glob) = globset::Glob::new(pattern) {
241 let matcher = glob.compile_matcher();
242 if matcher.is_match(&path_to_check) {
243 log::debug!("Excluding file from LSP linting: {path_to_check}");
244 return true;
245 }
246 }
247 }
248
249 false
250 }
251
252 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
254 let config_guard = self.config.read().await;
255
256 if !config_guard.enable_linting {
258 return Ok(Vec::new());
259 }
260
261 let lsp_config = config_guard.clone();
262 drop(config_guard); if self.should_exclude_uri(uri).await {
266 return Ok(Vec::new());
267 }
268
269 let file_path = uri.to_file_path().ok();
271 let rumdl_config = if let Some(ref path) = file_path {
272 self.resolve_config_for_file(path).await
273 } else {
274 (*self.rumdl_config.read().await).clone()
276 };
277
278 let all_rules = rules::all_rules(&rumdl_config);
279 let flavor = rumdl_config.markdown_flavor();
280
281 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
283
284 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
286
287 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor) {
289 Ok(warnings) => warnings,
290 Err(e) => {
291 log::error!("Failed to lint document {uri}: {e}");
292 return Ok(Vec::new());
293 }
294 };
295
296 if let Some(ref path) = file_path {
298 let index_state = self.index_state.read().await.clone();
299 if matches!(index_state, IndexState::Ready) {
300 let workspace_index = self.workspace_index.read().await;
301 if let Some(file_index) = workspace_index.get_file(path) {
302 match crate::run_cross_file_checks(path, file_index, &filtered_rules, &workspace_index) {
303 Ok(cross_file_warnings) => {
304 all_warnings.extend(cross_file_warnings);
305 }
306 Err(e) => {
307 log::warn!("Failed to run cross-file checks for {uri}: {e}");
308 }
309 }
310 }
311 }
312 }
313
314 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
315 Ok(diagnostics)
316 }
317
318 async fn update_diagnostics(&self, uri: Url, text: String) {
324 if *self.client_supports_pull_diagnostics.read().await {
326 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
327 return;
328 }
329
330 let version = {
332 let docs = self.documents.read().await;
333 docs.get(&uri).and_then(|entry| entry.version)
334 };
335
336 match self.lint_document(&uri, &text).await {
337 Ok(diagnostics) => {
338 self.client.publish_diagnostics(uri, diagnostics, version).await;
339 }
340 Err(e) => {
341 log::error!("Failed to update diagnostics: {e}");
342 }
343 }
344 }
345
346 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
348 if self.should_exclude_uri(uri).await {
350 return Ok(None);
351 }
352
353 let config_guard = self.config.read().await;
354 let lsp_config = config_guard.clone();
355 drop(config_guard);
356
357 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
359 self.resolve_config_for_file(&file_path).await
360 } else {
361 (*self.rumdl_config.read().await).clone()
363 };
364
365 let all_rules = rules::all_rules(&rumdl_config);
366 let flavor = rumdl_config.markdown_flavor();
367
368 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
370
371 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
373
374 let mut rules_with_warnings = std::collections::HashSet::new();
377 let mut fixed_text = text.to_string();
378
379 match lint(&fixed_text, &filtered_rules, false, flavor) {
380 Ok(warnings) => {
381 for warning in warnings {
382 if let Some(rule_name) = &warning.rule_name {
383 rules_with_warnings.insert(rule_name.clone());
384 }
385 }
386 }
387 Err(e) => {
388 log::warn!("Failed to lint document for auto-fix: {e}");
389 return Ok(None);
390 }
391 }
392
393 if rules_with_warnings.is_empty() {
395 return Ok(None);
396 }
397
398 let mut any_changes = false;
400
401 for rule in &filtered_rules {
402 if !rules_with_warnings.contains(rule.name()) {
404 continue;
405 }
406
407 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
408 match rule.fix(&ctx) {
409 Ok(new_text) => {
410 if new_text != fixed_text {
411 fixed_text = new_text;
412 any_changes = true;
413 }
414 }
415 Err(e) => {
416 let msg = e.to_string();
418 if !msg.contains("does not support automatic fixing") {
419 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
420 }
421 }
422 }
423 }
424
425 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
426 }
427
428 fn get_end_position(&self, text: &str) -> Position {
430 let mut line = 0u32;
431 let mut character = 0u32;
432
433 for ch in text.chars() {
434 if ch == '\n' {
435 line += 1;
436 character = 0;
437 } else {
438 character += 1;
439 }
440 }
441
442 Position { line, character }
443 }
444
445 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
447 let config_guard = self.config.read().await;
448 let lsp_config = config_guard.clone();
449 drop(config_guard);
450
451 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
453 self.resolve_config_for_file(&file_path).await
454 } else {
455 (*self.rumdl_config.read().await).clone()
457 };
458
459 let all_rules = rules::all_rules(&rumdl_config);
460 let flavor = rumdl_config.markdown_flavor();
461
462 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
464
465 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
467
468 match crate::lint(text, &filtered_rules, false, flavor) {
469 Ok(warnings) => {
470 let mut actions = Vec::new();
471 let mut fixable_count = 0;
472
473 for warning in &warnings {
474 let warning_line = (warning.line.saturating_sub(1)) as u32;
476 if warning_line >= range.start.line && warning_line <= range.end.line {
477 let mut warning_actions = warning_to_code_actions(warning, uri, text);
479 actions.append(&mut warning_actions);
480
481 if warning.fix.is_some() {
482 fixable_count += 1;
483 }
484 }
485 }
486
487 if fixable_count > 1 {
489 let fixable_warnings: Vec<_> = warnings
492 .iter()
493 .filter(|w| {
494 if let Some(rule_name) = &w.rule_name {
495 filtered_rules
496 .iter()
497 .find(|r| r.name() == rule_name)
498 .map(|r| r.fix_capability() != FixCapability::Unfixable)
499 .unwrap_or(false)
500 } else {
501 false
502 }
503 })
504 .cloned()
505 .collect();
506
507 let total_fixable = fixable_warnings.len();
509
510 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
511 && fixed_content != text
512 {
513 let mut line = 0u32;
515 let mut character = 0u32;
516 for ch in text.chars() {
517 if ch == '\n' {
518 line += 1;
519 character = 0;
520 } else {
521 character += 1;
522 }
523 }
524
525 let fix_all_action = CodeAction {
526 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
527 kind: Some(CodeActionKind::QUICKFIX),
528 diagnostics: Some(Vec::new()),
529 edit: Some(WorkspaceEdit {
530 changes: Some(
531 [(
532 uri.clone(),
533 vec![TextEdit {
534 range: Range {
535 start: Position { line: 0, character: 0 },
536 end: Position { line, character },
537 },
538 new_text: fixed_content,
539 }],
540 )]
541 .into_iter()
542 .collect(),
543 ),
544 ..Default::default()
545 }),
546 command: None,
547 is_preferred: Some(true),
548 disabled: None,
549 data: None,
550 };
551
552 actions.insert(0, fix_all_action);
554 }
555 }
556
557 Ok(actions)
558 }
559 Err(e) => {
560 log::error!("Failed to get code actions: {e}");
561 Ok(Vec::new())
562 }
563 }
564 }
565
566 async fn load_configuration(&self, notify_client: bool) {
568 let config_guard = self.config.read().await;
569 let explicit_config_path = config_guard.config_path.clone();
570 drop(config_guard);
571
572 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
574 Ok(sourced_config) => {
575 let loaded_files = sourced_config.loaded_files.clone();
576 *self.rumdl_config.write().await = sourced_config.into();
577
578 if !loaded_files.is_empty() {
579 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
580 log::info!("{message}");
581 if notify_client {
582 self.client.log_message(MessageType::INFO, &message).await;
583 }
584 } else {
585 log::info!("Using default rumdl configuration (no config files found)");
586 }
587 }
588 Err(e) => {
589 let message = format!("Failed to load rumdl config: {e}");
590 log::warn!("{message}");
591 if notify_client {
592 self.client.log_message(MessageType::WARNING, &message).await;
593 }
594 *self.rumdl_config.write().await = crate::config::Config::default();
596 }
597 }
598 }
599
600 async fn reload_configuration(&self) {
602 self.load_configuration(true).await;
603 }
604
605 fn load_config_for_lsp(
607 config_path: Option<&str>,
608 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
609 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
611 }
612
613 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
620 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
622
623 {
625 let cache = self.config_cache.read().await;
626 if let Some(entry) = cache.get(&search_dir) {
627 let source_owned: String; let source: &str = if entry.from_global_fallback {
629 "global/user fallback"
630 } else if let Some(path) = &entry.config_file {
631 source_owned = path.to_string_lossy().to_string();
632 &source_owned
633 } else {
634 "<unknown>"
635 };
636 log::debug!(
637 "Config cache hit for directory: {} (loaded from: {})",
638 search_dir.display(),
639 source
640 );
641 return entry.config.clone();
642 }
643 }
644
645 log::debug!(
647 "Config cache miss for directory: {}, searching for config...",
648 search_dir.display()
649 );
650
651 let workspace_root = {
653 let workspace_roots = self.workspace_roots.read().await;
654 workspace_roots
655 .iter()
656 .find(|root| search_dir.starts_with(root))
657 .map(|p| p.to_path_buf())
658 };
659
660 let mut current_dir = search_dir.clone();
662 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
663
664 loop {
665 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
667
668 for config_file_name in CONFIG_FILES {
669 let config_path = current_dir.join(config_file_name);
670 if config_path.exists() {
671 if *config_file_name == "pyproject.toml" {
673 if let Ok(content) = std::fs::read_to_string(&config_path) {
674 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
675 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
676 } else {
677 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
678 continue;
679 }
680 } else {
681 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
682 continue;
683 }
684 } else {
685 log::debug!("Found config file: {}", config_path.display());
686 }
687
688 if let Some(config_path_str) = config_path.to_str() {
690 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
691 found_config = Some((sourced.into(), Some(config_path)));
692 break;
693 }
694 } else {
695 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
696 }
697 }
698 }
699
700 if found_config.is_some() {
701 break;
702 }
703
704 if let Some(ref root) = workspace_root
706 && ¤t_dir == root
707 {
708 log::debug!("Hit workspace root without finding config: {}", root.display());
709 break;
710 }
711
712 if let Some(parent) = current_dir.parent() {
714 current_dir = parent.to_path_buf();
715 } else {
716 break;
718 }
719 }
720
721 let (config, config_file) = if let Some((cfg, path)) = found_config {
723 (cfg, path)
724 } else {
725 log::debug!("No project config found; using global/user fallback config");
726 let fallback = self.rumdl_config.read().await.clone();
727 (fallback, None)
728 };
729
730 let from_global = config_file.is_none();
732 let entry = ConfigCacheEntry {
733 config: config.clone(),
734 config_file,
735 from_global_fallback: from_global,
736 };
737
738 self.config_cache.write().await.insert(search_dir, entry);
739
740 config
741 }
742}
743
744#[tower_lsp::async_trait]
745impl LanguageServer for RumdlLanguageServer {
746 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
747 log::info!("Initializing rumdl Language Server");
748
749 if let Some(options) = params.initialization_options
751 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
752 {
753 *self.config.write().await = config;
754 }
755
756 let supports_pull = params
759 .capabilities
760 .text_document
761 .as_ref()
762 .and_then(|td| td.diagnostic.as_ref())
763 .is_some();
764
765 if supports_pull {
766 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
767 *self.client_supports_pull_diagnostics.write().await = true;
768 } else {
769 log::info!("Client does not support pull diagnostics - using push model");
770 }
771
772 let mut roots = Vec::new();
774 if let Some(workspace_folders) = params.workspace_folders {
775 for folder in workspace_folders {
776 if let Ok(path) = folder.uri.to_file_path() {
777 log::info!("Workspace root: {}", path.display());
778 roots.push(path);
779 }
780 }
781 } else if let Some(root_uri) = params.root_uri
782 && let Ok(path) = root_uri.to_file_path()
783 {
784 log::info!("Workspace root: {}", path.display());
785 roots.push(path);
786 }
787 *self.workspace_roots.write().await = roots;
788
789 self.load_configuration(false).await;
791
792 Ok(InitializeResult {
793 capabilities: ServerCapabilities {
794 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
795 open_close: Some(true),
796 change: Some(TextDocumentSyncKind::FULL),
797 will_save: Some(false),
798 will_save_wait_until: Some(true),
799 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
800 include_text: Some(false),
801 })),
802 })),
803 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
804 document_formatting_provider: Some(OneOf::Left(true)),
805 document_range_formatting_provider: Some(OneOf::Left(true)),
806 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
807 identifier: Some("rumdl".to_string()),
808 inter_file_dependencies: true,
809 workspace_diagnostics: true,
810 work_done_progress_options: WorkDoneProgressOptions::default(),
811 })),
812 workspace: Some(WorkspaceServerCapabilities {
813 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
814 supported: Some(true),
815 change_notifications: Some(OneOf::Left(true)),
816 }),
817 file_operations: None,
818 }),
819 ..Default::default()
820 },
821 server_info: Some(ServerInfo {
822 name: "rumdl".to_string(),
823 version: Some(env!("CARGO_PKG_VERSION").to_string()),
824 }),
825 })
826 }
827
828 async fn initialized(&self, _: InitializedParams) {
829 let version = env!("CARGO_PKG_VERSION");
830
831 let (binary_path, build_time) = std::env::current_exe()
833 .ok()
834 .map(|path| {
835 let path_str = path.to_str().unwrap_or("unknown").to_string();
836 let build_time = std::fs::metadata(&path)
837 .ok()
838 .and_then(|metadata| metadata.modified().ok())
839 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
840 .and_then(|duration| {
841 let secs = duration.as_secs();
842 chrono::DateTime::from_timestamp(secs as i64, 0)
843 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
844 })
845 .unwrap_or_else(|| "unknown".to_string());
846 (path_str, build_time)
847 })
848 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
849
850 let working_dir = std::env::current_dir()
851 .ok()
852 .and_then(|p| p.to_str().map(|s| s.to_string()))
853 .unwrap_or_else(|| "unknown".to_string());
854
855 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
856 log::info!("Working directory: {working_dir}");
857
858 self.client
859 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
860 .await;
861
862 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
864 log::warn!("Failed to trigger initial workspace indexing");
865 } else {
866 log::info!("Triggered initial workspace indexing for cross-file analysis");
867 }
868
869 let markdown_patterns = [
872 "**/*.md",
873 "**/*.markdown",
874 "**/*.mdx",
875 "**/*.mkd",
876 "**/*.mkdn",
877 "**/*.mdown",
878 "**/*.mdwn",
879 "**/*.qmd",
880 "**/*.rmd",
881 ];
882 let watchers: Vec<_> = markdown_patterns
883 .iter()
884 .map(|pattern| FileSystemWatcher {
885 glob_pattern: GlobPattern::String((*pattern).to_string()),
886 kind: Some(WatchKind::all()),
887 })
888 .collect();
889
890 let registration = Registration {
891 id: "markdown-watcher".to_string(),
892 method: "workspace/didChangeWatchedFiles".to_string(),
893 register_options: Some(
894 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
895 ),
896 };
897
898 if self.client.register_capability(vec![registration]).await.is_err() {
899 log::debug!("Client does not support file watching capability");
900 }
901 }
902
903 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
904 let mut roots = self.workspace_roots.write().await;
906
907 for removed in ¶ms.event.removed {
909 if let Ok(path) = removed.uri.to_file_path() {
910 roots.retain(|r| r != &path);
911 log::info!("Removed workspace root: {}", path.display());
912 }
913 }
914
915 for added in ¶ms.event.added {
917 if let Ok(path) = added.uri.to_file_path()
918 && !roots.contains(&path)
919 {
920 log::info!("Added workspace root: {}", path.display());
921 roots.push(path);
922 }
923 }
924 drop(roots);
925
926 self.config_cache.write().await.clear();
928
929 self.reload_configuration().await;
931
932 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
934 log::warn!("Failed to trigger workspace rescan after folder change");
935 }
936 }
937
938 async fn shutdown(&self) -> JsonRpcResult<()> {
939 log::info!("Shutting down rumdl Language Server");
940
941 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
943
944 Ok(())
945 }
946
947 async fn did_open(&self, params: DidOpenTextDocumentParams) {
948 let uri = params.text_document.uri;
949 let text = params.text_document.text;
950 let version = params.text_document.version;
951
952 let entry = DocumentEntry {
953 content: text.clone(),
954 version: Some(version),
955 from_disk: false,
956 };
957 self.documents.write().await.insert(uri.clone(), entry);
958
959 if let Ok(path) = uri.to_file_path() {
961 let _ = self
962 .update_tx
963 .send(IndexUpdate::FileChanged {
964 path,
965 content: text.clone(),
966 })
967 .await;
968 }
969
970 self.update_diagnostics(uri, text).await;
971 }
972
973 async fn did_change(&self, params: DidChangeTextDocumentParams) {
974 let uri = params.text_document.uri;
975 let version = params.text_document.version;
976
977 if let Some(change) = params.content_changes.into_iter().next() {
978 let text = change.text;
979
980 let entry = DocumentEntry {
981 content: text.clone(),
982 version: Some(version),
983 from_disk: false,
984 };
985 self.documents.write().await.insert(uri.clone(), entry);
986
987 if let Ok(path) = uri.to_file_path() {
989 let _ = self
990 .update_tx
991 .send(IndexUpdate::FileChanged {
992 path,
993 content: text.clone(),
994 })
995 .await;
996 }
997
998 self.update_diagnostics(uri, text).await;
999 }
1000 }
1001
1002 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1003 let config_guard = self.config.read().await;
1004 let enable_auto_fix = config_guard.enable_auto_fix;
1005 drop(config_guard);
1006
1007 if !enable_auto_fix {
1008 return Ok(None);
1009 }
1010
1011 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1013 return Ok(None);
1014 };
1015
1016 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1018 Ok(Some(fixed_text)) => {
1019 Ok(Some(vec![TextEdit {
1021 range: Range {
1022 start: Position { line: 0, character: 0 },
1023 end: self.get_end_position(&text),
1024 },
1025 new_text: fixed_text,
1026 }]))
1027 }
1028 Ok(None) => Ok(None),
1029 Err(e) => {
1030 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1031 Ok(None)
1032 }
1033 }
1034 }
1035
1036 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1037 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1040 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1041 .await;
1042 }
1043 }
1044
1045 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1046 self.documents.write().await.remove(¶ms.text_document.uri);
1048
1049 self.client
1052 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1053 .await;
1054 }
1055
1056 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1057 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1059
1060 let mut config_changed = false;
1061
1062 for change in ¶ms.changes {
1063 if let Ok(path) = change.uri.to_file_path() {
1064 let file_name = path.file_name().and_then(|f| f.to_str());
1065 let extension = path.extension().and_then(|e| e.to_str());
1066
1067 if let Some(name) = file_name
1069 && CONFIG_FILES.contains(&name)
1070 && !config_changed
1071 {
1072 log::info!("Config file changed: {}, invalidating config cache", path.display());
1073
1074 let mut cache = self.config_cache.write().await;
1076 cache.retain(|_, entry| {
1077 if let Some(config_file) = &entry.config_file {
1078 config_file != &path
1079 } else {
1080 true
1081 }
1082 });
1083
1084 drop(cache);
1086 self.reload_configuration().await;
1087 config_changed = true;
1088 }
1089
1090 if let Some(ext) = extension
1092 && is_markdown_extension(ext)
1093 {
1094 match change.typ {
1095 FileChangeType::CREATED | FileChangeType::CHANGED => {
1096 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1098 let _ = self
1099 .update_tx
1100 .send(IndexUpdate::FileChanged {
1101 path: path.clone(),
1102 content,
1103 })
1104 .await;
1105 }
1106 }
1107 FileChangeType::DELETED => {
1108 let _ = self
1109 .update_tx
1110 .send(IndexUpdate::FileDeleted { path: path.clone() })
1111 .await;
1112 }
1113 _ => {}
1114 }
1115 }
1116 }
1117 }
1118
1119 if config_changed {
1121 let docs_to_update: Vec<(Url, String)> = {
1122 let docs = self.documents.read().await;
1123 docs.iter()
1124 .filter(|(_, entry)| !entry.from_disk)
1125 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1126 .collect()
1127 };
1128
1129 for (uri, text) in docs_to_update {
1130 self.update_diagnostics(uri, text).await;
1131 }
1132 }
1133 }
1134
1135 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1136 let uri = params.text_document.uri;
1137 let range = params.range;
1138
1139 if let Some(text) = self.get_document_content(&uri).await {
1140 match self.get_code_actions(&uri, &text, range).await {
1141 Ok(actions) => {
1142 let response: Vec<CodeActionOrCommand> =
1143 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1144 Ok(Some(response))
1145 }
1146 Err(e) => {
1147 log::error!("Failed to get code actions: {e}");
1148 Ok(None)
1149 }
1150 }
1151 } else {
1152 Ok(None)
1153 }
1154 }
1155
1156 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1157 log::debug!(
1162 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1163 params.range
1164 );
1165
1166 let formatting_params = DocumentFormattingParams {
1167 text_document: params.text_document,
1168 options: params.options,
1169 work_done_progress_params: params.work_done_progress_params,
1170 };
1171
1172 self.formatting(formatting_params).await
1173 }
1174
1175 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1176 let uri = params.text_document.uri;
1177
1178 log::debug!("Formatting request for: {uri}");
1179
1180 if let Some(text) = self.get_document_content(&uri).await {
1181 let config_guard = self.config.read().await;
1183 let lsp_config = config_guard.clone();
1184 drop(config_guard);
1185
1186 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
1188 self.resolve_config_for_file(&file_path).await
1189 } else {
1190 self.rumdl_config.read().await.clone()
1192 };
1193
1194 let all_rules = rules::all_rules(&rumdl_config);
1195 let flavor = rumdl_config.markdown_flavor();
1196
1197 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1199
1200 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1202
1203 match crate::lint(&text, &filtered_rules, false, flavor) {
1205 Ok(warnings) => {
1206 log::debug!(
1207 "Found {} warnings, {} with fixes",
1208 warnings.len(),
1209 warnings.iter().filter(|w| w.fix.is_some()).count()
1210 );
1211
1212 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1213 if has_fixes {
1214 let fixable_warnings: Vec<_> = warnings
1218 .iter()
1219 .filter(|w| {
1220 if let Some(rule_name) = &w.rule_name {
1221 filtered_rules
1222 .iter()
1223 .find(|r| r.name() == rule_name)
1224 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1225 .unwrap_or(false)
1226 } else {
1227 false
1228 }
1229 })
1230 .cloned()
1231 .collect();
1232
1233 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1234 Ok(fixed_content) => {
1235 if fixed_content != text {
1236 log::debug!("Returning formatting edits");
1237 let end_position = self.get_end_position(&text);
1238 let edit = TextEdit {
1239 range: Range {
1240 start: Position { line: 0, character: 0 },
1241 end: end_position,
1242 },
1243 new_text: fixed_content,
1244 };
1245 return Ok(Some(vec![edit]));
1246 }
1247 }
1248 Err(e) => {
1249 log::error!("Failed to apply fixes: {e}");
1250 }
1251 }
1252 }
1253 Ok(Some(Vec::new()))
1254 }
1255 Err(e) => {
1256 log::error!("Failed to format document: {e}");
1257 Ok(Some(Vec::new()))
1258 }
1259 }
1260 } else {
1261 log::warn!("Document not found: {uri}");
1262 Ok(None)
1263 }
1264 }
1265
1266 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1267 let uri = params.text_document.uri;
1268
1269 if let Some(text) = self.get_document_content(&uri).await {
1270 match self.lint_document(&uri, &text).await {
1271 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1272 RelatedFullDocumentDiagnosticReport {
1273 related_documents: None,
1274 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1275 result_id: None,
1276 items: diagnostics,
1277 },
1278 },
1279 ))),
1280 Err(e) => {
1281 log::error!("Failed to get diagnostics: {e}");
1282 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1283 RelatedFullDocumentDiagnosticReport {
1284 related_documents: None,
1285 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1286 result_id: None,
1287 items: Vec::new(),
1288 },
1289 },
1290 )))
1291 }
1292 }
1293 } else {
1294 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1295 RelatedFullDocumentDiagnosticReport {
1296 related_documents: None,
1297 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1298 result_id: None,
1299 items: Vec::new(),
1300 },
1301 },
1302 )))
1303 }
1304 }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310 use crate::rule::LintWarning;
1311 use tower_lsp::LspService;
1312
1313 fn create_test_server() -> RumdlLanguageServer {
1314 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1315 service.inner().clone()
1316 }
1317
1318 #[tokio::test]
1319 async fn test_server_creation() {
1320 let server = create_test_server();
1321
1322 let config = server.config.read().await;
1324 assert!(config.enable_linting);
1325 assert!(!config.enable_auto_fix);
1326 }
1327
1328 #[tokio::test]
1329 async fn test_lint_document() {
1330 let server = create_test_server();
1331
1332 let uri = Url::parse("file:///test.md").unwrap();
1334 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1335
1336 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1337
1338 assert!(!diagnostics.is_empty());
1340 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1341 }
1342
1343 #[tokio::test]
1344 async fn test_lint_document_disabled() {
1345 let server = create_test_server();
1346
1347 server.config.write().await.enable_linting = false;
1349
1350 let uri = Url::parse("file:///test.md").unwrap();
1351 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1352
1353 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1354
1355 assert!(diagnostics.is_empty());
1357 }
1358
1359 #[tokio::test]
1360 async fn test_get_code_actions() {
1361 let server = create_test_server();
1362
1363 let uri = Url::parse("file:///test.md").unwrap();
1364 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1365
1366 let range = Range {
1368 start: Position { line: 0, character: 0 },
1369 end: Position { line: 3, character: 21 },
1370 };
1371
1372 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1373
1374 assert!(!actions.is_empty());
1376 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1377 }
1378
1379 #[tokio::test]
1380 async fn test_get_code_actions_outside_range() {
1381 let server = create_test_server();
1382
1383 let uri = Url::parse("file:///test.md").unwrap();
1384 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1385
1386 let range = Range {
1388 start: Position { line: 0, character: 0 },
1389 end: Position { line: 0, character: 6 },
1390 };
1391
1392 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1393
1394 assert!(actions.is_empty());
1396 }
1397
1398 #[tokio::test]
1399 async fn test_document_storage() {
1400 let server = create_test_server();
1401
1402 let uri = Url::parse("file:///test.md").unwrap();
1403 let text = "# Test Document";
1404
1405 let entry = DocumentEntry {
1407 content: text.to_string(),
1408 version: Some(1),
1409 from_disk: false,
1410 };
1411 server.documents.write().await.insert(uri.clone(), entry);
1412
1413 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1415 assert_eq!(stored, Some(text.to_string()));
1416
1417 server.documents.write().await.remove(&uri);
1419
1420 let stored = server.documents.read().await.get(&uri).cloned();
1422 assert_eq!(stored, None);
1423 }
1424
1425 #[tokio::test]
1426 async fn test_configuration_loading() {
1427 let server = create_test_server();
1428
1429 server.load_configuration(false).await;
1431
1432 let rumdl_config = server.rumdl_config.read().await;
1435 drop(rumdl_config); }
1438
1439 #[tokio::test]
1440 async fn test_load_config_for_lsp() {
1441 let result = RumdlLanguageServer::load_config_for_lsp(None);
1443 assert!(result.is_ok());
1444
1445 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1447 assert!(result.is_err());
1448 }
1449
1450 #[tokio::test]
1451 async fn test_warning_conversion() {
1452 let warning = LintWarning {
1453 message: "Test warning".to_string(),
1454 line: 1,
1455 column: 1,
1456 end_line: 1,
1457 end_column: 10,
1458 severity: crate::rule::Severity::Warning,
1459 fix: None,
1460 rule_name: Some("MD001".to_string()),
1461 };
1462
1463 let diagnostic = warning_to_diagnostic(&warning);
1465 assert_eq!(diagnostic.message, "Test warning");
1466 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1467 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1468
1469 let uri = Url::parse("file:///test.md").unwrap();
1471 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1472 assert_eq!(actions.len(), 1);
1474 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1475 }
1476
1477 #[tokio::test]
1478 async fn test_multiple_documents() {
1479 let server = create_test_server();
1480
1481 let uri1 = Url::parse("file:///test1.md").unwrap();
1482 let uri2 = Url::parse("file:///test2.md").unwrap();
1483 let text1 = "# Document 1";
1484 let text2 = "# Document 2";
1485
1486 {
1488 let mut docs = server.documents.write().await;
1489 let entry1 = DocumentEntry {
1490 content: text1.to_string(),
1491 version: Some(1),
1492 from_disk: false,
1493 };
1494 let entry2 = DocumentEntry {
1495 content: text2.to_string(),
1496 version: Some(1),
1497 from_disk: false,
1498 };
1499 docs.insert(uri1.clone(), entry1);
1500 docs.insert(uri2.clone(), entry2);
1501 }
1502
1503 let docs = server.documents.read().await;
1505 assert_eq!(docs.len(), 2);
1506 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1507 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1508 }
1509
1510 #[tokio::test]
1511 async fn test_auto_fix_on_save() {
1512 let server = create_test_server();
1513
1514 {
1516 let mut config = server.config.write().await;
1517 config.enable_auto_fix = true;
1518 }
1519
1520 let uri = Url::parse("file:///test.md").unwrap();
1521 let text = "#Heading without space"; let entry = DocumentEntry {
1525 content: text.to_string(),
1526 version: Some(1),
1527 from_disk: false,
1528 };
1529 server.documents.write().await.insert(uri.clone(), entry);
1530
1531 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1533 assert!(fixed.is_some());
1534 assert_eq!(fixed.unwrap(), "# Heading without space\n");
1536 }
1537
1538 #[tokio::test]
1539 async fn test_get_end_position() {
1540 let server = create_test_server();
1541
1542 let pos = server.get_end_position("Hello");
1544 assert_eq!(pos.line, 0);
1545 assert_eq!(pos.character, 5);
1546
1547 let pos = server.get_end_position("Hello\nWorld\nTest");
1549 assert_eq!(pos.line, 2);
1550 assert_eq!(pos.character, 4);
1551
1552 let pos = server.get_end_position("");
1554 assert_eq!(pos.line, 0);
1555 assert_eq!(pos.character, 0);
1556
1557 let pos = server.get_end_position("Hello\n");
1559 assert_eq!(pos.line, 1);
1560 assert_eq!(pos.character, 0);
1561 }
1562
1563 #[tokio::test]
1564 async fn test_empty_document_handling() {
1565 let server = create_test_server();
1566
1567 let uri = Url::parse("file:///empty.md").unwrap();
1568 let text = "";
1569
1570 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1572 assert!(diagnostics.is_empty());
1573
1574 let range = Range {
1576 start: Position { line: 0, character: 0 },
1577 end: Position { line: 0, character: 0 },
1578 };
1579 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1580 assert!(actions.is_empty());
1581 }
1582
1583 #[tokio::test]
1584 async fn test_config_update() {
1585 let server = create_test_server();
1586
1587 {
1589 let mut config = server.config.write().await;
1590 config.enable_auto_fix = true;
1591 config.config_path = Some("/custom/path.toml".to_string());
1592 }
1593
1594 let config = server.config.read().await;
1596 assert!(config.enable_auto_fix);
1597 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1598 }
1599
1600 #[tokio::test]
1601 async fn test_document_formatting() {
1602 let server = create_test_server();
1603 let uri = Url::parse("file:///test.md").unwrap();
1604 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1605
1606 let entry = DocumentEntry {
1608 content: text.to_string(),
1609 version: Some(1),
1610 from_disk: false,
1611 };
1612 server.documents.write().await.insert(uri.clone(), entry);
1613
1614 let params = DocumentFormattingParams {
1616 text_document: TextDocumentIdentifier { uri: uri.clone() },
1617 options: FormattingOptions {
1618 tab_size: 4,
1619 insert_spaces: true,
1620 properties: HashMap::new(),
1621 trim_trailing_whitespace: Some(true),
1622 insert_final_newline: Some(true),
1623 trim_final_newlines: Some(true),
1624 },
1625 work_done_progress_params: WorkDoneProgressParams::default(),
1626 };
1627
1628 let result = server.formatting(params).await.unwrap();
1630
1631 assert!(result.is_some());
1633 let edits = result.unwrap();
1634 assert!(!edits.is_empty());
1635
1636 let edit = &edits[0];
1638 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
1641 assert_eq!(edit.new_text, expected);
1642 }
1643
1644 #[tokio::test]
1647 async fn test_unfixable_rules_excluded_from_formatting() {
1648 let server = create_test_server();
1649 let uri = Url::parse("file:///test.md").unwrap();
1650
1651 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
1653
1654 let entry = DocumentEntry {
1656 content: text.to_string(),
1657 version: Some(1),
1658 from_disk: false,
1659 };
1660 server.documents.write().await.insert(uri.clone(), entry);
1661
1662 let format_params = DocumentFormattingParams {
1664 text_document: TextDocumentIdentifier { uri: uri.clone() },
1665 options: FormattingOptions {
1666 tab_size: 4,
1667 insert_spaces: true,
1668 properties: HashMap::new(),
1669 trim_trailing_whitespace: Some(true),
1670 insert_final_newline: Some(true),
1671 trim_final_newlines: Some(true),
1672 },
1673 work_done_progress_params: WorkDoneProgressParams::default(),
1674 };
1675
1676 let format_result = server.formatting(format_params).await.unwrap();
1677 assert!(format_result.is_some(), "Should return formatting edits");
1678
1679 let edits = format_result.unwrap();
1680 assert!(!edits.is_empty(), "Should have formatting edits");
1681
1682 let formatted = &edits[0].new_text;
1683 assert!(
1684 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
1685 "HTML should be preserved during formatting (Unfixable rule)"
1686 );
1687 assert!(
1688 !formatted.contains("spaces "),
1689 "Trailing spaces should be removed (fixable rule)"
1690 );
1691
1692 let range = Range {
1694 start: Position { line: 0, character: 0 },
1695 end: Position { line: 10, character: 0 },
1696 };
1697
1698 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
1699
1700 let html_fix_actions: Vec<_> = code_actions
1702 .iter()
1703 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
1704 .collect();
1705
1706 assert!(
1707 !html_fix_actions.is_empty(),
1708 "Quick Fix actions should be available for HTML (Unfixable rules)"
1709 );
1710
1711 let fix_all_actions: Vec<_> = code_actions
1713 .iter()
1714 .filter(|action| action.title.contains("Fix all"))
1715 .collect();
1716
1717 if let Some(fix_all_action) = fix_all_actions.first()
1718 && let Some(ref edit) = fix_all_action.edit
1719 && let Some(ref changes) = edit.changes
1720 && let Some(text_edits) = changes.get(&uri)
1721 && let Some(text_edit) = text_edits.first()
1722 {
1723 let fixed_all = &text_edit.new_text;
1724 assert!(
1725 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
1726 "Fix All should preserve HTML (Unfixable rules)"
1727 );
1728 assert!(
1729 !fixed_all.contains("spaces "),
1730 "Fix All should remove trailing spaces (fixable rules)"
1731 );
1732 }
1733 }
1734
1735 #[tokio::test]
1737 async fn test_resolve_config_for_file_multi_root() {
1738 use std::fs;
1739 use tempfile::tempdir;
1740
1741 let temp_dir = tempdir().unwrap();
1742 let temp_path = temp_dir.path();
1743
1744 let project_a = temp_path.join("project_a");
1746 let project_a_docs = project_a.join("docs");
1747 fs::create_dir_all(&project_a_docs).unwrap();
1748
1749 let config_a = project_a.join(".rumdl.toml");
1750 fs::write(
1751 &config_a,
1752 r#"
1753[global]
1754
1755[MD013]
1756line_length = 60
1757"#,
1758 )
1759 .unwrap();
1760
1761 let project_b = temp_path.join("project_b");
1763 fs::create_dir(&project_b).unwrap();
1764
1765 let config_b = project_b.join(".rumdl.toml");
1766 fs::write(
1767 &config_b,
1768 r#"
1769[global]
1770
1771[MD013]
1772line_length = 120
1773"#,
1774 )
1775 .unwrap();
1776
1777 let server = create_test_server();
1779
1780 {
1782 let mut roots = server.workspace_roots.write().await;
1783 roots.push(project_a.clone());
1784 roots.push(project_b.clone());
1785 }
1786
1787 let file_a = project_a_docs.join("test.md");
1789 fs::write(&file_a, "# Test A\n").unwrap();
1790
1791 let config_for_a = server.resolve_config_for_file(&file_a).await;
1792 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1793 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1794
1795 let file_b = project_b.join("test.md");
1797 fs::write(&file_b, "# Test B\n").unwrap();
1798
1799 let config_for_b = server.resolve_config_for_file(&file_b).await;
1800 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1801 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1802 }
1803
1804 #[tokio::test]
1806 async fn test_config_resolution_respects_workspace_boundaries() {
1807 use std::fs;
1808 use tempfile::tempdir;
1809
1810 let temp_dir = tempdir().unwrap();
1811 let temp_path = temp_dir.path();
1812
1813 let parent_config = temp_path.join(".rumdl.toml");
1815 fs::write(
1816 &parent_config,
1817 r#"
1818[global]
1819
1820[MD013]
1821line_length = 80
1822"#,
1823 )
1824 .unwrap();
1825
1826 let workspace_root = temp_path.join("workspace");
1828 let workspace_subdir = workspace_root.join("subdir");
1829 fs::create_dir_all(&workspace_subdir).unwrap();
1830
1831 let workspace_config = workspace_root.join(".rumdl.toml");
1832 fs::write(
1833 &workspace_config,
1834 r#"
1835[global]
1836
1837[MD013]
1838line_length = 100
1839"#,
1840 )
1841 .unwrap();
1842
1843 let server = create_test_server();
1844
1845 {
1847 let mut roots = server.workspace_roots.write().await;
1848 roots.push(workspace_root.clone());
1849 }
1850
1851 let test_file = workspace_subdir.join("deep").join("test.md");
1853 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1854 fs::write(&test_file, "# Test\n").unwrap();
1855
1856 let config = server.resolve_config_for_file(&test_file).await;
1857 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1858
1859 assert_eq!(
1861 line_length,
1862 Some(100),
1863 "Should find workspace config, not parent config outside workspace"
1864 );
1865 }
1866
1867 #[tokio::test]
1869 async fn test_config_cache_hit() {
1870 use std::fs;
1871 use tempfile::tempdir;
1872
1873 let temp_dir = tempdir().unwrap();
1874 let temp_path = temp_dir.path();
1875
1876 let project = temp_path.join("project");
1877 fs::create_dir(&project).unwrap();
1878
1879 let config_file = project.join(".rumdl.toml");
1880 fs::write(
1881 &config_file,
1882 r#"
1883[global]
1884
1885[MD013]
1886line_length = 75
1887"#,
1888 )
1889 .unwrap();
1890
1891 let server = create_test_server();
1892 {
1893 let mut roots = server.workspace_roots.write().await;
1894 roots.push(project.clone());
1895 }
1896
1897 let test_file = project.join("test.md");
1898 fs::write(&test_file, "# Test\n").unwrap();
1899
1900 let config1 = server.resolve_config_for_file(&test_file).await;
1902 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1903 assert_eq!(line_length1, Some(75));
1904
1905 {
1907 let cache = server.config_cache.read().await;
1908 let search_dir = test_file.parent().unwrap();
1909 assert!(
1910 cache.contains_key(search_dir),
1911 "Cache should be populated after first call"
1912 );
1913 }
1914
1915 let config2 = server.resolve_config_for_file(&test_file).await;
1917 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1918 assert_eq!(line_length2, Some(75));
1919 }
1920
1921 #[tokio::test]
1923 async fn test_nested_directory_config_search() {
1924 use std::fs;
1925 use tempfile::tempdir;
1926
1927 let temp_dir = tempdir().unwrap();
1928 let temp_path = temp_dir.path();
1929
1930 let project = temp_path.join("project");
1931 fs::create_dir(&project).unwrap();
1932
1933 let config = project.join(".rumdl.toml");
1935 fs::write(
1936 &config,
1937 r#"
1938[global]
1939
1940[MD013]
1941line_length = 110
1942"#,
1943 )
1944 .unwrap();
1945
1946 let deep_dir = project.join("src").join("docs").join("guides");
1948 fs::create_dir_all(&deep_dir).unwrap();
1949 let deep_file = deep_dir.join("test.md");
1950 fs::write(&deep_file, "# Test\n").unwrap();
1951
1952 let server = create_test_server();
1953 {
1954 let mut roots = server.workspace_roots.write().await;
1955 roots.push(project.clone());
1956 }
1957
1958 let resolved_config = server.resolve_config_for_file(&deep_file).await;
1959 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1960
1961 assert_eq!(
1962 line_length,
1963 Some(110),
1964 "Should find config by searching upward from deep directory"
1965 );
1966 }
1967
1968 #[tokio::test]
1970 async fn test_fallback_to_default_config() {
1971 use std::fs;
1972 use tempfile::tempdir;
1973
1974 let temp_dir = tempdir().unwrap();
1975 let temp_path = temp_dir.path();
1976
1977 let project = temp_path.join("project");
1978 fs::create_dir(&project).unwrap();
1979
1980 let test_file = project.join("test.md");
1983 fs::write(&test_file, "# Test\n").unwrap();
1984
1985 let server = create_test_server();
1986 {
1987 let mut roots = server.workspace_roots.write().await;
1988 roots.push(project.clone());
1989 }
1990
1991 let config = server.resolve_config_for_file(&test_file).await;
1992
1993 assert_eq!(
1995 config.global.line_length, 80,
1996 "Should fall back to default config when no config file found"
1997 );
1998 }
1999
2000 #[tokio::test]
2002 async fn test_config_priority_closer_wins() {
2003 use std::fs;
2004 use tempfile::tempdir;
2005
2006 let temp_dir = tempdir().unwrap();
2007 let temp_path = temp_dir.path();
2008
2009 let project = temp_path.join("project");
2010 fs::create_dir(&project).unwrap();
2011
2012 let parent_config = project.join(".rumdl.toml");
2014 fs::write(
2015 &parent_config,
2016 r#"
2017[global]
2018
2019[MD013]
2020line_length = 100
2021"#,
2022 )
2023 .unwrap();
2024
2025 let subdir = project.join("subdir");
2027 fs::create_dir(&subdir).unwrap();
2028
2029 let subdir_config = subdir.join(".rumdl.toml");
2030 fs::write(
2031 &subdir_config,
2032 r#"
2033[global]
2034
2035[MD013]
2036line_length = 50
2037"#,
2038 )
2039 .unwrap();
2040
2041 let server = create_test_server();
2042 {
2043 let mut roots = server.workspace_roots.write().await;
2044 roots.push(project.clone());
2045 }
2046
2047 let test_file = subdir.join("test.md");
2049 fs::write(&test_file, "# Test\n").unwrap();
2050
2051 let config = server.resolve_config_for_file(&test_file).await;
2052 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2053
2054 assert_eq!(
2055 line_length,
2056 Some(50),
2057 "Closer config (subdir) should override parent config"
2058 );
2059 }
2060
2061 #[tokio::test]
2067 async fn test_issue_131_pyproject_without_rumdl_section() {
2068 use std::fs;
2069 use tempfile::tempdir;
2070
2071 let parent_dir = tempdir().unwrap();
2073
2074 let project_dir = parent_dir.path().join("project");
2076 fs::create_dir(&project_dir).unwrap();
2077
2078 fs::write(
2080 project_dir.join("pyproject.toml"),
2081 r#"
2082[project]
2083name = "test-project"
2084version = "0.1.0"
2085"#,
2086 )
2087 .unwrap();
2088
2089 fs::write(
2092 parent_dir.path().join(".rumdl.toml"),
2093 r#"
2094[global]
2095disable = ["MD013"]
2096"#,
2097 )
2098 .unwrap();
2099
2100 let test_file = project_dir.join("test.md");
2101 fs::write(&test_file, "# Test\n").unwrap();
2102
2103 let server = create_test_server();
2104
2105 {
2107 let mut roots = server.workspace_roots.write().await;
2108 roots.push(parent_dir.path().to_path_buf());
2109 }
2110
2111 let config = server.resolve_config_for_file(&test_file).await;
2113
2114 assert!(
2117 config.global.disable.contains(&"MD013".to_string()),
2118 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2119 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2120 );
2121
2122 let cache = server.config_cache.read().await;
2125 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2126
2127 assert!(
2128 cache_entry.config_file.is_some(),
2129 "Should have found a config file (parent .rumdl.toml)"
2130 );
2131
2132 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2133 assert!(
2134 found_config_path.ends_with(".rumdl.toml"),
2135 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2136 );
2137 assert!(
2138 found_config_path.parent().unwrap() == parent_dir.path(),
2139 "Should have loaded config from parent directory, not project_dir"
2140 );
2141 }
2142
2143 #[tokio::test]
2148 async fn test_issue_131_pyproject_with_rumdl_section() {
2149 use std::fs;
2150 use tempfile::tempdir;
2151
2152 let parent_dir = tempdir().unwrap();
2154
2155 let project_dir = parent_dir.path().join("project");
2157 fs::create_dir(&project_dir).unwrap();
2158
2159 fs::write(
2161 project_dir.join("pyproject.toml"),
2162 r#"
2163[project]
2164name = "test-project"
2165
2166[tool.rumdl.global]
2167disable = ["MD033"]
2168"#,
2169 )
2170 .unwrap();
2171
2172 fs::write(
2174 parent_dir.path().join(".rumdl.toml"),
2175 r#"
2176[global]
2177disable = ["MD041"]
2178"#,
2179 )
2180 .unwrap();
2181
2182 let test_file = project_dir.join("test.md");
2183 fs::write(&test_file, "# Test\n").unwrap();
2184
2185 let server = create_test_server();
2186
2187 {
2189 let mut roots = server.workspace_roots.write().await;
2190 roots.push(parent_dir.path().to_path_buf());
2191 }
2192
2193 let config = server.resolve_config_for_file(&test_file).await;
2195
2196 assert!(
2198 config.global.disable.contains(&"MD033".to_string()),
2199 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2200 Expected MD033 from project_dir pyproject.toml to be disabled."
2201 );
2202
2203 assert!(
2205 !config.global.disable.contains(&"MD041".to_string()),
2206 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2207 );
2208
2209 let cache = server.config_cache.read().await;
2211 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2212
2213 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2214
2215 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2216 assert!(
2217 found_config_path.ends_with("pyproject.toml"),
2218 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2219 );
2220 assert!(
2221 found_config_path.parent().unwrap() == project_dir,
2222 "Should have loaded pyproject.toml from project_dir, not parent"
2223 );
2224 }
2225
2226 #[tokio::test]
2231 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2232 use std::fs;
2233 use tempfile::tempdir;
2234
2235 let temp_dir = tempdir().unwrap();
2236
2237 fs::write(
2239 temp_dir.path().join("pyproject.toml"),
2240 r#"
2241[project]
2242name = "test-project"
2243
2244[tool.rumdl.global]
2245disable = ["MD022"]
2246"#,
2247 )
2248 .unwrap();
2249
2250 let test_file = temp_dir.path().join("test.md");
2251 fs::write(&test_file, "# Test\n").unwrap();
2252
2253 let server = create_test_server();
2254
2255 {
2257 let mut roots = server.workspace_roots.write().await;
2258 roots.push(temp_dir.path().to_path_buf());
2259 }
2260
2261 let config = server.resolve_config_for_file(&test_file).await;
2263
2264 assert!(
2266 config.global.disable.contains(&"MD022".to_string()),
2267 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2268 );
2269
2270 let cache = server.config_cache.read().await;
2272 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2273 assert!(
2274 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2275 "Should have loaded pyproject.toml"
2276 );
2277 }
2278
2279 #[tokio::test]
2284 async fn test_issue_182_pull_diagnostics_capability_default() {
2285 let server = create_test_server();
2286
2287 assert!(
2289 !*server.client_supports_pull_diagnostics.read().await,
2290 "Default should be false - push diagnostics by default"
2291 );
2292 }
2293
2294 #[tokio::test]
2296 async fn test_issue_182_pull_diagnostics_flag_update() {
2297 let server = create_test_server();
2298
2299 *server.client_supports_pull_diagnostics.write().await = true;
2301
2302 assert!(
2303 *server.client_supports_pull_diagnostics.read().await,
2304 "Flag should be settable to true"
2305 );
2306 }
2307
2308 #[tokio::test]
2312 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2313 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2314
2315 let caps_with_diagnostic = ClientCapabilities {
2317 text_document: Some(TextDocumentClientCapabilities {
2318 diagnostic: Some(DiagnosticClientCapabilities {
2319 dynamic_registration: Some(true),
2320 related_document_support: Some(false),
2321 }),
2322 ..Default::default()
2323 }),
2324 ..Default::default()
2325 };
2326
2327 let supports_pull = caps_with_diagnostic
2329 .text_document
2330 .as_ref()
2331 .and_then(|td| td.diagnostic.as_ref())
2332 .is_some();
2333
2334 assert!(supports_pull, "Should detect pull diagnostic support");
2335 }
2336
2337 #[tokio::test]
2339 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2340 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2341
2342 let caps_without_diagnostic = ClientCapabilities {
2344 text_document: Some(TextDocumentClientCapabilities {
2345 diagnostic: None, ..Default::default()
2347 }),
2348 ..Default::default()
2349 };
2350
2351 let supports_pull = caps_without_diagnostic
2353 .text_document
2354 .as_ref()
2355 .and_then(|td| td.diagnostic.as_ref())
2356 .is_some();
2357
2358 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2359 }
2360
2361 #[tokio::test]
2363 async fn test_issue_182_capability_detection_no_text_document() {
2364 use tower_lsp::lsp_types::ClientCapabilities;
2365
2366 let caps_no_text_doc = ClientCapabilities {
2368 text_document: None,
2369 ..Default::default()
2370 };
2371
2372 let supports_pull = caps_no_text_doc
2374 .text_document
2375 .as_ref()
2376 .and_then(|td| td.diagnostic.as_ref())
2377 .is_some();
2378
2379 assert!(
2380 !supports_pull,
2381 "Should NOT detect pull diagnostic support when text_document is None"
2382 );
2383 }
2384}