1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use anyhow::Result;
11use futures::future::join_all;
12use tokio::sync::{RwLock, mpsc};
13use tower_lsp::jsonrpc::Result as JsonRpcResult;
14use tower_lsp::lsp_types::*;
15use tower_lsp::{Client, LanguageServer};
16
17use crate::config::Config;
18use crate::lint;
19use crate::lsp::index_worker::IndexWorker;
20use crate::lsp::types::{
21 ConfigurationPreference, IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig, warning_to_code_actions,
22 warning_to_diagnostic,
23};
24use crate::rule::{FixCapability, Rule};
25use crate::rules;
26use crate::workspace_index::WorkspaceIndex;
27
28const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
30
31const MAX_RULE_LIST_SIZE: usize = 100;
33
34const MAX_LINE_LENGTH: usize = 10_000;
36
37#[inline]
39fn is_markdown_extension(ext: &str) -> bool {
40 MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
41}
42
43#[derive(Clone, Debug, PartialEq)]
45struct DocumentEntry {
46 content: String,
48 version: Option<i32>,
50 from_disk: bool,
52}
53
54#[derive(Clone, Debug)]
56pub(crate) struct ConfigCacheEntry {
57 pub(crate) config: Config,
59 pub(crate) config_file: Option<PathBuf>,
61 pub(crate) from_global_fallback: bool,
63}
64
65#[derive(Clone)]
75pub struct RumdlLanguageServer {
76 client: Client,
77 config: Arc<RwLock<RumdlLspConfig>>,
79 #[cfg_attr(test, allow(dead_code))]
81 pub(crate) rumdl_config: Arc<RwLock<Config>>,
82 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
84 #[cfg_attr(test, allow(dead_code))]
86 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
87 #[cfg_attr(test, allow(dead_code))]
90 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
91 workspace_index: Arc<RwLock<WorkspaceIndex>>,
93 index_state: Arc<RwLock<IndexState>>,
95 update_tx: mpsc::Sender<IndexUpdate>,
97 client_supports_pull_diagnostics: Arc<RwLock<bool>>,
100}
101
102impl RumdlLanguageServer {
103 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
104 let mut initial_config = RumdlLspConfig::default();
106 if let Some(path) = cli_config_path {
107 initial_config.config_path = Some(path.to_string());
108 }
109
110 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
112 let index_state = Arc::new(RwLock::new(IndexState::default()));
113 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
114
115 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
117 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
118
119 let worker = IndexWorker::new(
121 update_rx,
122 workspace_index.clone(),
123 index_state.clone(),
124 client.clone(),
125 workspace_roots.clone(),
126 relint_tx,
127 );
128 tokio::spawn(worker.run());
129
130 Self {
131 client,
132 config: Arc::new(RwLock::new(initial_config)),
133 rumdl_config: Arc::new(RwLock::new(Config::default())),
134 documents: Arc::new(RwLock::new(HashMap::new())),
135 workspace_roots,
136 config_cache: Arc::new(RwLock::new(HashMap::new())),
137 workspace_index,
138 index_state,
139 update_tx,
140 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
141 }
142 }
143
144 async fn get_document_content(&self, uri: &Url) -> Option<String> {
150 {
152 let docs = self.documents.read().await;
153 if let Some(entry) = docs.get(uri) {
154 return Some(entry.content.clone());
155 }
156 }
157
158 if let Ok(path) = uri.to_file_path() {
160 if let Ok(content) = tokio::fs::read_to_string(&path).await {
161 let entry = DocumentEntry {
163 content: content.clone(),
164 version: None,
165 from_disk: true,
166 };
167
168 let mut docs = self.documents.write().await;
169 docs.insert(uri.clone(), entry);
170
171 log::debug!("Loaded document from disk and cached: {uri}");
172 return Some(content);
173 } else {
174 log::debug!("Failed to read file from disk: {uri}");
175 }
176 }
177
178 None
179 }
180
181 fn is_valid_rule_name(name: &str) -> bool {
185 let bytes = name.as_bytes();
186
187 if bytes.len() == 3
189 && bytes[0].eq_ignore_ascii_case(&b'A')
190 && bytes[1].eq_ignore_ascii_case(&b'L')
191 && bytes[2].eq_ignore_ascii_case(&b'L')
192 {
193 return true;
194 }
195
196 if bytes.len() != 5 {
198 return false;
199 }
200
201 if !bytes[0].eq_ignore_ascii_case(&b'M') || !bytes[1].eq_ignore_ascii_case(&b'D') {
203 return false;
204 }
205
206 let d0 = bytes[2].wrapping_sub(b'0');
208 let d1 = bytes[3].wrapping_sub(b'0');
209 let d2 = bytes[4].wrapping_sub(b'0');
210
211 if d0 > 9 || d1 > 9 || d2 > 9 {
213 return false;
214 }
215
216 let num = (d0 as u32) * 100 + (d1 as u32) * 10 + (d2 as u32);
217
218 matches!(num, 1 | 3..=5 | 7 | 9..=14 | 18..=62)
220 }
221
222 fn apply_lsp_config_overrides(
224 &self,
225 mut filtered_rules: Vec<Box<dyn Rule>>,
226 lsp_config: &RumdlLspConfig,
227 ) -> Vec<Box<dyn Rule>> {
228 let mut enable_rules: Vec<String> = Vec::new();
230 if let Some(enable) = &lsp_config.enable_rules {
231 enable_rules.extend(enable.iter().cloned());
232 }
233 if let Some(settings) = &lsp_config.settings
234 && let Some(enable) = &settings.enable
235 {
236 enable_rules.extend(enable.iter().cloned());
237 }
238
239 if !enable_rules.is_empty() {
241 let enable_set: std::collections::HashSet<String> = enable_rules.into_iter().collect();
242 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
243 }
244
245 let mut disable_rules: Vec<String> = Vec::new();
247 if let Some(disable) = &lsp_config.disable_rules {
248 disable_rules.extend(disable.iter().cloned());
249 }
250 if let Some(settings) = &lsp_config.settings
251 && let Some(disable) = &settings.disable
252 {
253 disable_rules.extend(disable.iter().cloned());
254 }
255
256 if !disable_rules.is_empty() {
258 let disable_set: std::collections::HashSet<String> = disable_rules.into_iter().collect();
259 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
260 }
261
262 filtered_rules
263 }
264
265 fn merge_lsp_settings(&self, mut file_config: Config, lsp_config: &RumdlLspConfig) -> Config {
271 let Some(settings) = &lsp_config.settings else {
272 return file_config;
273 };
274
275 match lsp_config.configuration_preference {
276 ConfigurationPreference::EditorFirst => {
277 self.apply_lsp_settings_to_config(&mut file_config, settings);
279 }
280 ConfigurationPreference::FilesystemFirst => {
281 self.apply_lsp_settings_if_absent(&mut file_config, settings);
283 }
284 ConfigurationPreference::EditorOnly => {
285 let mut default_config = Config::default();
287 self.apply_lsp_settings_to_config(&mut default_config, settings);
288 return default_config;
289 }
290 }
291
292 file_config
293 }
294
295 fn apply_lsp_settings_to_config(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
297 if let Some(line_length) = settings.line_length {
299 config.global.line_length = crate::types::LineLength::new(line_length);
300 }
301
302 if let Some(disable) = &settings.disable {
304 config.global.disable.extend(disable.iter().cloned());
305 }
306
307 if let Some(enable) = &settings.enable {
309 config.global.enable.extend(enable.iter().cloned());
310 }
311
312 for (rule_name, rule_config) in &settings.rules {
314 self.apply_rule_config(config, rule_name, rule_config);
315 }
316 }
317
318 fn apply_lsp_settings_if_absent(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
320 if config.global.line_length.get() == 80
323 && let Some(line_length) = settings.line_length
324 {
325 config.global.line_length = crate::types::LineLength::new(line_length);
326 }
327
328 if let Some(disable) = &settings.disable {
330 config.global.disable.extend(disable.iter().cloned());
331 }
332
333 if let Some(enable) = &settings.enable {
334 config.global.enable.extend(enable.iter().cloned());
335 }
336
337 for (rule_name, rule_config) in &settings.rules {
339 self.apply_rule_config_if_absent(config, rule_name, rule_config);
340 }
341 }
342
343 fn apply_rule_config(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
348 let rule_key = rule_name.to_uppercase();
349
350 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
352
353 if let Some(obj) = rule_config.as_object() {
355 for (key, value) in obj {
356 let config_key = Self::camel_to_snake(key);
358
359 if let Some(toml_value) = Self::json_to_toml(value) {
361 rule_entry.values.insert(config_key, toml_value);
362 }
363 }
364 }
365 }
366
367 fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
369 let rule_key = rule_name.to_uppercase();
370
371 let existing_rule = config.rules.get(&rule_key);
373 let has_existing = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
374
375 if has_existing {
376 log::debug!("Rule {rule_key} already configured in file, skipping LSP settings");
378 return;
379 }
380
381 self.apply_rule_config(config, rule_name, rule_config);
383 }
384
385 fn camel_to_snake(s: &str) -> String {
387 let mut result = String::new();
388 for (i, c) in s.chars().enumerate() {
389 if c.is_uppercase() && i > 0 {
390 result.push('_');
391 }
392 result.push(c.to_lowercase().next().unwrap_or(c));
393 }
394 result
395 }
396
397 fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
399 match json {
400 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
401 serde_json::Value::Number(n) => {
402 if let Some(i) = n.as_i64() {
403 Some(toml::Value::Integer(i))
404 } else {
405 n.as_f64().map(toml::Value::Float)
406 }
407 }
408 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
409 serde_json::Value::Array(arr) => {
410 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
411 Some(toml::Value::Array(toml_arr))
412 }
413 serde_json::Value::Object(obj) => {
414 let mut table = toml::map::Map::new();
415 for (k, v) in obj {
416 if let Some(toml_v) = Self::json_to_toml(v) {
417 table.insert(Self::camel_to_snake(k), toml_v);
418 }
419 }
420 Some(toml::Value::Table(table))
421 }
422 serde_json::Value::Null => None,
423 }
424 }
425
426 async fn should_exclude_uri(&self, uri: &Url) -> bool {
428 let file_path = match uri.to_file_path() {
430 Ok(path) => path,
431 Err(_) => return false, };
433
434 let rumdl_config = self.resolve_config_for_file(&file_path).await;
436 let exclude_patterns = &rumdl_config.global.exclude;
437
438 if exclude_patterns.is_empty() {
440 return false;
441 }
442
443 let path_to_check = if file_path.is_absolute() {
446 if let Ok(cwd) = std::env::current_dir() {
448 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
450 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
451 relative.to_string_lossy().to_string()
452 } else {
453 file_path.to_string_lossy().to_string()
455 }
456 } else {
457 file_path.to_string_lossy().to_string()
459 }
460 } else {
461 file_path.to_string_lossy().to_string()
462 }
463 } else {
464 file_path.to_string_lossy().to_string()
466 };
467
468 for pattern in exclude_patterns {
470 if let Ok(glob) = globset::Glob::new(pattern) {
471 let matcher = glob.compile_matcher();
472 if matcher.is_match(&path_to_check) {
473 log::debug!("Excluding file from LSP linting: {path_to_check}");
474 return true;
475 }
476 }
477 }
478
479 false
480 }
481
482 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
484 let config_guard = self.config.read().await;
485
486 if !config_guard.enable_linting {
488 return Ok(Vec::new());
489 }
490
491 let lsp_config = config_guard.clone();
492 drop(config_guard); if self.should_exclude_uri(uri).await {
496 return Ok(Vec::new());
497 }
498
499 let file_path = uri.to_file_path().ok();
501 let file_config = if let Some(ref path) = file_path {
502 self.resolve_config_for_file(path).await
503 } else {
504 (*self.rumdl_config.read().await).clone()
506 };
507
508 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
510
511 let all_rules = rules::all_rules(&rumdl_config);
512 let flavor = rumdl_config.markdown_flavor();
513
514 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
516
517 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
519
520 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
522 Ok(warnings) => warnings,
523 Err(e) => {
524 log::error!("Failed to lint document {uri}: {e}");
525 return Ok(Vec::new());
526 }
527 };
528
529 if let Some(ref path) = file_path {
531 let index_state = self.index_state.read().await.clone();
532 if matches!(index_state, IndexState::Ready) {
533 let workspace_index = self.workspace_index.read().await;
534 if let Some(file_index) = workspace_index.get_file(path) {
535 match crate::run_cross_file_checks(
536 path,
537 file_index,
538 &filtered_rules,
539 &workspace_index,
540 Some(&rumdl_config),
541 ) {
542 Ok(cross_file_warnings) => {
543 all_warnings.extend(cross_file_warnings);
544 }
545 Err(e) => {
546 log::warn!("Failed to run cross-file checks for {uri}: {e}");
547 }
548 }
549 }
550 }
551 }
552
553 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
554 Ok(diagnostics)
555 }
556
557 async fn update_diagnostics(&self, uri: Url, text: String) {
563 if *self.client_supports_pull_diagnostics.read().await {
565 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
566 return;
567 }
568
569 let version = {
571 let docs = self.documents.read().await;
572 docs.get(&uri).and_then(|entry| entry.version)
573 };
574
575 match self.lint_document(&uri, &text).await {
576 Ok(diagnostics) => {
577 self.client.publish_diagnostics(uri, diagnostics, version).await;
578 }
579 Err(e) => {
580 log::error!("Failed to update diagnostics: {e}");
581 }
582 }
583 }
584
585 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
587 if self.should_exclude_uri(uri).await {
589 return Ok(None);
590 }
591
592 let config_guard = self.config.read().await;
593 let lsp_config = config_guard.clone();
594 drop(config_guard);
595
596 let file_config = if let Ok(file_path) = uri.to_file_path() {
598 self.resolve_config_for_file(&file_path).await
599 } else {
600 (*self.rumdl_config.read().await).clone()
602 };
603
604 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
606
607 let all_rules = rules::all_rules(&rumdl_config);
608 let flavor = rumdl_config.markdown_flavor();
609
610 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
612
613 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
615
616 let mut rules_with_warnings = std::collections::HashSet::new();
619 let mut fixed_text = text.to_string();
620
621 match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
622 Ok(warnings) => {
623 for warning in warnings {
624 if let Some(rule_name) = &warning.rule_name {
625 rules_with_warnings.insert(rule_name.clone());
626 }
627 }
628 }
629 Err(e) => {
630 log::warn!("Failed to lint document for auto-fix: {e}");
631 return Ok(None);
632 }
633 }
634
635 if rules_with_warnings.is_empty() {
637 return Ok(None);
638 }
639
640 let mut any_changes = false;
642
643 for rule in &filtered_rules {
644 if !rules_with_warnings.contains(rule.name()) {
646 continue;
647 }
648
649 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
650 match rule.fix(&ctx) {
651 Ok(new_text) => {
652 if new_text != fixed_text {
653 fixed_text = new_text;
654 any_changes = true;
655 }
656 }
657 Err(e) => {
658 let msg = e.to_string();
660 if !msg.contains("does not support automatic fixing") {
661 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
662 }
663 }
664 }
665 }
666
667 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
668 }
669
670 fn get_end_position(&self, text: &str) -> Position {
672 let mut line = 0u32;
673 let mut character = 0u32;
674
675 for ch in text.chars() {
676 if ch == '\n' {
677 line += 1;
678 character = 0;
679 } else {
680 character += 1;
681 }
682 }
683
684 Position { line, character }
685 }
686
687 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
689 let config_guard = self.config.read().await;
690 let lsp_config = config_guard.clone();
691 drop(config_guard);
692
693 let file_config = if let Ok(file_path) = uri.to_file_path() {
695 self.resolve_config_for_file(&file_path).await
696 } else {
697 (*self.rumdl_config.read().await).clone()
699 };
700
701 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
703
704 let all_rules = rules::all_rules(&rumdl_config);
705 let flavor = rumdl_config.markdown_flavor();
706
707 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
709
710 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
712
713 match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
714 Ok(warnings) => {
715 let mut actions = Vec::new();
716 let mut fixable_count = 0;
717
718 for warning in &warnings {
719 let warning_line = (warning.line.saturating_sub(1)) as u32;
721 if warning_line >= range.start.line && warning_line <= range.end.line {
722 let mut warning_actions = warning_to_code_actions(warning, uri, text);
724 actions.append(&mut warning_actions);
725
726 if warning.fix.is_some() {
727 fixable_count += 1;
728 }
729 }
730 }
731
732 if fixable_count > 1 {
734 let fixable_warnings: Vec<_> = warnings
737 .iter()
738 .filter(|w| {
739 if let Some(rule_name) = &w.rule_name {
740 filtered_rules
741 .iter()
742 .find(|r| r.name() == rule_name)
743 .map(|r| r.fix_capability() != FixCapability::Unfixable)
744 .unwrap_or(false)
745 } else {
746 false
747 }
748 })
749 .cloned()
750 .collect();
751
752 let total_fixable = fixable_warnings.len();
754
755 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
756 && fixed_content != text
757 {
758 let mut line = 0u32;
760 let mut character = 0u32;
761 for ch in text.chars() {
762 if ch == '\n' {
763 line += 1;
764 character = 0;
765 } else {
766 character += 1;
767 }
768 }
769
770 let fix_all_action = CodeAction {
771 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
772 kind: Some(CodeActionKind::QUICKFIX),
773 diagnostics: Some(Vec::new()),
774 edit: Some(WorkspaceEdit {
775 changes: Some(
776 [(
777 uri.clone(),
778 vec![TextEdit {
779 range: Range {
780 start: Position { line: 0, character: 0 },
781 end: Position { line, character },
782 },
783 new_text: fixed_content,
784 }],
785 )]
786 .into_iter()
787 .collect(),
788 ),
789 ..Default::default()
790 }),
791 command: None,
792 is_preferred: Some(true),
793 disabled: None,
794 data: None,
795 };
796
797 actions.insert(0, fix_all_action);
799 }
800 }
801
802 Ok(actions)
803 }
804 Err(e) => {
805 log::error!("Failed to get code actions: {e}");
806 Ok(Vec::new())
807 }
808 }
809 }
810
811 async fn load_configuration(&self, notify_client: bool) {
813 let config_guard = self.config.read().await;
814 let explicit_config_path = config_guard.config_path.clone();
815 drop(config_guard);
816
817 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
819 Ok(sourced_config) => {
820 let loaded_files = sourced_config.loaded_files.clone();
821 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
823
824 if !loaded_files.is_empty() {
825 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
826 log::info!("{message}");
827 if notify_client {
828 self.client.log_message(MessageType::INFO, &message).await;
829 }
830 } else {
831 log::info!("Using default rumdl configuration (no config files found)");
832 }
833 }
834 Err(e) => {
835 let message = format!("Failed to load rumdl config: {e}");
836 log::warn!("{message}");
837 if notify_client {
838 self.client.log_message(MessageType::WARNING, &message).await;
839 }
840 *self.rumdl_config.write().await = crate::config::Config::default();
842 }
843 }
844 }
845
846 async fn reload_configuration(&self) {
848 self.load_configuration(true).await;
849 }
850
851 fn load_config_for_lsp(
853 config_path: Option<&str>,
854 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
855 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
857 }
858
859 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
866 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
868
869 {
871 let cache = self.config_cache.read().await;
872 if let Some(entry) = cache.get(&search_dir) {
873 let source_owned: String; let source: &str = if entry.from_global_fallback {
875 "global/user fallback"
876 } else if let Some(path) = &entry.config_file {
877 source_owned = path.to_string_lossy().to_string();
878 &source_owned
879 } else {
880 "<unknown>"
881 };
882 log::debug!(
883 "Config cache hit for directory: {} (loaded from: {})",
884 search_dir.display(),
885 source
886 );
887 return entry.config.clone();
888 }
889 }
890
891 log::debug!(
893 "Config cache miss for directory: {}, searching for config...",
894 search_dir.display()
895 );
896
897 let workspace_root = {
899 let workspace_roots = self.workspace_roots.read().await;
900 workspace_roots
901 .iter()
902 .find(|root| search_dir.starts_with(root))
903 .map(|p| p.to_path_buf())
904 };
905
906 let mut current_dir = search_dir.clone();
908 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
909
910 loop {
911 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
913
914 for config_file_name in CONFIG_FILES {
915 let config_path = current_dir.join(config_file_name);
916 if config_path.exists() {
917 if *config_file_name == "pyproject.toml" {
919 if let Ok(content) = std::fs::read_to_string(&config_path) {
920 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
921 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
922 } else {
923 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
924 continue;
925 }
926 } else {
927 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
928 continue;
929 }
930 } else {
931 log::debug!("Found config file: {}", config_path.display());
932 }
933
934 if let Some(config_path_str) = config_path.to_str() {
936 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
937 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
938 break;
939 }
940 } else {
941 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
942 }
943 }
944 }
945
946 if found_config.is_some() {
947 break;
948 }
949
950 if let Some(ref root) = workspace_root
952 && ¤t_dir == root
953 {
954 log::debug!("Hit workspace root without finding config: {}", root.display());
955 break;
956 }
957
958 if let Some(parent) = current_dir.parent() {
960 current_dir = parent.to_path_buf();
961 } else {
962 break;
964 }
965 }
966
967 let (config, config_file) = if let Some((cfg, path)) = found_config {
969 (cfg, path)
970 } else {
971 log::debug!("No project config found; using global/user fallback config");
972 let fallback = self.rumdl_config.read().await.clone();
973 (fallback, None)
974 };
975
976 let from_global = config_file.is_none();
978 let entry = ConfigCacheEntry {
979 config: config.clone(),
980 config_file,
981 from_global_fallback: from_global,
982 };
983
984 self.config_cache.write().await.insert(search_dir, entry);
985
986 config
987 }
988}
989
990#[tower_lsp::async_trait]
991impl LanguageServer for RumdlLanguageServer {
992 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
993 log::info!("Initializing rumdl Language Server");
994
995 if let Some(options) = params.initialization_options
997 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
998 {
999 *self.config.write().await = config;
1000 }
1001
1002 let supports_pull = params
1005 .capabilities
1006 .text_document
1007 .as_ref()
1008 .and_then(|td| td.diagnostic.as_ref())
1009 .is_some();
1010
1011 if supports_pull {
1012 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1013 *self.client_supports_pull_diagnostics.write().await = true;
1014 } else {
1015 log::info!("Client does not support pull diagnostics - using push model");
1016 }
1017
1018 let mut roots = Vec::new();
1020 if let Some(workspace_folders) = params.workspace_folders {
1021 for folder in workspace_folders {
1022 if let Ok(path) = folder.uri.to_file_path() {
1023 log::info!("Workspace root: {}", path.display());
1024 roots.push(path);
1025 }
1026 }
1027 } else if let Some(root_uri) = params.root_uri
1028 && let Ok(path) = root_uri.to_file_path()
1029 {
1030 log::info!("Workspace root: {}", path.display());
1031 roots.push(path);
1032 }
1033 *self.workspace_roots.write().await = roots;
1034
1035 self.load_configuration(false).await;
1037
1038 Ok(InitializeResult {
1039 capabilities: ServerCapabilities {
1040 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1041 open_close: Some(true),
1042 change: Some(TextDocumentSyncKind::FULL),
1043 will_save: Some(false),
1044 will_save_wait_until: Some(true),
1045 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1046 include_text: Some(false),
1047 })),
1048 })),
1049 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1050 document_formatting_provider: Some(OneOf::Left(true)),
1051 document_range_formatting_provider: Some(OneOf::Left(true)),
1052 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1053 identifier: Some("rumdl".to_string()),
1054 inter_file_dependencies: true,
1055 workspace_diagnostics: true,
1056 work_done_progress_options: WorkDoneProgressOptions::default(),
1057 })),
1058 workspace: Some(WorkspaceServerCapabilities {
1059 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1060 supported: Some(true),
1061 change_notifications: Some(OneOf::Left(true)),
1062 }),
1063 file_operations: None,
1064 }),
1065 ..Default::default()
1066 },
1067 server_info: Some(ServerInfo {
1068 name: "rumdl".to_string(),
1069 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1070 }),
1071 })
1072 }
1073
1074 async fn initialized(&self, _: InitializedParams) {
1075 let version = env!("CARGO_PKG_VERSION");
1076
1077 let (binary_path, build_time) = std::env::current_exe()
1079 .ok()
1080 .map(|path| {
1081 let path_str = path.to_str().unwrap_or("unknown").to_string();
1082 let build_time = std::fs::metadata(&path)
1083 .ok()
1084 .and_then(|metadata| metadata.modified().ok())
1085 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1086 .and_then(|duration| {
1087 let secs = duration.as_secs();
1088 chrono::DateTime::from_timestamp(secs as i64, 0)
1089 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1090 })
1091 .unwrap_or_else(|| "unknown".to_string());
1092 (path_str, build_time)
1093 })
1094 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1095
1096 let working_dir = std::env::current_dir()
1097 .ok()
1098 .and_then(|p| p.to_str().map(|s| s.to_string()))
1099 .unwrap_or_else(|| "unknown".to_string());
1100
1101 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1102 log::info!("Working directory: {working_dir}");
1103
1104 self.client
1105 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1106 .await;
1107
1108 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1110 log::warn!("Failed to trigger initial workspace indexing");
1111 } else {
1112 log::info!("Triggered initial workspace indexing for cross-file analysis");
1113 }
1114
1115 let markdown_patterns = [
1118 "**/*.md",
1119 "**/*.markdown",
1120 "**/*.mdx",
1121 "**/*.mkd",
1122 "**/*.mkdn",
1123 "**/*.mdown",
1124 "**/*.mdwn",
1125 "**/*.qmd",
1126 "**/*.rmd",
1127 ];
1128 let watchers: Vec<_> = markdown_patterns
1129 .iter()
1130 .map(|pattern| FileSystemWatcher {
1131 glob_pattern: GlobPattern::String((*pattern).to_string()),
1132 kind: Some(WatchKind::all()),
1133 })
1134 .collect();
1135
1136 let registration = Registration {
1137 id: "markdown-watcher".to_string(),
1138 method: "workspace/didChangeWatchedFiles".to_string(),
1139 register_options: Some(
1140 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1141 ),
1142 };
1143
1144 if self.client.register_capability(vec![registration]).await.is_err() {
1145 log::debug!("Client does not support file watching capability");
1146 }
1147 }
1148
1149 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1150 let mut roots = self.workspace_roots.write().await;
1152
1153 for removed in ¶ms.event.removed {
1155 if let Ok(path) = removed.uri.to_file_path() {
1156 roots.retain(|r| r != &path);
1157 log::info!("Removed workspace root: {}", path.display());
1158 }
1159 }
1160
1161 for added in ¶ms.event.added {
1163 if let Ok(path) = added.uri.to_file_path()
1164 && !roots.contains(&path)
1165 {
1166 log::info!("Added workspace root: {}", path.display());
1167 roots.push(path);
1168 }
1169 }
1170 drop(roots);
1171
1172 self.config_cache.write().await.clear();
1174
1175 self.reload_configuration().await;
1177
1178 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1180 log::warn!("Failed to trigger workspace rescan after folder change");
1181 }
1182 }
1183
1184 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1185 log::debug!("Configuration changed: {:?}", params.settings);
1186
1187 let settings_value = params.settings;
1191
1192 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1194 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1195 } else {
1196 settings_value
1197 };
1198
1199 let mut config_applied = false;
1201 let mut warnings: Vec<String> = Vec::new();
1202
1203 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1207 && (rule_settings.disable.is_some()
1208 || rule_settings.enable.is_some()
1209 || rule_settings.line_length.is_some()
1210 || !rule_settings.rules.is_empty())
1211 {
1212 if let Some(ref disable) = rule_settings.disable {
1214 for rule in disable {
1215 if !Self::is_valid_rule_name(rule) {
1216 warnings.push(format!("Unknown rule in disable list: {rule}"));
1217 }
1218 }
1219 }
1220 if let Some(ref enable) = rule_settings.enable {
1221 for rule in enable {
1222 if !Self::is_valid_rule_name(rule) {
1223 warnings.push(format!("Unknown rule in enable list: {rule}"));
1224 }
1225 }
1226 }
1227 for rule_name in rule_settings.rules.keys() {
1229 if !Self::is_valid_rule_name(rule_name) {
1230 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1231 }
1232 }
1233
1234 log::info!("Applied rule settings from configuration (Neovim style)");
1235 let mut config = self.config.write().await;
1236 config.settings = Some(rule_settings);
1237 drop(config);
1238 config_applied = true;
1239 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1240 && (full_config.config_path.is_some()
1241 || full_config.enable_rules.is_some()
1242 || full_config.disable_rules.is_some()
1243 || full_config.settings.is_some()
1244 || !full_config.enable_linting
1245 || full_config.enable_auto_fix)
1246 {
1247 if let Some(ref rules) = full_config.enable_rules {
1249 for rule in rules {
1250 if !Self::is_valid_rule_name(rule) {
1251 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1252 }
1253 }
1254 }
1255 if let Some(ref rules) = full_config.disable_rules {
1256 for rule in rules {
1257 if !Self::is_valid_rule_name(rule) {
1258 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1259 }
1260 }
1261 }
1262
1263 log::info!("Applied full LSP configuration from settings");
1264 *self.config.write().await = full_config;
1265 config_applied = true;
1266 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1267 let mut config = self.config.write().await;
1270
1271 let mut rules = std::collections::HashMap::new();
1273 let mut disable = Vec::new();
1274 let mut enable = Vec::new();
1275 let mut line_length = None;
1276
1277 for (key, value) in obj {
1278 match key.as_str() {
1279 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1280 Ok(d) => {
1281 if d.len() > MAX_RULE_LIST_SIZE {
1282 warnings.push(format!(
1283 "Too many rules in 'disable' ({} > {}), truncating",
1284 d.len(),
1285 MAX_RULE_LIST_SIZE
1286 ));
1287 }
1288 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1289 if !Self::is_valid_rule_name(rule) {
1290 warnings.push(format!("Unknown rule in disable: {rule}"));
1291 }
1292 }
1293 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1294 }
1295 Err(_) => {
1296 warnings.push(format!(
1297 "Invalid 'disable' value: expected array of strings, got {value}"
1298 ));
1299 }
1300 },
1301 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1302 Ok(e) => {
1303 if e.len() > MAX_RULE_LIST_SIZE {
1304 warnings.push(format!(
1305 "Too many rules in 'enable' ({} > {}), truncating",
1306 e.len(),
1307 MAX_RULE_LIST_SIZE
1308 ));
1309 }
1310 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1311 if !Self::is_valid_rule_name(rule) {
1312 warnings.push(format!("Unknown rule in enable: {rule}"));
1313 }
1314 }
1315 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1316 }
1317 Err(_) => {
1318 warnings.push(format!(
1319 "Invalid 'enable' value: expected array of strings, got {value}"
1320 ));
1321 }
1322 },
1323 "lineLength" | "line_length" | "line-length" => {
1324 if let Some(l) = value.as_u64() {
1325 match usize::try_from(l) {
1326 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1327 Ok(len) => warnings.push(format!(
1328 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1329 )),
1330 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1331 }
1332 } else {
1333 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1334 }
1335 }
1336 _ if key.starts_with("MD") || key.starts_with("md") => {
1338 let normalized = key.to_uppercase();
1339 if !Self::is_valid_rule_name(&normalized) {
1340 warnings.push(format!("Unknown rule: {key}"));
1341 }
1342 rules.insert(normalized, value);
1343 }
1344 _ => {
1345 warnings.push(format!("Unknown configuration key: {key}"));
1347 }
1348 }
1349 }
1350
1351 let settings = LspRuleSettings {
1352 line_length,
1353 disable: if disable.is_empty() { None } else { Some(disable) },
1354 enable: if enable.is_empty() { None } else { Some(enable) },
1355 rules,
1356 };
1357
1358 log::info!("Applied Neovim-style rule settings (manual parse)");
1359 config.settings = Some(settings);
1360 drop(config);
1361 config_applied = true;
1362 } else {
1363 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1364 }
1365
1366 for warning in &warnings {
1368 log::warn!("{warning}");
1369 }
1370
1371 if !warnings.is_empty() {
1373 let message = if warnings.len() == 1 {
1374 format!("rumdl: {}", warnings[0])
1375 } else {
1376 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1377 };
1378 self.client.log_message(MessageType::WARNING, message).await;
1379 }
1380
1381 if !config_applied {
1382 log::debug!("No configuration changes applied");
1383 }
1384
1385 self.config_cache.write().await.clear();
1387
1388 let doc_list: Vec<_> = {
1390 let documents = self.documents.read().await;
1391 documents
1392 .iter()
1393 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1394 .collect()
1395 };
1396
1397 let tasks = doc_list.into_iter().map(|(uri, text)| {
1399 let server = self.clone();
1400 tokio::spawn(async move {
1401 server.update_diagnostics(uri, text).await;
1402 })
1403 });
1404
1405 let _ = join_all(tasks).await;
1407 }
1408
1409 async fn shutdown(&self) -> JsonRpcResult<()> {
1410 log::info!("Shutting down rumdl Language Server");
1411
1412 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1414
1415 Ok(())
1416 }
1417
1418 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1419 let uri = params.text_document.uri;
1420 let text = params.text_document.text;
1421 let version = params.text_document.version;
1422
1423 let entry = DocumentEntry {
1424 content: text.clone(),
1425 version: Some(version),
1426 from_disk: false,
1427 };
1428 self.documents.write().await.insert(uri.clone(), entry);
1429
1430 if let Ok(path) = uri.to_file_path() {
1432 let _ = self
1433 .update_tx
1434 .send(IndexUpdate::FileChanged {
1435 path,
1436 content: text.clone(),
1437 })
1438 .await;
1439 }
1440
1441 self.update_diagnostics(uri, text).await;
1442 }
1443
1444 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1445 let uri = params.text_document.uri;
1446 let version = params.text_document.version;
1447
1448 if let Some(change) = params.content_changes.into_iter().next() {
1449 let text = change.text;
1450
1451 let entry = DocumentEntry {
1452 content: text.clone(),
1453 version: Some(version),
1454 from_disk: false,
1455 };
1456 self.documents.write().await.insert(uri.clone(), entry);
1457
1458 if let Ok(path) = uri.to_file_path() {
1460 let _ = self
1461 .update_tx
1462 .send(IndexUpdate::FileChanged {
1463 path,
1464 content: text.clone(),
1465 })
1466 .await;
1467 }
1468
1469 self.update_diagnostics(uri, text).await;
1470 }
1471 }
1472
1473 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1474 let config_guard = self.config.read().await;
1475 let enable_auto_fix = config_guard.enable_auto_fix;
1476 drop(config_guard);
1477
1478 if !enable_auto_fix {
1479 return Ok(None);
1480 }
1481
1482 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1484 return Ok(None);
1485 };
1486
1487 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1489 Ok(Some(fixed_text)) => {
1490 Ok(Some(vec![TextEdit {
1492 range: Range {
1493 start: Position { line: 0, character: 0 },
1494 end: self.get_end_position(&text),
1495 },
1496 new_text: fixed_text,
1497 }]))
1498 }
1499 Ok(None) => Ok(None),
1500 Err(e) => {
1501 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1502 Ok(None)
1503 }
1504 }
1505 }
1506
1507 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1508 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1511 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1512 .await;
1513 }
1514 }
1515
1516 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1517 self.documents.write().await.remove(¶ms.text_document.uri);
1519
1520 self.client
1523 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1524 .await;
1525 }
1526
1527 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1528 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1530
1531 let mut config_changed = false;
1532
1533 for change in ¶ms.changes {
1534 if let Ok(path) = change.uri.to_file_path() {
1535 let file_name = path.file_name().and_then(|f| f.to_str());
1536 let extension = path.extension().and_then(|e| e.to_str());
1537
1538 if let Some(name) = file_name
1540 && CONFIG_FILES.contains(&name)
1541 && !config_changed
1542 {
1543 log::info!("Config file changed: {}, invalidating config cache", path.display());
1544
1545 let mut cache = self.config_cache.write().await;
1547 cache.retain(|_, entry| {
1548 if let Some(config_file) = &entry.config_file {
1549 config_file != &path
1550 } else {
1551 true
1552 }
1553 });
1554
1555 drop(cache);
1557 self.reload_configuration().await;
1558 config_changed = true;
1559 }
1560
1561 if let Some(ext) = extension
1563 && is_markdown_extension(ext)
1564 {
1565 match change.typ {
1566 FileChangeType::CREATED | FileChangeType::CHANGED => {
1567 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1569 let _ = self
1570 .update_tx
1571 .send(IndexUpdate::FileChanged {
1572 path: path.clone(),
1573 content,
1574 })
1575 .await;
1576 }
1577 }
1578 FileChangeType::DELETED => {
1579 let _ = self
1580 .update_tx
1581 .send(IndexUpdate::FileDeleted { path: path.clone() })
1582 .await;
1583 }
1584 _ => {}
1585 }
1586 }
1587 }
1588 }
1589
1590 if config_changed {
1592 let docs_to_update: Vec<(Url, String)> = {
1593 let docs = self.documents.read().await;
1594 docs.iter()
1595 .filter(|(_, entry)| !entry.from_disk)
1596 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1597 .collect()
1598 };
1599
1600 for (uri, text) in docs_to_update {
1601 self.update_diagnostics(uri, text).await;
1602 }
1603 }
1604 }
1605
1606 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1607 let uri = params.text_document.uri;
1608 let range = params.range;
1609
1610 if let Some(text) = self.get_document_content(&uri).await {
1611 match self.get_code_actions(&uri, &text, range).await {
1612 Ok(actions) => {
1613 let response: Vec<CodeActionOrCommand> =
1614 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1615 Ok(Some(response))
1616 }
1617 Err(e) => {
1618 log::error!("Failed to get code actions: {e}");
1619 Ok(None)
1620 }
1621 }
1622 } else {
1623 Ok(None)
1624 }
1625 }
1626
1627 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1628 log::debug!(
1633 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1634 params.range
1635 );
1636
1637 let formatting_params = DocumentFormattingParams {
1638 text_document: params.text_document,
1639 options: params.options,
1640 work_done_progress_params: params.work_done_progress_params,
1641 };
1642
1643 self.formatting(formatting_params).await
1644 }
1645
1646 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1647 let uri = params.text_document.uri;
1648
1649 log::debug!("Formatting request for: {uri}");
1650
1651 if let Some(text) = self.get_document_content(&uri).await {
1652 let config_guard = self.config.read().await;
1654 let lsp_config = config_guard.clone();
1655 drop(config_guard);
1656
1657 let file_config = if let Ok(file_path) = uri.to_file_path() {
1659 self.resolve_config_for_file(&file_path).await
1660 } else {
1661 self.rumdl_config.read().await.clone()
1663 };
1664
1665 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1667
1668 let all_rules = rules::all_rules(&rumdl_config);
1669 let flavor = rumdl_config.markdown_flavor();
1670
1671 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1673
1674 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1676
1677 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1679 Ok(warnings) => {
1680 log::debug!(
1681 "Found {} warnings, {} with fixes",
1682 warnings.len(),
1683 warnings.iter().filter(|w| w.fix.is_some()).count()
1684 );
1685
1686 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1687 if has_fixes {
1688 let fixable_warnings: Vec<_> = warnings
1692 .iter()
1693 .filter(|w| {
1694 if let Some(rule_name) = &w.rule_name {
1695 filtered_rules
1696 .iter()
1697 .find(|r| r.name() == rule_name)
1698 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1699 .unwrap_or(false)
1700 } else {
1701 false
1702 }
1703 })
1704 .cloned()
1705 .collect();
1706
1707 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1708 Ok(fixed_content) => {
1709 if fixed_content != text {
1710 log::debug!("Returning formatting edits");
1711 let end_position = self.get_end_position(&text);
1712 let edit = TextEdit {
1713 range: Range {
1714 start: Position { line: 0, character: 0 },
1715 end: end_position,
1716 },
1717 new_text: fixed_content,
1718 };
1719 return Ok(Some(vec![edit]));
1720 }
1721 }
1722 Err(e) => {
1723 log::error!("Failed to apply fixes: {e}");
1724 }
1725 }
1726 }
1727 Ok(Some(Vec::new()))
1728 }
1729 Err(e) => {
1730 log::error!("Failed to format document: {e}");
1731 Ok(Some(Vec::new()))
1732 }
1733 }
1734 } else {
1735 log::warn!("Document not found: {uri}");
1736 Ok(None)
1737 }
1738 }
1739
1740 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1741 let uri = params.text_document.uri;
1742
1743 if let Some(text) = self.get_document_content(&uri).await {
1744 match self.lint_document(&uri, &text).await {
1745 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1746 RelatedFullDocumentDiagnosticReport {
1747 related_documents: None,
1748 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1749 result_id: None,
1750 items: diagnostics,
1751 },
1752 },
1753 ))),
1754 Err(e) => {
1755 log::error!("Failed to get diagnostics: {e}");
1756 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1757 RelatedFullDocumentDiagnosticReport {
1758 related_documents: None,
1759 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1760 result_id: None,
1761 items: Vec::new(),
1762 },
1763 },
1764 )))
1765 }
1766 }
1767 } else {
1768 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1769 RelatedFullDocumentDiagnosticReport {
1770 related_documents: None,
1771 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1772 result_id: None,
1773 items: Vec::new(),
1774 },
1775 },
1776 )))
1777 }
1778 }
1779}
1780
1781#[cfg(test)]
1782mod tests {
1783 use super::*;
1784 use crate::rule::LintWarning;
1785 use tower_lsp::LspService;
1786
1787 fn create_test_server() -> RumdlLanguageServer {
1788 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1789 service.inner().clone()
1790 }
1791
1792 #[test]
1793 fn test_is_valid_rule_name() {
1794 assert!(RumdlLanguageServer::is_valid_rule_name("MD001"));
1796 assert!(RumdlLanguageServer::is_valid_rule_name("md001")); assert!(RumdlLanguageServer::is_valid_rule_name("Md001")); assert!(RumdlLanguageServer::is_valid_rule_name("mD001")); assert!(RumdlLanguageServer::is_valid_rule_name("all")); assert!(RumdlLanguageServer::is_valid_rule_name("ALL")); assert!(RumdlLanguageServer::is_valid_rule_name("All")); assert!(RumdlLanguageServer::is_valid_rule_name("MD003")); assert!(RumdlLanguageServer::is_valid_rule_name("MD005")); assert!(RumdlLanguageServer::is_valid_rule_name("MD007")); assert!(RumdlLanguageServer::is_valid_rule_name("MD009")); assert!(RumdlLanguageServer::is_valid_rule_name("MD014")); assert!(RumdlLanguageServer::is_valid_rule_name("MD018")); assert!(RumdlLanguageServer::is_valid_rule_name("MD062")); assert!(RumdlLanguageServer::is_valid_rule_name("MD041")); assert!(RumdlLanguageServer::is_valid_rule_name("MD060")); assert!(RumdlLanguageServer::is_valid_rule_name("MD061")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD002")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD006")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD008")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD015")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD016")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD017")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD000")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD063")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD999")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD13")); assert!(!RumdlLanguageServer::is_valid_rule_name("INVALID"));
1833 assert!(!RumdlLanguageServer::is_valid_rule_name(""));
1834 assert!(!RumdlLanguageServer::is_valid_rule_name("MD"));
1835 assert!(!RumdlLanguageServer::is_valid_rule_name("MD0001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD1")); }
1838
1839 #[tokio::test]
1840 async fn test_server_creation() {
1841 let server = create_test_server();
1842
1843 let config = server.config.read().await;
1845 assert!(config.enable_linting);
1846 assert!(!config.enable_auto_fix);
1847 }
1848
1849 #[tokio::test]
1850 async fn test_lint_document() {
1851 let server = create_test_server();
1852
1853 let uri = Url::parse("file:///test.md").unwrap();
1855 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1856
1857 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1858
1859 assert!(!diagnostics.is_empty());
1861 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1862 }
1863
1864 #[tokio::test]
1865 async fn test_lint_document_disabled() {
1866 let server = create_test_server();
1867
1868 server.config.write().await.enable_linting = false;
1870
1871 let uri = Url::parse("file:///test.md").unwrap();
1872 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1873
1874 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1875
1876 assert!(diagnostics.is_empty());
1878 }
1879
1880 #[tokio::test]
1881 async fn test_get_code_actions() {
1882 let server = create_test_server();
1883
1884 let uri = Url::parse("file:///test.md").unwrap();
1885 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1886
1887 let range = Range {
1889 start: Position { line: 0, character: 0 },
1890 end: Position { line: 3, character: 21 },
1891 };
1892
1893 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1894
1895 assert!(!actions.is_empty());
1897 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1898 }
1899
1900 #[tokio::test]
1901 async fn test_get_code_actions_outside_range() {
1902 let server = create_test_server();
1903
1904 let uri = Url::parse("file:///test.md").unwrap();
1905 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1906
1907 let range = Range {
1909 start: Position { line: 0, character: 0 },
1910 end: Position { line: 0, character: 6 },
1911 };
1912
1913 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1914
1915 assert!(actions.is_empty());
1917 }
1918
1919 #[tokio::test]
1920 async fn test_document_storage() {
1921 let server = create_test_server();
1922
1923 let uri = Url::parse("file:///test.md").unwrap();
1924 let text = "# Test Document";
1925
1926 let entry = DocumentEntry {
1928 content: text.to_string(),
1929 version: Some(1),
1930 from_disk: false,
1931 };
1932 server.documents.write().await.insert(uri.clone(), entry);
1933
1934 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1936 assert_eq!(stored, Some(text.to_string()));
1937
1938 server.documents.write().await.remove(&uri);
1940
1941 let stored = server.documents.read().await.get(&uri).cloned();
1943 assert_eq!(stored, None);
1944 }
1945
1946 #[tokio::test]
1947 async fn test_configuration_loading() {
1948 let server = create_test_server();
1949
1950 server.load_configuration(false).await;
1952
1953 let rumdl_config = server.rumdl_config.read().await;
1956 drop(rumdl_config); }
1959
1960 #[tokio::test]
1961 async fn test_load_config_for_lsp() {
1962 let result = RumdlLanguageServer::load_config_for_lsp(None);
1964 assert!(result.is_ok());
1965
1966 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1968 assert!(result.is_err());
1969 }
1970
1971 #[tokio::test]
1972 async fn test_warning_conversion() {
1973 let warning = LintWarning {
1974 message: "Test warning".to_string(),
1975 line: 1,
1976 column: 1,
1977 end_line: 1,
1978 end_column: 10,
1979 severity: crate::rule::Severity::Warning,
1980 fix: None,
1981 rule_name: Some("MD001".to_string()),
1982 };
1983
1984 let diagnostic = warning_to_diagnostic(&warning);
1986 assert_eq!(diagnostic.message, "Test warning");
1987 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1988 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1989
1990 let uri = Url::parse("file:///test.md").unwrap();
1992 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1993 assert_eq!(actions.len(), 1);
1995 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1996 }
1997
1998 #[tokio::test]
1999 async fn test_multiple_documents() {
2000 let server = create_test_server();
2001
2002 let uri1 = Url::parse("file:///test1.md").unwrap();
2003 let uri2 = Url::parse("file:///test2.md").unwrap();
2004 let text1 = "# Document 1";
2005 let text2 = "# Document 2";
2006
2007 {
2009 let mut docs = server.documents.write().await;
2010 let entry1 = DocumentEntry {
2011 content: text1.to_string(),
2012 version: Some(1),
2013 from_disk: false,
2014 };
2015 let entry2 = DocumentEntry {
2016 content: text2.to_string(),
2017 version: Some(1),
2018 from_disk: false,
2019 };
2020 docs.insert(uri1.clone(), entry1);
2021 docs.insert(uri2.clone(), entry2);
2022 }
2023
2024 let docs = server.documents.read().await;
2026 assert_eq!(docs.len(), 2);
2027 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2028 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2029 }
2030
2031 #[tokio::test]
2032 async fn test_auto_fix_on_save() {
2033 let server = create_test_server();
2034
2035 {
2037 let mut config = server.config.write().await;
2038 config.enable_auto_fix = true;
2039 }
2040
2041 let uri = Url::parse("file:///test.md").unwrap();
2042 let text = "#Heading without space"; let entry = DocumentEntry {
2046 content: text.to_string(),
2047 version: Some(1),
2048 from_disk: false,
2049 };
2050 server.documents.write().await.insert(uri.clone(), entry);
2051
2052 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2054 assert!(fixed.is_some());
2055 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2057 }
2058
2059 #[tokio::test]
2060 async fn test_get_end_position() {
2061 let server = create_test_server();
2062
2063 let pos = server.get_end_position("Hello");
2065 assert_eq!(pos.line, 0);
2066 assert_eq!(pos.character, 5);
2067
2068 let pos = server.get_end_position("Hello\nWorld\nTest");
2070 assert_eq!(pos.line, 2);
2071 assert_eq!(pos.character, 4);
2072
2073 let pos = server.get_end_position("");
2075 assert_eq!(pos.line, 0);
2076 assert_eq!(pos.character, 0);
2077
2078 let pos = server.get_end_position("Hello\n");
2080 assert_eq!(pos.line, 1);
2081 assert_eq!(pos.character, 0);
2082 }
2083
2084 #[tokio::test]
2085 async fn test_empty_document_handling() {
2086 let server = create_test_server();
2087
2088 let uri = Url::parse("file:///empty.md").unwrap();
2089 let text = "";
2090
2091 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2093 assert!(diagnostics.is_empty());
2094
2095 let range = Range {
2097 start: Position { line: 0, character: 0 },
2098 end: Position { line: 0, character: 0 },
2099 };
2100 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2101 assert!(actions.is_empty());
2102 }
2103
2104 #[tokio::test]
2105 async fn test_config_update() {
2106 let server = create_test_server();
2107
2108 {
2110 let mut config = server.config.write().await;
2111 config.enable_auto_fix = true;
2112 config.config_path = Some("/custom/path.toml".to_string());
2113 }
2114
2115 let config = server.config.read().await;
2117 assert!(config.enable_auto_fix);
2118 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2119 }
2120
2121 #[tokio::test]
2122 async fn test_document_formatting() {
2123 let server = create_test_server();
2124 let uri = Url::parse("file:///test.md").unwrap();
2125 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2126
2127 let entry = DocumentEntry {
2129 content: text.to_string(),
2130 version: Some(1),
2131 from_disk: false,
2132 };
2133 server.documents.write().await.insert(uri.clone(), entry);
2134
2135 let params = DocumentFormattingParams {
2137 text_document: TextDocumentIdentifier { uri: uri.clone() },
2138 options: FormattingOptions {
2139 tab_size: 4,
2140 insert_spaces: true,
2141 properties: HashMap::new(),
2142 trim_trailing_whitespace: Some(true),
2143 insert_final_newline: Some(true),
2144 trim_final_newlines: Some(true),
2145 },
2146 work_done_progress_params: WorkDoneProgressParams::default(),
2147 };
2148
2149 let result = server.formatting(params).await.unwrap();
2151
2152 assert!(result.is_some());
2154 let edits = result.unwrap();
2155 assert!(!edits.is_empty());
2156
2157 let edit = &edits[0];
2159 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
2162 assert_eq!(edit.new_text, expected);
2163 }
2164
2165 #[tokio::test]
2168 async fn test_unfixable_rules_excluded_from_formatting() {
2169 let server = create_test_server();
2170 let uri = Url::parse("file:///test.md").unwrap();
2171
2172 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2174
2175 let entry = DocumentEntry {
2177 content: text.to_string(),
2178 version: Some(1),
2179 from_disk: false,
2180 };
2181 server.documents.write().await.insert(uri.clone(), entry);
2182
2183 let format_params = DocumentFormattingParams {
2185 text_document: TextDocumentIdentifier { uri: uri.clone() },
2186 options: FormattingOptions {
2187 tab_size: 4,
2188 insert_spaces: true,
2189 properties: HashMap::new(),
2190 trim_trailing_whitespace: Some(true),
2191 insert_final_newline: Some(true),
2192 trim_final_newlines: Some(true),
2193 },
2194 work_done_progress_params: WorkDoneProgressParams::default(),
2195 };
2196
2197 let format_result = server.formatting(format_params).await.unwrap();
2198 assert!(format_result.is_some(), "Should return formatting edits");
2199
2200 let edits = format_result.unwrap();
2201 assert!(!edits.is_empty(), "Should have formatting edits");
2202
2203 let formatted = &edits[0].new_text;
2204 assert!(
2205 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2206 "HTML should be preserved during formatting (Unfixable rule)"
2207 );
2208 assert!(
2209 !formatted.contains("spaces "),
2210 "Trailing spaces should be removed (fixable rule)"
2211 );
2212
2213 let range = Range {
2215 start: Position { line: 0, character: 0 },
2216 end: Position { line: 10, character: 0 },
2217 };
2218
2219 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2220
2221 let html_fix_actions: Vec<_> = code_actions
2223 .iter()
2224 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2225 .collect();
2226
2227 assert!(
2228 !html_fix_actions.is_empty(),
2229 "Quick Fix actions should be available for HTML (Unfixable rules)"
2230 );
2231
2232 let fix_all_actions: Vec<_> = code_actions
2234 .iter()
2235 .filter(|action| action.title.contains("Fix all"))
2236 .collect();
2237
2238 if let Some(fix_all_action) = fix_all_actions.first()
2239 && let Some(ref edit) = fix_all_action.edit
2240 && let Some(ref changes) = edit.changes
2241 && let Some(text_edits) = changes.get(&uri)
2242 && let Some(text_edit) = text_edits.first()
2243 {
2244 let fixed_all = &text_edit.new_text;
2245 assert!(
2246 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2247 "Fix All should preserve HTML (Unfixable rules)"
2248 );
2249 assert!(
2250 !fixed_all.contains("spaces "),
2251 "Fix All should remove trailing spaces (fixable rules)"
2252 );
2253 }
2254 }
2255
2256 #[tokio::test]
2258 async fn test_resolve_config_for_file_multi_root() {
2259 use std::fs;
2260 use tempfile::tempdir;
2261
2262 let temp_dir = tempdir().unwrap();
2263 let temp_path = temp_dir.path();
2264
2265 let project_a = temp_path.join("project_a");
2267 let project_a_docs = project_a.join("docs");
2268 fs::create_dir_all(&project_a_docs).unwrap();
2269
2270 let config_a = project_a.join(".rumdl.toml");
2271 fs::write(
2272 &config_a,
2273 r#"
2274[global]
2275
2276[MD013]
2277line_length = 60
2278"#,
2279 )
2280 .unwrap();
2281
2282 let project_b = temp_path.join("project_b");
2284 fs::create_dir(&project_b).unwrap();
2285
2286 let config_b = project_b.join(".rumdl.toml");
2287 fs::write(
2288 &config_b,
2289 r#"
2290[global]
2291
2292[MD013]
2293line_length = 120
2294"#,
2295 )
2296 .unwrap();
2297
2298 let server = create_test_server();
2300
2301 {
2303 let mut roots = server.workspace_roots.write().await;
2304 roots.push(project_a.clone());
2305 roots.push(project_b.clone());
2306 }
2307
2308 let file_a = project_a_docs.join("test.md");
2310 fs::write(&file_a, "# Test A\n").unwrap();
2311
2312 let config_for_a = server.resolve_config_for_file(&file_a).await;
2313 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2314 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2315
2316 let file_b = project_b.join("test.md");
2318 fs::write(&file_b, "# Test B\n").unwrap();
2319
2320 let config_for_b = server.resolve_config_for_file(&file_b).await;
2321 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2322 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2323 }
2324
2325 #[tokio::test]
2327 async fn test_config_resolution_respects_workspace_boundaries() {
2328 use std::fs;
2329 use tempfile::tempdir;
2330
2331 let temp_dir = tempdir().unwrap();
2332 let temp_path = temp_dir.path();
2333
2334 let parent_config = temp_path.join(".rumdl.toml");
2336 fs::write(
2337 &parent_config,
2338 r#"
2339[global]
2340
2341[MD013]
2342line_length = 80
2343"#,
2344 )
2345 .unwrap();
2346
2347 let workspace_root = temp_path.join("workspace");
2349 let workspace_subdir = workspace_root.join("subdir");
2350 fs::create_dir_all(&workspace_subdir).unwrap();
2351
2352 let workspace_config = workspace_root.join(".rumdl.toml");
2353 fs::write(
2354 &workspace_config,
2355 r#"
2356[global]
2357
2358[MD013]
2359line_length = 100
2360"#,
2361 )
2362 .unwrap();
2363
2364 let server = create_test_server();
2365
2366 {
2368 let mut roots = server.workspace_roots.write().await;
2369 roots.push(workspace_root.clone());
2370 }
2371
2372 let test_file = workspace_subdir.join("deep").join("test.md");
2374 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2375 fs::write(&test_file, "# Test\n").unwrap();
2376
2377 let config = server.resolve_config_for_file(&test_file).await;
2378 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2379
2380 assert_eq!(
2382 line_length,
2383 Some(100),
2384 "Should find workspace config, not parent config outside workspace"
2385 );
2386 }
2387
2388 #[tokio::test]
2390 async fn test_config_cache_hit() {
2391 use std::fs;
2392 use tempfile::tempdir;
2393
2394 let temp_dir = tempdir().unwrap();
2395 let temp_path = temp_dir.path();
2396
2397 let project = temp_path.join("project");
2398 fs::create_dir(&project).unwrap();
2399
2400 let config_file = project.join(".rumdl.toml");
2401 fs::write(
2402 &config_file,
2403 r#"
2404[global]
2405
2406[MD013]
2407line_length = 75
2408"#,
2409 )
2410 .unwrap();
2411
2412 let server = create_test_server();
2413 {
2414 let mut roots = server.workspace_roots.write().await;
2415 roots.push(project.clone());
2416 }
2417
2418 let test_file = project.join("test.md");
2419 fs::write(&test_file, "# Test\n").unwrap();
2420
2421 let config1 = server.resolve_config_for_file(&test_file).await;
2423 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2424 assert_eq!(line_length1, Some(75));
2425
2426 {
2428 let cache = server.config_cache.read().await;
2429 let search_dir = test_file.parent().unwrap();
2430 assert!(
2431 cache.contains_key(search_dir),
2432 "Cache should be populated after first call"
2433 );
2434 }
2435
2436 let config2 = server.resolve_config_for_file(&test_file).await;
2438 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2439 assert_eq!(line_length2, Some(75));
2440 }
2441
2442 #[tokio::test]
2444 async fn test_nested_directory_config_search() {
2445 use std::fs;
2446 use tempfile::tempdir;
2447
2448 let temp_dir = tempdir().unwrap();
2449 let temp_path = temp_dir.path();
2450
2451 let project = temp_path.join("project");
2452 fs::create_dir(&project).unwrap();
2453
2454 let config = project.join(".rumdl.toml");
2456 fs::write(
2457 &config,
2458 r#"
2459[global]
2460
2461[MD013]
2462line_length = 110
2463"#,
2464 )
2465 .unwrap();
2466
2467 let deep_dir = project.join("src").join("docs").join("guides");
2469 fs::create_dir_all(&deep_dir).unwrap();
2470 let deep_file = deep_dir.join("test.md");
2471 fs::write(&deep_file, "# Test\n").unwrap();
2472
2473 let server = create_test_server();
2474 {
2475 let mut roots = server.workspace_roots.write().await;
2476 roots.push(project.clone());
2477 }
2478
2479 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2480 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2481
2482 assert_eq!(
2483 line_length,
2484 Some(110),
2485 "Should find config by searching upward from deep directory"
2486 );
2487 }
2488
2489 #[tokio::test]
2491 async fn test_fallback_to_default_config() {
2492 use std::fs;
2493 use tempfile::tempdir;
2494
2495 let temp_dir = tempdir().unwrap();
2496 let temp_path = temp_dir.path();
2497
2498 let project = temp_path.join("project");
2499 fs::create_dir(&project).unwrap();
2500
2501 let test_file = project.join("test.md");
2504 fs::write(&test_file, "# Test\n").unwrap();
2505
2506 let server = create_test_server();
2507 {
2508 let mut roots = server.workspace_roots.write().await;
2509 roots.push(project.clone());
2510 }
2511
2512 let config = server.resolve_config_for_file(&test_file).await;
2513
2514 assert_eq!(
2516 config.global.line_length.get(),
2517 80,
2518 "Should fall back to default config when no config file found"
2519 );
2520 }
2521
2522 #[tokio::test]
2524 async fn test_config_priority_closer_wins() {
2525 use std::fs;
2526 use tempfile::tempdir;
2527
2528 let temp_dir = tempdir().unwrap();
2529 let temp_path = temp_dir.path();
2530
2531 let project = temp_path.join("project");
2532 fs::create_dir(&project).unwrap();
2533
2534 let parent_config = project.join(".rumdl.toml");
2536 fs::write(
2537 &parent_config,
2538 r#"
2539[global]
2540
2541[MD013]
2542line_length = 100
2543"#,
2544 )
2545 .unwrap();
2546
2547 let subdir = project.join("subdir");
2549 fs::create_dir(&subdir).unwrap();
2550
2551 let subdir_config = subdir.join(".rumdl.toml");
2552 fs::write(
2553 &subdir_config,
2554 r#"
2555[global]
2556
2557[MD013]
2558line_length = 50
2559"#,
2560 )
2561 .unwrap();
2562
2563 let server = create_test_server();
2564 {
2565 let mut roots = server.workspace_roots.write().await;
2566 roots.push(project.clone());
2567 }
2568
2569 let test_file = subdir.join("test.md");
2571 fs::write(&test_file, "# Test\n").unwrap();
2572
2573 let config = server.resolve_config_for_file(&test_file).await;
2574 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2575
2576 assert_eq!(
2577 line_length,
2578 Some(50),
2579 "Closer config (subdir) should override parent config"
2580 );
2581 }
2582
2583 #[tokio::test]
2589 async fn test_issue_131_pyproject_without_rumdl_section() {
2590 use std::fs;
2591 use tempfile::tempdir;
2592
2593 let parent_dir = tempdir().unwrap();
2595
2596 let project_dir = parent_dir.path().join("project");
2598 fs::create_dir(&project_dir).unwrap();
2599
2600 fs::write(
2602 project_dir.join("pyproject.toml"),
2603 r#"
2604[project]
2605name = "test-project"
2606version = "0.1.0"
2607"#,
2608 )
2609 .unwrap();
2610
2611 fs::write(
2614 parent_dir.path().join(".rumdl.toml"),
2615 r#"
2616[global]
2617disable = ["MD013"]
2618"#,
2619 )
2620 .unwrap();
2621
2622 let test_file = project_dir.join("test.md");
2623 fs::write(&test_file, "# Test\n").unwrap();
2624
2625 let server = create_test_server();
2626
2627 {
2629 let mut roots = server.workspace_roots.write().await;
2630 roots.push(parent_dir.path().to_path_buf());
2631 }
2632
2633 let config = server.resolve_config_for_file(&test_file).await;
2635
2636 assert!(
2639 config.global.disable.contains(&"MD013".to_string()),
2640 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2641 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2642 );
2643
2644 let cache = server.config_cache.read().await;
2647 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2648
2649 assert!(
2650 cache_entry.config_file.is_some(),
2651 "Should have found a config file (parent .rumdl.toml)"
2652 );
2653
2654 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2655 assert!(
2656 found_config_path.ends_with(".rumdl.toml"),
2657 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2658 );
2659 assert!(
2660 found_config_path.parent().unwrap() == parent_dir.path(),
2661 "Should have loaded config from parent directory, not project_dir"
2662 );
2663 }
2664
2665 #[tokio::test]
2670 async fn test_issue_131_pyproject_with_rumdl_section() {
2671 use std::fs;
2672 use tempfile::tempdir;
2673
2674 let parent_dir = tempdir().unwrap();
2676
2677 let project_dir = parent_dir.path().join("project");
2679 fs::create_dir(&project_dir).unwrap();
2680
2681 fs::write(
2683 project_dir.join("pyproject.toml"),
2684 r#"
2685[project]
2686name = "test-project"
2687
2688[tool.rumdl.global]
2689disable = ["MD033"]
2690"#,
2691 )
2692 .unwrap();
2693
2694 fs::write(
2696 parent_dir.path().join(".rumdl.toml"),
2697 r#"
2698[global]
2699disable = ["MD041"]
2700"#,
2701 )
2702 .unwrap();
2703
2704 let test_file = project_dir.join("test.md");
2705 fs::write(&test_file, "# Test\n").unwrap();
2706
2707 let server = create_test_server();
2708
2709 {
2711 let mut roots = server.workspace_roots.write().await;
2712 roots.push(parent_dir.path().to_path_buf());
2713 }
2714
2715 let config = server.resolve_config_for_file(&test_file).await;
2717
2718 assert!(
2720 config.global.disable.contains(&"MD033".to_string()),
2721 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2722 Expected MD033 from project_dir pyproject.toml to be disabled."
2723 );
2724
2725 assert!(
2727 !config.global.disable.contains(&"MD041".to_string()),
2728 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2729 );
2730
2731 let cache = server.config_cache.read().await;
2733 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2734
2735 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2736
2737 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2738 assert!(
2739 found_config_path.ends_with("pyproject.toml"),
2740 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2741 );
2742 assert!(
2743 found_config_path.parent().unwrap() == project_dir,
2744 "Should have loaded pyproject.toml from project_dir, not parent"
2745 );
2746 }
2747
2748 #[tokio::test]
2753 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2754 use std::fs;
2755 use tempfile::tempdir;
2756
2757 let temp_dir = tempdir().unwrap();
2758
2759 fs::write(
2761 temp_dir.path().join("pyproject.toml"),
2762 r#"
2763[project]
2764name = "test-project"
2765
2766[tool.rumdl.global]
2767disable = ["MD022"]
2768"#,
2769 )
2770 .unwrap();
2771
2772 let test_file = temp_dir.path().join("test.md");
2773 fs::write(&test_file, "# Test\n").unwrap();
2774
2775 let server = create_test_server();
2776
2777 {
2779 let mut roots = server.workspace_roots.write().await;
2780 roots.push(temp_dir.path().to_path_buf());
2781 }
2782
2783 let config = server.resolve_config_for_file(&test_file).await;
2785
2786 assert!(
2788 config.global.disable.contains(&"MD022".to_string()),
2789 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2790 );
2791
2792 let cache = server.config_cache.read().await;
2794 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2795 assert!(
2796 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2797 "Should have loaded pyproject.toml"
2798 );
2799 }
2800
2801 #[tokio::test]
2806 async fn test_issue_182_pull_diagnostics_capability_default() {
2807 let server = create_test_server();
2808
2809 assert!(
2811 !*server.client_supports_pull_diagnostics.read().await,
2812 "Default should be false - push diagnostics by default"
2813 );
2814 }
2815
2816 #[tokio::test]
2818 async fn test_issue_182_pull_diagnostics_flag_update() {
2819 let server = create_test_server();
2820
2821 *server.client_supports_pull_diagnostics.write().await = true;
2823
2824 assert!(
2825 *server.client_supports_pull_diagnostics.read().await,
2826 "Flag should be settable to true"
2827 );
2828 }
2829
2830 #[tokio::test]
2834 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2835 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2836
2837 let caps_with_diagnostic = ClientCapabilities {
2839 text_document: Some(TextDocumentClientCapabilities {
2840 diagnostic: Some(DiagnosticClientCapabilities {
2841 dynamic_registration: Some(true),
2842 related_document_support: Some(false),
2843 }),
2844 ..Default::default()
2845 }),
2846 ..Default::default()
2847 };
2848
2849 let supports_pull = caps_with_diagnostic
2851 .text_document
2852 .as_ref()
2853 .and_then(|td| td.diagnostic.as_ref())
2854 .is_some();
2855
2856 assert!(supports_pull, "Should detect pull diagnostic support");
2857 }
2858
2859 #[tokio::test]
2861 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2862 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2863
2864 let caps_without_diagnostic = ClientCapabilities {
2866 text_document: Some(TextDocumentClientCapabilities {
2867 diagnostic: None, ..Default::default()
2869 }),
2870 ..Default::default()
2871 };
2872
2873 let supports_pull = caps_without_diagnostic
2875 .text_document
2876 .as_ref()
2877 .and_then(|td| td.diagnostic.as_ref())
2878 .is_some();
2879
2880 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2881 }
2882
2883 #[tokio::test]
2885 async fn test_issue_182_capability_detection_no_text_document() {
2886 use tower_lsp::lsp_types::ClientCapabilities;
2887
2888 let caps_no_text_doc = ClientCapabilities {
2890 text_document: None,
2891 ..Default::default()
2892 };
2893
2894 let supports_pull = caps_no_text_doc
2896 .text_document
2897 .as_ref()
2898 .and_then(|td| td.diagnostic.as_ref())
2899 .is_some();
2900
2901 assert!(
2902 !supports_pull,
2903 "Should NOT detect pull diagnostic support when text_document is None"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_resource_limit_constants() {
2909 assert_eq!(MAX_RULE_LIST_SIZE, 100);
2911 assert_eq!(MAX_LINE_LENGTH, 10_000);
2912 }
2913
2914 #[test]
2915 fn test_is_valid_rule_name_zero_alloc() {
2916 assert!(!RumdlLanguageServer::is_valid_rule_name("MD/01")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD:01")); assert!(!RumdlLanguageServer::is_valid_rule_name("ND001")); assert!(!RumdlLanguageServer::is_valid_rule_name("ME001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD0①1")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD001")); assert!(!RumdlLanguageServer::is_valid_rule_name("MD\x00\x00\x00")); }
2932}