1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use anyhow::Result;
11use tokio::sync::RwLock;
12use tower_lsp::jsonrpc::Result as JsonRpcResult;
13use tower_lsp::lsp_types::*;
14use tower_lsp::{Client, LanguageServer};
15
16use crate::config::Config;
17use crate::lint;
18use crate::lsp::types::{RumdlLspConfig, warning_to_code_actions, warning_to_diagnostic};
19use crate::rule::Rule;
20use crate::rules;
21
22#[derive(Clone, Debug, PartialEq)]
24struct DocumentEntry {
25 content: String,
27 version: Option<i32>,
29 from_disk: bool,
31}
32
33#[derive(Clone, Debug)]
35pub(crate) struct ConfigCacheEntry {
36 pub(crate) config: Config,
38 pub(crate) config_file: Option<PathBuf>,
40}
41
42#[derive(Clone)]
51pub struct RumdlLanguageServer {
52 client: Client,
53 config: Arc<RwLock<RumdlLspConfig>>,
55 #[cfg_attr(test, allow(dead_code))]
57 pub(crate) rumdl_config: Arc<RwLock<Config>>,
58 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
60 #[cfg_attr(test, allow(dead_code))]
62 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
63 #[cfg_attr(test, allow(dead_code))]
66 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
67}
68
69impl RumdlLanguageServer {
70 pub fn new(client: Client) -> Self {
71 Self {
72 client,
73 config: Arc::new(RwLock::new(RumdlLspConfig::default())),
74 rumdl_config: Arc::new(RwLock::new(Config::default())),
75 documents: Arc::new(RwLock::new(HashMap::new())),
76 workspace_roots: Arc::new(RwLock::new(Vec::new())),
77 config_cache: Arc::new(RwLock::new(HashMap::new())),
78 }
79 }
80
81 async fn get_document_content(&self, uri: &Url) -> Option<String> {
87 {
89 let docs = self.documents.read().await;
90 if let Some(entry) = docs.get(uri) {
91 return Some(entry.content.clone());
92 }
93 }
94
95 if let Ok(path) = uri.to_file_path() {
97 if let Ok(content) = tokio::fs::read_to_string(&path).await {
98 let entry = DocumentEntry {
100 content: content.clone(),
101 version: None,
102 from_disk: true,
103 };
104
105 let mut docs = self.documents.write().await;
106 docs.insert(uri.clone(), entry);
107
108 log::debug!("Loaded document from disk and cached: {uri}");
109 return Some(content);
110 } else {
111 log::debug!("Failed to read file from disk: {uri}");
112 }
113 }
114
115 None
116 }
117
118 fn apply_lsp_config_overrides(
120 &self,
121 mut filtered_rules: Vec<Box<dyn Rule>>,
122 lsp_config: &RumdlLspConfig,
123 ) -> Vec<Box<dyn Rule>> {
124 if let Some(enable) = &lsp_config.enable_rules
126 && !enable.is_empty()
127 {
128 let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
129 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
130 }
131
132 if let Some(disable) = &lsp_config.disable_rules
134 && !disable.is_empty()
135 {
136 let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
137 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
138 }
139
140 filtered_rules
141 }
142
143 async fn should_exclude_uri(&self, uri: &Url) -> bool {
145 let file_path = match uri.to_file_path() {
147 Ok(path) => path,
148 Err(_) => return false, };
150
151 let rumdl_config = self.resolve_config_for_file(&file_path).await;
153 let exclude_patterns = &rumdl_config.global.exclude;
154
155 if exclude_patterns.is_empty() {
157 return false;
158 }
159
160 let path_to_check = if file_path.is_absolute() {
163 if let Ok(cwd) = std::env::current_dir() {
165 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
167 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
168 relative.to_string_lossy().to_string()
169 } else {
170 file_path.to_string_lossy().to_string()
172 }
173 } else {
174 file_path.to_string_lossy().to_string()
176 }
177 } else {
178 file_path.to_string_lossy().to_string()
179 }
180 } else {
181 file_path.to_string_lossy().to_string()
183 };
184
185 for pattern in exclude_patterns {
187 if let Ok(glob) = globset::Glob::new(pattern) {
188 let matcher = glob.compile_matcher();
189 if matcher.is_match(&path_to_check) {
190 log::debug!("Excluding file from LSP linting: {path_to_check}");
191 return true;
192 }
193 }
194 }
195
196 false
197 }
198
199 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
201 let config_guard = self.config.read().await;
202
203 if !config_guard.enable_linting {
205 return Ok(Vec::new());
206 }
207
208 let lsp_config = config_guard.clone();
209 drop(config_guard); if self.should_exclude_uri(uri).await {
213 return Ok(Vec::new());
214 }
215
216 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
218 self.resolve_config_for_file(&file_path).await
219 } else {
220 (*self.rumdl_config.read().await).clone()
222 };
223
224 let all_rules = rules::all_rules(&rumdl_config);
225 let flavor = rumdl_config.markdown_flavor();
226
227 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
229
230 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
232
233 match crate::lint(text, &filtered_rules, false, flavor) {
235 Ok(warnings) => {
236 let diagnostics = warnings.iter().map(warning_to_diagnostic).collect();
237 Ok(diagnostics)
238 }
239 Err(e) => {
240 log::error!("Failed to lint document {uri}: {e}");
241 Ok(Vec::new())
242 }
243 }
244 }
245
246 async fn update_diagnostics(&self, uri: Url, text: String) {
248 let version = {
250 let docs = self.documents.read().await;
251 docs.get(&uri).and_then(|entry| entry.version)
252 };
253
254 match self.lint_document(&uri, &text).await {
255 Ok(diagnostics) => {
256 self.client.publish_diagnostics(uri, diagnostics, version).await;
257 }
258 Err(e) => {
259 log::error!("Failed to update diagnostics: {e}");
260 }
261 }
262 }
263
264 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
266 if self.should_exclude_uri(uri).await {
268 return Ok(None);
269 }
270
271 let config_guard = self.config.read().await;
272 let lsp_config = config_guard.clone();
273 drop(config_guard);
274
275 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
277 self.resolve_config_for_file(&file_path).await
278 } else {
279 (*self.rumdl_config.read().await).clone()
281 };
282
283 let all_rules = rules::all_rules(&rumdl_config);
284 let flavor = rumdl_config.markdown_flavor();
285
286 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
288
289 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
291
292 let mut rules_with_warnings = std::collections::HashSet::new();
295 let mut fixed_text = text.to_string();
296
297 match lint(&fixed_text, &filtered_rules, false, flavor) {
298 Ok(warnings) => {
299 for warning in warnings {
300 if let Some(rule_name) = &warning.rule_name {
301 rules_with_warnings.insert(rule_name.clone());
302 }
303 }
304 }
305 Err(e) => {
306 log::warn!("Failed to lint document for auto-fix: {e}");
307 return Ok(None);
308 }
309 }
310
311 if rules_with_warnings.is_empty() {
313 return Ok(None);
314 }
315
316 let mut any_changes = false;
318
319 for rule in &filtered_rules {
320 if !rules_with_warnings.contains(rule.name()) {
322 continue;
323 }
324
325 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
326 match rule.fix(&ctx) {
327 Ok(new_text) => {
328 if new_text != fixed_text {
329 fixed_text = new_text;
330 any_changes = true;
331 }
332 }
333 Err(e) => {
334 let msg = e.to_string();
336 if !msg.contains("does not support automatic fixing") {
337 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
338 }
339 }
340 }
341 }
342
343 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
344 }
345
346 fn get_end_position(&self, text: &str) -> Position {
348 let mut line = 0u32;
349 let mut character = 0u32;
350
351 for ch in text.chars() {
352 if ch == '\n' {
353 line += 1;
354 character = 0;
355 } else {
356 character += 1;
357 }
358 }
359
360 Position { line, character }
361 }
362
363 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
365 let config_guard = self.config.read().await;
366 let lsp_config = config_guard.clone();
367 drop(config_guard);
368
369 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
371 self.resolve_config_for_file(&file_path).await
372 } else {
373 (*self.rumdl_config.read().await).clone()
375 };
376
377 let all_rules = rules::all_rules(&rumdl_config);
378 let flavor = rumdl_config.markdown_flavor();
379
380 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
382
383 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
385
386 match crate::lint(text, &filtered_rules, false, flavor) {
387 Ok(warnings) => {
388 let mut actions = Vec::new();
389 let mut fixable_count = 0;
390
391 for warning in &warnings {
392 let warning_line = (warning.line.saturating_sub(1)) as u32;
394 if warning_line >= range.start.line && warning_line <= range.end.line {
395 let mut warning_actions = warning_to_code_actions(warning, uri, text);
397 actions.append(&mut warning_actions);
398
399 if warning.fix.is_some() {
400 fixable_count += 1;
401 }
402 }
403 }
404
405 if fixable_count > 1 {
407 let total_fixable = warnings.iter().filter(|w| w.fix.is_some()).count();
409
410 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &warnings)
411 && fixed_content != text
412 {
413 let mut line = 0u32;
415 let mut character = 0u32;
416 for ch in text.chars() {
417 if ch == '\n' {
418 line += 1;
419 character = 0;
420 } else {
421 character += 1;
422 }
423 }
424
425 let fix_all_action = CodeAction {
426 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
427 kind: Some(CodeActionKind::QUICKFIX),
428 diagnostics: Some(Vec::new()),
429 edit: Some(WorkspaceEdit {
430 changes: Some(
431 [(
432 uri.clone(),
433 vec![TextEdit {
434 range: Range {
435 start: Position { line: 0, character: 0 },
436 end: Position { line, character },
437 },
438 new_text: fixed_content,
439 }],
440 )]
441 .into_iter()
442 .collect(),
443 ),
444 ..Default::default()
445 }),
446 command: None,
447 is_preferred: Some(true),
448 disabled: None,
449 data: None,
450 };
451
452 actions.insert(0, fix_all_action);
454 }
455 }
456
457 Ok(actions)
458 }
459 Err(e) => {
460 log::error!("Failed to get code actions: {e}");
461 Ok(Vec::new())
462 }
463 }
464 }
465
466 async fn load_configuration(&self, notify_client: bool) {
468 let config_guard = self.config.read().await;
469 let explicit_config_path = config_guard.config_path.clone();
470 drop(config_guard);
471
472 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
474 Ok(sourced_config) => {
475 let loaded_files = sourced_config.loaded_files.clone();
476 *self.rumdl_config.write().await = sourced_config.into();
477
478 if !loaded_files.is_empty() {
479 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
480 log::info!("{message}");
481 if notify_client {
482 self.client.log_message(MessageType::INFO, &message).await;
483 }
484 } else {
485 log::info!("Using default rumdl configuration (no config files found)");
486 }
487 }
488 Err(e) => {
489 let message = format!("Failed to load rumdl config: {e}");
490 log::warn!("{message}");
491 if notify_client {
492 self.client.log_message(MessageType::WARNING, &message).await;
493 }
494 *self.rumdl_config.write().await = crate::config::Config::default();
496 }
497 }
498 }
499
500 async fn reload_configuration(&self) {
502 self.load_configuration(true).await;
503 }
504
505 fn load_config_for_lsp(
507 config_path: Option<&str>,
508 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
509 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
511 }
512
513 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
520 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
522
523 {
525 let cache = self.config_cache.read().await;
526 if let Some(entry) = cache.get(&search_dir) {
527 log::debug!(
528 "Config cache hit for directory: {} (loaded from: {:?})",
529 search_dir.display(),
530 entry.config_file
531 );
532 return entry.config.clone();
533 }
534 }
535
536 log::debug!(
538 "Config cache miss for directory: {}, searching for config...",
539 search_dir.display()
540 );
541
542 let workspace_root = {
544 let workspace_roots = self.workspace_roots.read().await;
545 workspace_roots
546 .iter()
547 .find(|root| search_dir.starts_with(root))
548 .map(|p| p.to_path_buf())
549 };
550
551 let mut current_dir = search_dir.clone();
553 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
554
555 loop {
556 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
558
559 for config_file_name in CONFIG_FILES {
560 let config_path = current_dir.join(config_file_name);
561 if config_path.exists() {
562 log::debug!("Found config file: {}", config_path.display());
563
564 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path.to_str().unwrap())) {
566 found_config = Some((sourced.into(), Some(config_path)));
567 break;
568 }
569 }
570 }
571
572 if found_config.is_some() {
573 break;
574 }
575
576 if let Some(ref root) = workspace_root
578 && ¤t_dir == root
579 {
580 log::debug!("Hit workspace root without finding config: {}", root.display());
581 break;
582 }
583
584 if let Some(parent) = current_dir.parent() {
586 current_dir = parent.to_path_buf();
587 } else {
588 break;
590 }
591 }
592
593 let (config, config_file) = found_config.unwrap_or_else(|| {
595 log::debug!("No config found, using default config");
596 (Config::default(), None)
597 });
598
599 let entry = ConfigCacheEntry {
601 config: config.clone(),
602 config_file,
603 };
604
605 self.config_cache.write().await.insert(search_dir, entry);
606
607 config
608 }
609}
610
611#[tower_lsp::async_trait]
612impl LanguageServer for RumdlLanguageServer {
613 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
614 log::info!("Initializing rumdl Language Server");
615
616 if let Some(options) = params.initialization_options
618 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
619 {
620 *self.config.write().await = config;
621 }
622
623 let mut roots = Vec::new();
625 if let Some(workspace_folders) = params.workspace_folders {
626 for folder in workspace_folders {
627 if let Ok(path) = folder.uri.to_file_path() {
628 log::info!("Workspace root: {}", path.display());
629 roots.push(path);
630 }
631 }
632 } else if let Some(root_uri) = params.root_uri
633 && let Ok(path) = root_uri.to_file_path()
634 {
635 log::info!("Workspace root: {}", path.display());
636 roots.push(path);
637 }
638 *self.workspace_roots.write().await = roots;
639
640 self.load_configuration(false).await;
642
643 Ok(InitializeResult {
644 capabilities: ServerCapabilities {
645 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
646 open_close: Some(true),
647 change: Some(TextDocumentSyncKind::FULL),
648 will_save: Some(false),
649 will_save_wait_until: Some(true),
650 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
651 include_text: Some(false),
652 })),
653 })),
654 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
655 document_formatting_provider: Some(OneOf::Left(true)),
656 document_range_formatting_provider: Some(OneOf::Left(true)),
657 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
658 identifier: Some("rumdl".to_string()),
659 inter_file_dependencies: false,
660 workspace_diagnostics: false,
661 work_done_progress_options: WorkDoneProgressOptions::default(),
662 })),
663 workspace: Some(WorkspaceServerCapabilities {
664 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
665 supported: Some(true),
666 change_notifications: Some(OneOf::Left(true)),
667 }),
668 file_operations: None,
669 }),
670 ..Default::default()
671 },
672 server_info: Some(ServerInfo {
673 name: "rumdl".to_string(),
674 version: Some(env!("CARGO_PKG_VERSION").to_string()),
675 }),
676 })
677 }
678
679 async fn initialized(&self, _: InitializedParams) {
680 let version = env!("CARGO_PKG_VERSION");
681
682 let (binary_path, build_time) = std::env::current_exe()
684 .ok()
685 .map(|path| {
686 let path_str = path.to_str().unwrap_or("unknown").to_string();
687 let build_time = std::fs::metadata(&path)
688 .ok()
689 .and_then(|metadata| metadata.modified().ok())
690 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
691 .and_then(|duration| {
692 let secs = duration.as_secs();
693 chrono::DateTime::from_timestamp(secs as i64, 0)
694 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
695 })
696 .unwrap_or_else(|| "unknown".to_string());
697 (path_str, build_time)
698 })
699 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
700
701 let working_dir = std::env::current_dir()
702 .ok()
703 .and_then(|p| p.to_str().map(|s| s.to_string()))
704 .unwrap_or_else(|| "unknown".to_string());
705
706 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
707 log::info!("Working directory: {working_dir}");
708
709 self.client
710 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
711 .await;
712 }
713
714 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
715 let mut roots = self.workspace_roots.write().await;
717
718 for removed in ¶ms.event.removed {
720 if let Ok(path) = removed.uri.to_file_path() {
721 roots.retain(|r| r != &path);
722 log::info!("Removed workspace root: {}", path.display());
723 }
724 }
725
726 for added in ¶ms.event.added {
728 if let Ok(path) = added.uri.to_file_path()
729 && !roots.contains(&path)
730 {
731 log::info!("Added workspace root: {}", path.display());
732 roots.push(path);
733 }
734 }
735 drop(roots);
736
737 self.config_cache.write().await.clear();
739
740 self.reload_configuration().await;
742 }
743
744 async fn shutdown(&self) -> JsonRpcResult<()> {
745 log::info!("Shutting down rumdl Language Server");
746 Ok(())
747 }
748
749 async fn did_open(&self, params: DidOpenTextDocumentParams) {
750 let uri = params.text_document.uri;
751 let text = params.text_document.text;
752 let version = params.text_document.version;
753
754 let entry = DocumentEntry {
755 content: text.clone(),
756 version: Some(version),
757 from_disk: false,
758 };
759 self.documents.write().await.insert(uri.clone(), entry);
760
761 self.update_diagnostics(uri, text).await;
762 }
763
764 async fn did_change(&self, params: DidChangeTextDocumentParams) {
765 let uri = params.text_document.uri;
766 let version = params.text_document.version;
767
768 if let Some(change) = params.content_changes.into_iter().next() {
769 let text = change.text;
770
771 let entry = DocumentEntry {
772 content: text.clone(),
773 version: Some(version),
774 from_disk: false,
775 };
776 self.documents.write().await.insert(uri.clone(), entry);
777
778 self.update_diagnostics(uri, text).await;
779 }
780 }
781
782 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
783 let config_guard = self.config.read().await;
784 let enable_auto_fix = config_guard.enable_auto_fix;
785 drop(config_guard);
786
787 if !enable_auto_fix {
788 return Ok(None);
789 }
790
791 let text = match self.get_document_content(¶ms.text_document.uri).await {
793 Some(content) => content,
794 None => return Ok(None),
795 };
796
797 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
799 Ok(Some(fixed_text)) => {
800 Ok(Some(vec![TextEdit {
802 range: Range {
803 start: Position { line: 0, character: 0 },
804 end: self.get_end_position(&text),
805 },
806 new_text: fixed_text,
807 }]))
808 }
809 Ok(None) => Ok(None),
810 Err(e) => {
811 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
812 Ok(None)
813 }
814 }
815 }
816
817 async fn did_save(&self, params: DidSaveTextDocumentParams) {
818 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
821 self.update_diagnostics(params.text_document.uri, entry.content.clone())
822 .await;
823 }
824 }
825
826 async fn did_close(&self, params: DidCloseTextDocumentParams) {
827 self.documents.write().await.remove(¶ms.text_document.uri);
829
830 self.client
832 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
833 .await;
834 }
835
836 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
837 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
839
840 for change in ¶ms.changes {
841 if let Ok(path) = change.uri.to_file_path()
842 && let Some(file_name) = path.file_name().and_then(|f| f.to_str())
843 && CONFIG_FILES.contains(&file_name)
844 {
845 log::info!("Config file changed: {}, invalidating config cache", path.display());
846
847 let mut cache = self.config_cache.write().await;
849 cache.retain(|_, entry| {
850 if let Some(config_file) = &entry.config_file {
851 config_file != &path
852 } else {
853 true
854 }
855 });
856
857 drop(cache);
859 self.reload_configuration().await;
860
861 let docs_to_update: Vec<(Url, String)> = {
864 let docs = self.documents.read().await;
865 docs.iter()
866 .filter(|(_, entry)| !entry.from_disk)
867 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
868 .collect()
869 };
870
871 for (uri, text) in docs_to_update {
873 self.update_diagnostics(uri, text).await;
874 }
875
876 break;
877 }
878 }
879 }
880
881 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
882 let uri = params.text_document.uri;
883 let range = params.range;
884
885 if let Some(text) = self.get_document_content(&uri).await {
886 match self.get_code_actions(&uri, &text, range).await {
887 Ok(actions) => {
888 let response: Vec<CodeActionOrCommand> =
889 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
890 Ok(Some(response))
891 }
892 Err(e) => {
893 log::error!("Failed to get code actions: {e}");
894 Ok(None)
895 }
896 }
897 } else {
898 Ok(None)
899 }
900 }
901
902 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
903 log::debug!(
908 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
909 params.range
910 );
911
912 let formatting_params = DocumentFormattingParams {
913 text_document: params.text_document,
914 options: params.options,
915 work_done_progress_params: params.work_done_progress_params,
916 };
917
918 self.formatting(formatting_params).await
919 }
920
921 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
922 let uri = params.text_document.uri;
923
924 log::debug!("Formatting request for: {uri}");
925
926 if let Some(text) = self.get_document_content(&uri).await {
927 let config_guard = self.config.read().await;
929 let lsp_config = config_guard.clone();
930 drop(config_guard);
931
932 let rumdl_config = if let Ok(file_path) = uri.to_file_path() {
934 self.resolve_config_for_file(&file_path).await
935 } else {
936 self.rumdl_config.read().await.clone()
938 };
939
940 let all_rules = rules::all_rules(&rumdl_config);
941 let flavor = rumdl_config.markdown_flavor();
942
943 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
945
946 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
948
949 match crate::lint(&text, &filtered_rules, false, flavor) {
951 Ok(warnings) => {
952 log::debug!(
953 "Found {} warnings, {} with fixes",
954 warnings.len(),
955 warnings.iter().filter(|w| w.fix.is_some()).count()
956 );
957
958 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
959 if has_fixes {
960 match crate::utils::fix_utils::apply_warning_fixes(&text, &warnings) {
961 Ok(fixed_content) => {
962 if fixed_content != text {
963 log::debug!("Returning formatting edits");
964 let end_position = self.get_end_position(&text);
965 let edit = TextEdit {
966 range: Range {
967 start: Position { line: 0, character: 0 },
968 end: end_position,
969 },
970 new_text: fixed_content,
971 };
972 return Ok(Some(vec![edit]));
973 }
974 }
975 Err(e) => {
976 log::error!("Failed to apply fixes: {e}");
977 }
978 }
979 }
980 Ok(Some(Vec::new()))
981 }
982 Err(e) => {
983 log::error!("Failed to format document: {e}");
984 Ok(Some(Vec::new()))
985 }
986 }
987 } else {
988 log::warn!("Document not found: {uri}");
989 Ok(None)
990 }
991 }
992
993 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
994 let uri = params.text_document.uri;
995
996 if let Some(text) = self.get_document_content(&uri).await {
997 match self.lint_document(&uri, &text).await {
998 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
999 RelatedFullDocumentDiagnosticReport {
1000 related_documents: None,
1001 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1002 result_id: None,
1003 items: diagnostics,
1004 },
1005 },
1006 ))),
1007 Err(e) => {
1008 log::error!("Failed to get diagnostics: {e}");
1009 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1010 RelatedFullDocumentDiagnosticReport {
1011 related_documents: None,
1012 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1013 result_id: None,
1014 items: Vec::new(),
1015 },
1016 },
1017 )))
1018 }
1019 }
1020 } else {
1021 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1022 RelatedFullDocumentDiagnosticReport {
1023 related_documents: None,
1024 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1025 result_id: None,
1026 items: Vec::new(),
1027 },
1028 },
1029 )))
1030 }
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use crate::rule::LintWarning;
1038 use tower_lsp::LspService;
1039
1040 fn create_test_server() -> RumdlLanguageServer {
1041 let (service, _socket) = LspService::new(RumdlLanguageServer::new);
1042 service.inner().clone()
1043 }
1044
1045 #[tokio::test]
1046 async fn test_server_creation() {
1047 let server = create_test_server();
1048
1049 let config = server.config.read().await;
1051 assert!(config.enable_linting);
1052 assert!(!config.enable_auto_fix);
1053 }
1054
1055 #[tokio::test]
1056 async fn test_lint_document() {
1057 let server = create_test_server();
1058
1059 let uri = Url::parse("file:///test.md").unwrap();
1061 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1062
1063 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1064
1065 assert!(!diagnostics.is_empty());
1067 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1068 }
1069
1070 #[tokio::test]
1071 async fn test_lint_document_disabled() {
1072 let server = create_test_server();
1073
1074 server.config.write().await.enable_linting = false;
1076
1077 let uri = Url::parse("file:///test.md").unwrap();
1078 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1079
1080 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1081
1082 assert!(diagnostics.is_empty());
1084 }
1085
1086 #[tokio::test]
1087 async fn test_get_code_actions() {
1088 let server = create_test_server();
1089
1090 let uri = Url::parse("file:///test.md").unwrap();
1091 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1092
1093 let range = Range {
1095 start: Position { line: 0, character: 0 },
1096 end: Position { line: 3, character: 21 },
1097 };
1098
1099 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1100
1101 assert!(!actions.is_empty());
1103 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1104 }
1105
1106 #[tokio::test]
1107 async fn test_get_code_actions_outside_range() {
1108 let server = create_test_server();
1109
1110 let uri = Url::parse("file:///test.md").unwrap();
1111 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1112
1113 let range = Range {
1115 start: Position { line: 0, character: 0 },
1116 end: Position { line: 0, character: 6 },
1117 };
1118
1119 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1120
1121 assert!(actions.is_empty());
1123 }
1124
1125 #[tokio::test]
1126 async fn test_document_storage() {
1127 let server = create_test_server();
1128
1129 let uri = Url::parse("file:///test.md").unwrap();
1130 let text = "# Test Document";
1131
1132 let entry = DocumentEntry {
1134 content: text.to_string(),
1135 version: Some(1),
1136 from_disk: false,
1137 };
1138 server.documents.write().await.insert(uri.clone(), entry);
1139
1140 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1142 assert_eq!(stored, Some(text.to_string()));
1143
1144 server.documents.write().await.remove(&uri);
1146
1147 let stored = server.documents.read().await.get(&uri).cloned();
1149 assert_eq!(stored, None);
1150 }
1151
1152 #[tokio::test]
1153 async fn test_configuration_loading() {
1154 let server = create_test_server();
1155
1156 server.load_configuration(false).await;
1158
1159 let rumdl_config = server.rumdl_config.read().await;
1162 drop(rumdl_config); }
1165
1166 #[tokio::test]
1167 async fn test_load_config_for_lsp() {
1168 let result = RumdlLanguageServer::load_config_for_lsp(None);
1170 assert!(result.is_ok());
1171
1172 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1174 assert!(result.is_err());
1175 }
1176
1177 #[tokio::test]
1178 async fn test_warning_conversion() {
1179 let warning = LintWarning {
1180 message: "Test warning".to_string(),
1181 line: 1,
1182 column: 1,
1183 end_line: 1,
1184 end_column: 10,
1185 severity: crate::rule::Severity::Warning,
1186 fix: None,
1187 rule_name: Some("MD001".to_string()),
1188 };
1189
1190 let diagnostic = warning_to_diagnostic(&warning);
1192 assert_eq!(diagnostic.message, "Test warning");
1193 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1194 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1195
1196 let uri = Url::parse("file:///test.md").unwrap();
1198 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1199 assert_eq!(actions.len(), 1);
1201 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1202 }
1203
1204 #[tokio::test]
1205 async fn test_multiple_documents() {
1206 let server = create_test_server();
1207
1208 let uri1 = Url::parse("file:///test1.md").unwrap();
1209 let uri2 = Url::parse("file:///test2.md").unwrap();
1210 let text1 = "# Document 1";
1211 let text2 = "# Document 2";
1212
1213 {
1215 let mut docs = server.documents.write().await;
1216 let entry1 = DocumentEntry {
1217 content: text1.to_string(),
1218 version: Some(1),
1219 from_disk: false,
1220 };
1221 let entry2 = DocumentEntry {
1222 content: text2.to_string(),
1223 version: Some(1),
1224 from_disk: false,
1225 };
1226 docs.insert(uri1.clone(), entry1);
1227 docs.insert(uri2.clone(), entry2);
1228 }
1229
1230 let docs = server.documents.read().await;
1232 assert_eq!(docs.len(), 2);
1233 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
1234 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
1235 }
1236
1237 #[tokio::test]
1238 async fn test_auto_fix_on_save() {
1239 let server = create_test_server();
1240
1241 {
1243 let mut config = server.config.write().await;
1244 config.enable_auto_fix = true;
1245 }
1246
1247 let uri = Url::parse("file:///test.md").unwrap();
1248 let text = "#Heading without space"; let entry = DocumentEntry {
1252 content: text.to_string(),
1253 version: Some(1),
1254 from_disk: false,
1255 };
1256 server.documents.write().await.insert(uri.clone(), entry);
1257
1258 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
1260 assert!(fixed.is_some());
1261 assert_eq!(fixed.unwrap(), "# Heading without space\n");
1263 }
1264
1265 #[tokio::test]
1266 async fn test_get_end_position() {
1267 let server = create_test_server();
1268
1269 let pos = server.get_end_position("Hello");
1271 assert_eq!(pos.line, 0);
1272 assert_eq!(pos.character, 5);
1273
1274 let pos = server.get_end_position("Hello\nWorld\nTest");
1276 assert_eq!(pos.line, 2);
1277 assert_eq!(pos.character, 4);
1278
1279 let pos = server.get_end_position("");
1281 assert_eq!(pos.line, 0);
1282 assert_eq!(pos.character, 0);
1283
1284 let pos = server.get_end_position("Hello\n");
1286 assert_eq!(pos.line, 1);
1287 assert_eq!(pos.character, 0);
1288 }
1289
1290 #[tokio::test]
1291 async fn test_empty_document_handling() {
1292 let server = create_test_server();
1293
1294 let uri = Url::parse("file:///empty.md").unwrap();
1295 let text = "";
1296
1297 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1299 assert!(diagnostics.is_empty());
1300
1301 let range = Range {
1303 start: Position { line: 0, character: 0 },
1304 end: Position { line: 0, character: 0 },
1305 };
1306 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1307 assert!(actions.is_empty());
1308 }
1309
1310 #[tokio::test]
1311 async fn test_config_update() {
1312 let server = create_test_server();
1313
1314 {
1316 let mut config = server.config.write().await;
1317 config.enable_auto_fix = true;
1318 config.config_path = Some("/custom/path.toml".to_string());
1319 }
1320
1321 let config = server.config.read().await;
1323 assert!(config.enable_auto_fix);
1324 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
1325 }
1326
1327 #[tokio::test]
1328 async fn test_document_formatting() {
1329 let server = create_test_server();
1330 let uri = Url::parse("file:///test.md").unwrap();
1331 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1332
1333 let entry = DocumentEntry {
1335 content: text.to_string(),
1336 version: Some(1),
1337 from_disk: false,
1338 };
1339 server.documents.write().await.insert(uri.clone(), entry);
1340
1341 let params = DocumentFormattingParams {
1343 text_document: TextDocumentIdentifier { uri: uri.clone() },
1344 options: FormattingOptions {
1345 tab_size: 4,
1346 insert_spaces: true,
1347 properties: HashMap::new(),
1348 trim_trailing_whitespace: Some(true),
1349 insert_final_newline: Some(true),
1350 trim_final_newlines: Some(true),
1351 },
1352 work_done_progress_params: WorkDoneProgressParams::default(),
1353 };
1354
1355 let result = server.formatting(params).await.unwrap();
1357
1358 assert!(result.is_some());
1360 let edits = result.unwrap();
1361 assert!(!edits.is_empty());
1362
1363 let edit = &edits[0];
1365 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
1368 assert_eq!(edit.new_text, expected);
1369 }
1370
1371 #[tokio::test]
1373 async fn test_resolve_config_for_file_multi_root() {
1374 use std::fs;
1375 use tempfile::tempdir;
1376
1377 let temp_dir = tempdir().unwrap();
1378 let temp_path = temp_dir.path();
1379
1380 let project_a = temp_path.join("project_a");
1382 let project_a_docs = project_a.join("docs");
1383 fs::create_dir_all(&project_a_docs).unwrap();
1384
1385 let config_a = project_a.join(".rumdl.toml");
1386 fs::write(
1387 &config_a,
1388 r#"
1389[global]
1390
1391[MD013]
1392line_length = 60
1393"#,
1394 )
1395 .unwrap();
1396
1397 let project_b = temp_path.join("project_b");
1399 fs::create_dir(&project_b).unwrap();
1400
1401 let config_b = project_b.join(".rumdl.toml");
1402 fs::write(
1403 &config_b,
1404 r#"
1405[global]
1406
1407[MD013]
1408line_length = 120
1409"#,
1410 )
1411 .unwrap();
1412
1413 let server = create_test_server();
1415
1416 {
1418 let mut roots = server.workspace_roots.write().await;
1419 roots.push(project_a.clone());
1420 roots.push(project_b.clone());
1421 }
1422
1423 let file_a = project_a_docs.join("test.md");
1425 fs::write(&file_a, "# Test A\n").unwrap();
1426
1427 let config_for_a = server.resolve_config_for_file(&file_a).await;
1428 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
1429 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
1430
1431 let file_b = project_b.join("test.md");
1433 fs::write(&file_b, "# Test B\n").unwrap();
1434
1435 let config_for_b = server.resolve_config_for_file(&file_b).await;
1436 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
1437 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
1438 }
1439
1440 #[tokio::test]
1442 async fn test_config_resolution_respects_workspace_boundaries() {
1443 use std::fs;
1444 use tempfile::tempdir;
1445
1446 let temp_dir = tempdir().unwrap();
1447 let temp_path = temp_dir.path();
1448
1449 let parent_config = temp_path.join(".rumdl.toml");
1451 fs::write(
1452 &parent_config,
1453 r#"
1454[global]
1455
1456[MD013]
1457line_length = 80
1458"#,
1459 )
1460 .unwrap();
1461
1462 let workspace_root = temp_path.join("workspace");
1464 let workspace_subdir = workspace_root.join("subdir");
1465 fs::create_dir_all(&workspace_subdir).unwrap();
1466
1467 let workspace_config = workspace_root.join(".rumdl.toml");
1468 fs::write(
1469 &workspace_config,
1470 r#"
1471[global]
1472
1473[MD013]
1474line_length = 100
1475"#,
1476 )
1477 .unwrap();
1478
1479 let server = create_test_server();
1480
1481 {
1483 let mut roots = server.workspace_roots.write().await;
1484 roots.push(workspace_root.clone());
1485 }
1486
1487 let test_file = workspace_subdir.join("deep").join("test.md");
1489 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
1490 fs::write(&test_file, "# Test\n").unwrap();
1491
1492 let config = server.resolve_config_for_file(&test_file).await;
1493 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1494
1495 assert_eq!(
1497 line_length,
1498 Some(100),
1499 "Should find workspace config, not parent config outside workspace"
1500 );
1501 }
1502
1503 #[tokio::test]
1505 async fn test_config_cache_hit() {
1506 use std::fs;
1507 use tempfile::tempdir;
1508
1509 let temp_dir = tempdir().unwrap();
1510 let temp_path = temp_dir.path();
1511
1512 let project = temp_path.join("project");
1513 fs::create_dir(&project).unwrap();
1514
1515 let config_file = project.join(".rumdl.toml");
1516 fs::write(
1517 &config_file,
1518 r#"
1519[global]
1520
1521[MD013]
1522line_length = 75
1523"#,
1524 )
1525 .unwrap();
1526
1527 let server = create_test_server();
1528 {
1529 let mut roots = server.workspace_roots.write().await;
1530 roots.push(project.clone());
1531 }
1532
1533 let test_file = project.join("test.md");
1534 fs::write(&test_file, "# Test\n").unwrap();
1535
1536 let config1 = server.resolve_config_for_file(&test_file).await;
1538 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
1539 assert_eq!(line_length1, Some(75));
1540
1541 {
1543 let cache = server.config_cache.read().await;
1544 let search_dir = test_file.parent().unwrap();
1545 assert!(
1546 cache.contains_key(search_dir),
1547 "Cache should be populated after first call"
1548 );
1549 }
1550
1551 let config2 = server.resolve_config_for_file(&test_file).await;
1553 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
1554 assert_eq!(line_length2, Some(75));
1555 }
1556
1557 #[tokio::test]
1559 async fn test_nested_directory_config_search() {
1560 use std::fs;
1561 use tempfile::tempdir;
1562
1563 let temp_dir = tempdir().unwrap();
1564 let temp_path = temp_dir.path();
1565
1566 let project = temp_path.join("project");
1567 fs::create_dir(&project).unwrap();
1568
1569 let config = project.join(".rumdl.toml");
1571 fs::write(
1572 &config,
1573 r#"
1574[global]
1575
1576[MD013]
1577line_length = 110
1578"#,
1579 )
1580 .unwrap();
1581
1582 let deep_dir = project.join("src").join("docs").join("guides");
1584 fs::create_dir_all(&deep_dir).unwrap();
1585 let deep_file = deep_dir.join("test.md");
1586 fs::write(&deep_file, "# Test\n").unwrap();
1587
1588 let server = create_test_server();
1589 {
1590 let mut roots = server.workspace_roots.write().await;
1591 roots.push(project.clone());
1592 }
1593
1594 let resolved_config = server.resolve_config_for_file(&deep_file).await;
1595 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
1596
1597 assert_eq!(
1598 line_length,
1599 Some(110),
1600 "Should find config by searching upward from deep directory"
1601 );
1602 }
1603
1604 #[tokio::test]
1606 async fn test_fallback_to_default_config() {
1607 use std::fs;
1608 use tempfile::tempdir;
1609
1610 let temp_dir = tempdir().unwrap();
1611 let temp_path = temp_dir.path();
1612
1613 let project = temp_path.join("project");
1614 fs::create_dir(&project).unwrap();
1615
1616 let test_file = project.join("test.md");
1619 fs::write(&test_file, "# Test\n").unwrap();
1620
1621 let server = create_test_server();
1622 {
1623 let mut roots = server.workspace_roots.write().await;
1624 roots.push(project.clone());
1625 }
1626
1627 let config = server.resolve_config_for_file(&test_file).await;
1628
1629 assert_eq!(
1631 config.global.line_length, 80,
1632 "Should fall back to default config when no config file found"
1633 );
1634 }
1635
1636 #[tokio::test]
1638 async fn test_config_priority_closer_wins() {
1639 use std::fs;
1640 use tempfile::tempdir;
1641
1642 let temp_dir = tempdir().unwrap();
1643 let temp_path = temp_dir.path();
1644
1645 let project = temp_path.join("project");
1646 fs::create_dir(&project).unwrap();
1647
1648 let parent_config = project.join(".rumdl.toml");
1650 fs::write(
1651 &parent_config,
1652 r#"
1653[global]
1654
1655[MD013]
1656line_length = 100
1657"#,
1658 )
1659 .unwrap();
1660
1661 let subdir = project.join("subdir");
1663 fs::create_dir(&subdir).unwrap();
1664
1665 let subdir_config = subdir.join(".rumdl.toml");
1666 fs::write(
1667 &subdir_config,
1668 r#"
1669[global]
1670
1671[MD013]
1672line_length = 50
1673"#,
1674 )
1675 .unwrap();
1676
1677 let server = create_test_server();
1678 {
1679 let mut roots = server.workspace_roots.write().await;
1680 roots.push(project.clone());
1681 }
1682
1683 let test_file = subdir.join("test.md");
1685 fs::write(&test_file, "# Test\n").unwrap();
1686
1687 let config = server.resolve_config_for_file(&test_file).await;
1688 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
1689
1690 assert_eq!(
1691 line_length,
1692 Some(50),
1693 "Closer config (subdir) should override parent config"
1694 );
1695 }
1696}