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, is_valid_rule_name};
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 apply_lsp_config_overrides(
183 &self,
184 mut filtered_rules: Vec<Box<dyn Rule>>,
185 lsp_config: &RumdlLspConfig,
186 ) -> Vec<Box<dyn Rule>> {
187 let mut enable_rules: Vec<String> = Vec::new();
189 if let Some(enable) = &lsp_config.enable_rules {
190 enable_rules.extend(enable.iter().cloned());
191 }
192 if let Some(settings) = &lsp_config.settings
193 && let Some(enable) = &settings.enable
194 {
195 enable_rules.extend(enable.iter().cloned());
196 }
197
198 if !enable_rules.is_empty() {
200 let enable_set: std::collections::HashSet<String> = enable_rules.into_iter().collect();
201 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
202 }
203
204 let mut disable_rules: Vec<String> = Vec::new();
206 if let Some(disable) = &lsp_config.disable_rules {
207 disable_rules.extend(disable.iter().cloned());
208 }
209 if let Some(settings) = &lsp_config.settings
210 && let Some(disable) = &settings.disable
211 {
212 disable_rules.extend(disable.iter().cloned());
213 }
214
215 if !disable_rules.is_empty() {
217 let disable_set: std::collections::HashSet<String> = disable_rules.into_iter().collect();
218 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
219 }
220
221 filtered_rules
222 }
223
224 fn merge_lsp_settings(&self, mut file_config: Config, lsp_config: &RumdlLspConfig) -> Config {
230 let Some(settings) = &lsp_config.settings else {
231 return file_config;
232 };
233
234 match lsp_config.configuration_preference {
235 ConfigurationPreference::EditorFirst => {
236 self.apply_lsp_settings_to_config(&mut file_config, settings);
238 }
239 ConfigurationPreference::FilesystemFirst => {
240 self.apply_lsp_settings_if_absent(&mut file_config, settings);
242 }
243 ConfigurationPreference::EditorOnly => {
244 let mut default_config = Config::default();
246 self.apply_lsp_settings_to_config(&mut default_config, settings);
247 return default_config;
248 }
249 }
250
251 file_config
252 }
253
254 fn apply_lsp_settings_to_config(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
256 if let Some(line_length) = settings.line_length {
258 config.global.line_length = crate::types::LineLength::new(line_length);
259 }
260
261 if let Some(disable) = &settings.disable {
263 config.global.disable.extend(disable.iter().cloned());
264 }
265
266 if let Some(enable) = &settings.enable {
268 config.global.enable.extend(enable.iter().cloned());
269 }
270
271 for (rule_name, rule_config) in &settings.rules {
273 self.apply_rule_config(config, rule_name, rule_config);
274 }
275 }
276
277 fn apply_lsp_settings_if_absent(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
279 if config.global.line_length.get() == 80
282 && let Some(line_length) = settings.line_length
283 {
284 config.global.line_length = crate::types::LineLength::new(line_length);
285 }
286
287 if let Some(disable) = &settings.disable {
289 config.global.disable.extend(disable.iter().cloned());
290 }
291
292 if let Some(enable) = &settings.enable {
293 config.global.enable.extend(enable.iter().cloned());
294 }
295
296 for (rule_name, rule_config) in &settings.rules {
298 self.apply_rule_config_if_absent(config, rule_name, rule_config);
299 }
300 }
301
302 fn apply_rule_config(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
307 let rule_key = rule_name.to_uppercase();
308
309 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
311
312 if let Some(obj) = rule_config.as_object() {
314 for (key, value) in obj {
315 let config_key = Self::camel_to_snake(key);
317
318 if config_key == "severity" {
320 if let Some(severity_str) = value.as_str() {
321 match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
322 severity_str.to_string(),
323 )) {
324 Ok(severity) => {
325 rule_entry.severity = Some(severity);
326 }
327 Err(_) => {
328 log::warn!(
329 "Invalid severity '{severity_str}' for rule {rule_key}. \
330 Valid values: error, warning, info"
331 );
332 }
333 }
334 }
335 continue;
336 }
337
338 if let Some(toml_value) = Self::json_to_toml(value) {
340 rule_entry.values.insert(config_key, toml_value);
341 }
342 }
343 }
344 }
345
346 fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
354 let rule_key = rule_name.to_uppercase();
355
356 let existing_rule = config.rules.get(&rule_key);
358 let has_existing_values = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
359 let has_existing_severity = existing_rule.and_then(|r| r.severity).is_some();
360
361 if let Some(obj) = rule_config.as_object() {
363 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
364
365 for (key, value) in obj {
366 let config_key = Self::camel_to_snake(key);
367
368 if config_key == "severity" {
370 if !has_existing_severity && let Some(severity_str) = value.as_str() {
371 match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
372 severity_str.to_string(),
373 )) {
374 Ok(severity) => {
375 rule_entry.severity = Some(severity);
376 }
377 Err(_) => {
378 log::warn!(
379 "Invalid severity '{severity_str}' for rule {rule_key}. \
380 Valid values: error, warning, info"
381 );
382 }
383 }
384 }
385 continue;
386 }
387
388 if !has_existing_values && let Some(toml_value) = Self::json_to_toml(value) {
390 rule_entry.values.insert(config_key, toml_value);
391 }
392 }
393 }
394 }
395
396 fn camel_to_snake(s: &str) -> String {
398 let mut result = String::new();
399 for (i, c) in s.chars().enumerate() {
400 if c.is_uppercase() && i > 0 {
401 result.push('_');
402 }
403 result.push(c.to_lowercase().next().unwrap_or(c));
404 }
405 result
406 }
407
408 fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
410 match json {
411 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
412 serde_json::Value::Number(n) => {
413 if let Some(i) = n.as_i64() {
414 Some(toml::Value::Integer(i))
415 } else {
416 n.as_f64().map(toml::Value::Float)
417 }
418 }
419 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
420 serde_json::Value::Array(arr) => {
421 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
422 Some(toml::Value::Array(toml_arr))
423 }
424 serde_json::Value::Object(obj) => {
425 let mut table = toml::map::Map::new();
426 for (k, v) in obj {
427 if let Some(toml_v) = Self::json_to_toml(v) {
428 table.insert(Self::camel_to_snake(k), toml_v);
429 }
430 }
431 Some(toml::Value::Table(table))
432 }
433 serde_json::Value::Null => None,
434 }
435 }
436
437 async fn should_exclude_uri(&self, uri: &Url) -> bool {
439 let file_path = match uri.to_file_path() {
441 Ok(path) => path,
442 Err(_) => return false, };
444
445 let rumdl_config = self.resolve_config_for_file(&file_path).await;
447 let exclude_patterns = &rumdl_config.global.exclude;
448
449 if exclude_patterns.is_empty() {
451 return false;
452 }
453
454 let path_to_check = if file_path.is_absolute() {
457 if let Ok(cwd) = std::env::current_dir() {
459 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
461 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
462 relative.to_string_lossy().to_string()
463 } else {
464 file_path.to_string_lossy().to_string()
466 }
467 } else {
468 file_path.to_string_lossy().to_string()
470 }
471 } else {
472 file_path.to_string_lossy().to_string()
473 }
474 } else {
475 file_path.to_string_lossy().to_string()
477 };
478
479 for pattern in exclude_patterns {
481 if let Ok(glob) = globset::Glob::new(pattern) {
482 let matcher = glob.compile_matcher();
483 if matcher.is_match(&path_to_check) {
484 log::debug!("Excluding file from LSP linting: {path_to_check}");
485 return true;
486 }
487 }
488 }
489
490 false
491 }
492
493 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
495 let config_guard = self.config.read().await;
496
497 if !config_guard.enable_linting {
499 return Ok(Vec::new());
500 }
501
502 let lsp_config = config_guard.clone();
503 drop(config_guard); if self.should_exclude_uri(uri).await {
507 return Ok(Vec::new());
508 }
509
510 let file_path = uri.to_file_path().ok();
512 let file_config = if let Some(ref path) = file_path {
513 self.resolve_config_for_file(path).await
514 } else {
515 (*self.rumdl_config.read().await).clone()
517 };
518
519 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
521
522 let all_rules = rules::all_rules(&rumdl_config);
523 let flavor = rumdl_config.markdown_flavor();
524
525 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
527
528 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
530
531 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
533 Ok(warnings) => warnings,
534 Err(e) => {
535 log::error!("Failed to lint document {uri}: {e}");
536 return Ok(Vec::new());
537 }
538 };
539
540 if let Some(ref path) = file_path {
542 let index_state = self.index_state.read().await.clone();
543 if matches!(index_state, IndexState::Ready) {
544 let workspace_index = self.workspace_index.read().await;
545 if let Some(file_index) = workspace_index.get_file(path) {
546 match crate::run_cross_file_checks(
547 path,
548 file_index,
549 &filtered_rules,
550 &workspace_index,
551 Some(&rumdl_config),
552 ) {
553 Ok(cross_file_warnings) => {
554 all_warnings.extend(cross_file_warnings);
555 }
556 Err(e) => {
557 log::warn!("Failed to run cross-file checks for {uri}: {e}");
558 }
559 }
560 }
561 }
562 }
563
564 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
565 Ok(diagnostics)
566 }
567
568 async fn update_diagnostics(&self, uri: Url, text: String) {
574 if *self.client_supports_pull_diagnostics.read().await {
576 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
577 return;
578 }
579
580 let version = {
582 let docs = self.documents.read().await;
583 docs.get(&uri).and_then(|entry| entry.version)
584 };
585
586 match self.lint_document(&uri, &text).await {
587 Ok(diagnostics) => {
588 self.client.publish_diagnostics(uri, diagnostics, version).await;
589 }
590 Err(e) => {
591 log::error!("Failed to update diagnostics: {e}");
592 }
593 }
594 }
595
596 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
598 if self.should_exclude_uri(uri).await {
600 return Ok(None);
601 }
602
603 let config_guard = self.config.read().await;
604 let lsp_config = config_guard.clone();
605 drop(config_guard);
606
607 let file_config = if let Ok(file_path) = uri.to_file_path() {
609 self.resolve_config_for_file(&file_path).await
610 } else {
611 (*self.rumdl_config.read().await).clone()
613 };
614
615 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
617
618 let all_rules = rules::all_rules(&rumdl_config);
619 let flavor = rumdl_config.markdown_flavor();
620
621 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
623
624 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
626
627 let mut rules_with_warnings = std::collections::HashSet::new();
630 let mut fixed_text = text.to_string();
631
632 match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
633 Ok(warnings) => {
634 for warning in warnings {
635 if let Some(rule_name) = &warning.rule_name {
636 rules_with_warnings.insert(rule_name.clone());
637 }
638 }
639 }
640 Err(e) => {
641 log::warn!("Failed to lint document for auto-fix: {e}");
642 return Ok(None);
643 }
644 }
645
646 if rules_with_warnings.is_empty() {
648 return Ok(None);
649 }
650
651 let mut any_changes = false;
653
654 for rule in &filtered_rules {
655 if !rules_with_warnings.contains(rule.name()) {
657 continue;
658 }
659
660 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
661 match rule.fix(&ctx) {
662 Ok(new_text) => {
663 if new_text != fixed_text {
664 fixed_text = new_text;
665 any_changes = true;
666 }
667 }
668 Err(e) => {
669 let msg = e.to_string();
671 if !msg.contains("does not support automatic fixing") {
672 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
673 }
674 }
675 }
676 }
677
678 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
679 }
680
681 fn get_end_position(&self, text: &str) -> Position {
683 let mut line = 0u32;
684 let mut character = 0u32;
685
686 for ch in text.chars() {
687 if ch == '\n' {
688 line += 1;
689 character = 0;
690 } else {
691 character += 1;
692 }
693 }
694
695 Position { line, character }
696 }
697
698 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
700 let config_guard = self.config.read().await;
701 let lsp_config = config_guard.clone();
702 drop(config_guard);
703
704 let file_config = if let Ok(file_path) = uri.to_file_path() {
706 self.resolve_config_for_file(&file_path).await
707 } else {
708 (*self.rumdl_config.read().await).clone()
710 };
711
712 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
714
715 let all_rules = rules::all_rules(&rumdl_config);
716 let flavor = rumdl_config.markdown_flavor();
717
718 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
720
721 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
723
724 match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
725 Ok(warnings) => {
726 let mut actions = Vec::new();
727 let mut fixable_count = 0;
728
729 for warning in &warnings {
730 let warning_line = (warning.line.saturating_sub(1)) as u32;
732 if warning_line >= range.start.line && warning_line <= range.end.line {
733 let mut warning_actions = warning_to_code_actions(warning, uri, text);
735 actions.append(&mut warning_actions);
736
737 if warning.fix.is_some() {
738 fixable_count += 1;
739 }
740 }
741 }
742
743 if fixable_count > 1 {
745 let fixable_warnings: Vec<_> = warnings
748 .iter()
749 .filter(|w| {
750 if let Some(rule_name) = &w.rule_name {
751 filtered_rules
752 .iter()
753 .find(|r| r.name() == rule_name)
754 .map(|r| r.fix_capability() != FixCapability::Unfixable)
755 .unwrap_or(false)
756 } else {
757 false
758 }
759 })
760 .cloned()
761 .collect();
762
763 let total_fixable = fixable_warnings.len();
765
766 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
767 && fixed_content != text
768 {
769 let mut line = 0u32;
771 let mut character = 0u32;
772 for ch in text.chars() {
773 if ch == '\n' {
774 line += 1;
775 character = 0;
776 } else {
777 character += 1;
778 }
779 }
780
781 let fix_all_action = CodeAction {
782 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
783 kind: Some(CodeActionKind::QUICKFIX),
784 diagnostics: Some(Vec::new()),
785 edit: Some(WorkspaceEdit {
786 changes: Some(
787 [(
788 uri.clone(),
789 vec![TextEdit {
790 range: Range {
791 start: Position { line: 0, character: 0 },
792 end: Position { line, character },
793 },
794 new_text: fixed_content,
795 }],
796 )]
797 .into_iter()
798 .collect(),
799 ),
800 ..Default::default()
801 }),
802 command: None,
803 is_preferred: Some(true),
804 disabled: None,
805 data: None,
806 };
807
808 actions.insert(0, fix_all_action);
810 }
811 }
812
813 Ok(actions)
814 }
815 Err(e) => {
816 log::error!("Failed to get code actions: {e}");
817 Ok(Vec::new())
818 }
819 }
820 }
821
822 async fn load_configuration(&self, notify_client: bool) {
824 let config_guard = self.config.read().await;
825 let explicit_config_path = config_guard.config_path.clone();
826 drop(config_guard);
827
828 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
830 Ok(sourced_config) => {
831 let loaded_files = sourced_config.loaded_files.clone();
832 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
834
835 if !loaded_files.is_empty() {
836 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
837 log::info!("{message}");
838 if notify_client {
839 self.client.log_message(MessageType::INFO, &message).await;
840 }
841 } else {
842 log::info!("Using default rumdl configuration (no config files found)");
843 }
844 }
845 Err(e) => {
846 let message = format!("Failed to load rumdl config: {e}");
847 log::warn!("{message}");
848 if notify_client {
849 self.client.log_message(MessageType::WARNING, &message).await;
850 }
851 *self.rumdl_config.write().await = crate::config::Config::default();
853 }
854 }
855 }
856
857 async fn reload_configuration(&self) {
859 self.load_configuration(true).await;
860 }
861
862 fn load_config_for_lsp(
864 config_path: Option<&str>,
865 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
866 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
868 }
869
870 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
877 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
879
880 {
882 let cache = self.config_cache.read().await;
883 if let Some(entry) = cache.get(&search_dir) {
884 let source_owned: String; let source: &str = if entry.from_global_fallback {
886 "global/user fallback"
887 } else if let Some(path) = &entry.config_file {
888 source_owned = path.to_string_lossy().to_string();
889 &source_owned
890 } else {
891 "<unknown>"
892 };
893 log::debug!(
894 "Config cache hit for directory: {} (loaded from: {})",
895 search_dir.display(),
896 source
897 );
898 return entry.config.clone();
899 }
900 }
901
902 log::debug!(
904 "Config cache miss for directory: {}, searching for config...",
905 search_dir.display()
906 );
907
908 let workspace_root = {
910 let workspace_roots = self.workspace_roots.read().await;
911 workspace_roots
912 .iter()
913 .find(|root| search_dir.starts_with(root))
914 .map(|p| p.to_path_buf())
915 };
916
917 let mut current_dir = search_dir.clone();
919 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
920
921 loop {
922 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
924
925 for config_file_name in CONFIG_FILES {
926 let config_path = current_dir.join(config_file_name);
927 if config_path.exists() {
928 if *config_file_name == "pyproject.toml" {
930 if let Ok(content) = std::fs::read_to_string(&config_path) {
931 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
932 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
933 } else {
934 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
935 continue;
936 }
937 } else {
938 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
939 continue;
940 }
941 } else {
942 log::debug!("Found config file: {}", config_path.display());
943 }
944
945 if let Some(config_path_str) = config_path.to_str() {
947 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
948 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
949 break;
950 }
951 } else {
952 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
953 }
954 }
955 }
956
957 if found_config.is_some() {
958 break;
959 }
960
961 if let Some(ref root) = workspace_root
963 && ¤t_dir == root
964 {
965 log::debug!("Hit workspace root without finding config: {}", root.display());
966 break;
967 }
968
969 if let Some(parent) = current_dir.parent() {
971 current_dir = parent.to_path_buf();
972 } else {
973 break;
975 }
976 }
977
978 let (config, config_file) = if let Some((cfg, path)) = found_config {
980 (cfg, path)
981 } else {
982 log::debug!("No project config found; using global/user fallback config");
983 let fallback = self.rumdl_config.read().await.clone();
984 (fallback, None)
985 };
986
987 let from_global = config_file.is_none();
989 let entry = ConfigCacheEntry {
990 config: config.clone(),
991 config_file,
992 from_global_fallback: from_global,
993 };
994
995 self.config_cache.write().await.insert(search_dir, entry);
996
997 config
998 }
999}
1000
1001#[tower_lsp::async_trait]
1002impl LanguageServer for RumdlLanguageServer {
1003 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1004 log::info!("Initializing rumdl Language Server");
1005
1006 if let Some(options) = params.initialization_options
1008 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
1009 {
1010 *self.config.write().await = config;
1011 }
1012
1013 let supports_pull = params
1016 .capabilities
1017 .text_document
1018 .as_ref()
1019 .and_then(|td| td.diagnostic.as_ref())
1020 .is_some();
1021
1022 if supports_pull {
1023 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1024 *self.client_supports_pull_diagnostics.write().await = true;
1025 } else {
1026 log::info!("Client does not support pull diagnostics - using push model");
1027 }
1028
1029 let mut roots = Vec::new();
1031 if let Some(workspace_folders) = params.workspace_folders {
1032 for folder in workspace_folders {
1033 if let Ok(path) = folder.uri.to_file_path() {
1034 log::info!("Workspace root: {}", path.display());
1035 roots.push(path);
1036 }
1037 }
1038 } else if let Some(root_uri) = params.root_uri
1039 && let Ok(path) = root_uri.to_file_path()
1040 {
1041 log::info!("Workspace root: {}", path.display());
1042 roots.push(path);
1043 }
1044 *self.workspace_roots.write().await = roots;
1045
1046 self.load_configuration(false).await;
1048
1049 Ok(InitializeResult {
1050 capabilities: ServerCapabilities {
1051 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1052 open_close: Some(true),
1053 change: Some(TextDocumentSyncKind::FULL),
1054 will_save: Some(false),
1055 will_save_wait_until: Some(true),
1056 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1057 include_text: Some(false),
1058 })),
1059 })),
1060 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1061 document_formatting_provider: Some(OneOf::Left(true)),
1062 document_range_formatting_provider: Some(OneOf::Left(true)),
1063 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1064 identifier: Some("rumdl".to_string()),
1065 inter_file_dependencies: true,
1066 workspace_diagnostics: true,
1067 work_done_progress_options: WorkDoneProgressOptions::default(),
1068 })),
1069 workspace: Some(WorkspaceServerCapabilities {
1070 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1071 supported: Some(true),
1072 change_notifications: Some(OneOf::Left(true)),
1073 }),
1074 file_operations: None,
1075 }),
1076 ..Default::default()
1077 },
1078 server_info: Some(ServerInfo {
1079 name: "rumdl".to_string(),
1080 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1081 }),
1082 })
1083 }
1084
1085 async fn initialized(&self, _: InitializedParams) {
1086 let version = env!("CARGO_PKG_VERSION");
1087
1088 let (binary_path, build_time) = std::env::current_exe()
1090 .ok()
1091 .map(|path| {
1092 let path_str = path.to_str().unwrap_or("unknown").to_string();
1093 let build_time = std::fs::metadata(&path)
1094 .ok()
1095 .and_then(|metadata| metadata.modified().ok())
1096 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1097 .and_then(|duration| {
1098 let secs = duration.as_secs();
1099 chrono::DateTime::from_timestamp(secs as i64, 0)
1100 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1101 })
1102 .unwrap_or_else(|| "unknown".to_string());
1103 (path_str, build_time)
1104 })
1105 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1106
1107 let working_dir = std::env::current_dir()
1108 .ok()
1109 .and_then(|p| p.to_str().map(|s| s.to_string()))
1110 .unwrap_or_else(|| "unknown".to_string());
1111
1112 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1113 log::info!("Working directory: {working_dir}");
1114
1115 self.client
1116 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1117 .await;
1118
1119 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1121 log::warn!("Failed to trigger initial workspace indexing");
1122 } else {
1123 log::info!("Triggered initial workspace indexing for cross-file analysis");
1124 }
1125
1126 let markdown_patterns = [
1129 "**/*.md",
1130 "**/*.markdown",
1131 "**/*.mdx",
1132 "**/*.mkd",
1133 "**/*.mkdn",
1134 "**/*.mdown",
1135 "**/*.mdwn",
1136 "**/*.qmd",
1137 "**/*.rmd",
1138 ];
1139 let watchers: Vec<_> = markdown_patterns
1140 .iter()
1141 .map(|pattern| FileSystemWatcher {
1142 glob_pattern: GlobPattern::String((*pattern).to_string()),
1143 kind: Some(WatchKind::all()),
1144 })
1145 .collect();
1146
1147 let registration = Registration {
1148 id: "markdown-watcher".to_string(),
1149 method: "workspace/didChangeWatchedFiles".to_string(),
1150 register_options: Some(
1151 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1152 ),
1153 };
1154
1155 if self.client.register_capability(vec![registration]).await.is_err() {
1156 log::debug!("Client does not support file watching capability");
1157 }
1158 }
1159
1160 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1161 let mut roots = self.workspace_roots.write().await;
1163
1164 for removed in ¶ms.event.removed {
1166 if let Ok(path) = removed.uri.to_file_path() {
1167 roots.retain(|r| r != &path);
1168 log::info!("Removed workspace root: {}", path.display());
1169 }
1170 }
1171
1172 for added in ¶ms.event.added {
1174 if let Ok(path) = added.uri.to_file_path()
1175 && !roots.contains(&path)
1176 {
1177 log::info!("Added workspace root: {}", path.display());
1178 roots.push(path);
1179 }
1180 }
1181 drop(roots);
1182
1183 self.config_cache.write().await.clear();
1185
1186 self.reload_configuration().await;
1188
1189 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1191 log::warn!("Failed to trigger workspace rescan after folder change");
1192 }
1193 }
1194
1195 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1196 log::debug!("Configuration changed: {:?}", params.settings);
1197
1198 let settings_value = params.settings;
1202
1203 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1205 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1206 } else {
1207 settings_value
1208 };
1209
1210 let mut config_applied = false;
1212 let mut warnings: Vec<String> = Vec::new();
1213
1214 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1218 && (rule_settings.disable.is_some()
1219 || rule_settings.enable.is_some()
1220 || rule_settings.line_length.is_some()
1221 || !rule_settings.rules.is_empty())
1222 {
1223 if let Some(ref disable) = rule_settings.disable {
1225 for rule in disable {
1226 if !is_valid_rule_name(rule) {
1227 warnings.push(format!("Unknown rule in disable list: {rule}"));
1228 }
1229 }
1230 }
1231 if let Some(ref enable) = rule_settings.enable {
1232 for rule in enable {
1233 if !is_valid_rule_name(rule) {
1234 warnings.push(format!("Unknown rule in enable list: {rule}"));
1235 }
1236 }
1237 }
1238 for rule_name in rule_settings.rules.keys() {
1240 if !is_valid_rule_name(rule_name) {
1241 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1242 }
1243 }
1244
1245 log::info!("Applied rule settings from configuration (Neovim style)");
1246 let mut config = self.config.write().await;
1247 config.settings = Some(rule_settings);
1248 drop(config);
1249 config_applied = true;
1250 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1251 && (full_config.config_path.is_some()
1252 || full_config.enable_rules.is_some()
1253 || full_config.disable_rules.is_some()
1254 || full_config.settings.is_some()
1255 || !full_config.enable_linting
1256 || full_config.enable_auto_fix)
1257 {
1258 if let Some(ref rules) = full_config.enable_rules {
1260 for rule in rules {
1261 if !is_valid_rule_name(rule) {
1262 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1263 }
1264 }
1265 }
1266 if let Some(ref rules) = full_config.disable_rules {
1267 for rule in rules {
1268 if !is_valid_rule_name(rule) {
1269 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1270 }
1271 }
1272 }
1273
1274 log::info!("Applied full LSP configuration from settings");
1275 *self.config.write().await = full_config;
1276 config_applied = true;
1277 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1278 let mut config = self.config.write().await;
1281
1282 let mut rules = std::collections::HashMap::new();
1284 let mut disable = Vec::new();
1285 let mut enable = Vec::new();
1286 let mut line_length = None;
1287
1288 for (key, value) in obj {
1289 match key.as_str() {
1290 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1291 Ok(d) => {
1292 if d.len() > MAX_RULE_LIST_SIZE {
1293 warnings.push(format!(
1294 "Too many rules in 'disable' ({} > {}), truncating",
1295 d.len(),
1296 MAX_RULE_LIST_SIZE
1297 ));
1298 }
1299 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1300 if !is_valid_rule_name(rule) {
1301 warnings.push(format!("Unknown rule in disable: {rule}"));
1302 }
1303 }
1304 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1305 }
1306 Err(_) => {
1307 warnings.push(format!(
1308 "Invalid 'disable' value: expected array of strings, got {value}"
1309 ));
1310 }
1311 },
1312 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1313 Ok(e) => {
1314 if e.len() > MAX_RULE_LIST_SIZE {
1315 warnings.push(format!(
1316 "Too many rules in 'enable' ({} > {}), truncating",
1317 e.len(),
1318 MAX_RULE_LIST_SIZE
1319 ));
1320 }
1321 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1322 if !is_valid_rule_name(rule) {
1323 warnings.push(format!("Unknown rule in enable: {rule}"));
1324 }
1325 }
1326 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1327 }
1328 Err(_) => {
1329 warnings.push(format!(
1330 "Invalid 'enable' value: expected array of strings, got {value}"
1331 ));
1332 }
1333 },
1334 "lineLength" | "line_length" | "line-length" => {
1335 if let Some(l) = value.as_u64() {
1336 match usize::try_from(l) {
1337 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1338 Ok(len) => warnings.push(format!(
1339 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1340 )),
1341 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1342 }
1343 } else {
1344 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1345 }
1346 }
1347 _ if key.starts_with("MD") || key.starts_with("md") => {
1349 let normalized = key.to_uppercase();
1350 if !is_valid_rule_name(&normalized) {
1351 warnings.push(format!("Unknown rule: {key}"));
1352 }
1353 rules.insert(normalized, value);
1354 }
1355 _ => {
1356 warnings.push(format!("Unknown configuration key: {key}"));
1358 }
1359 }
1360 }
1361
1362 let settings = LspRuleSettings {
1363 line_length,
1364 disable: if disable.is_empty() { None } else { Some(disable) },
1365 enable: if enable.is_empty() { None } else { Some(enable) },
1366 rules,
1367 };
1368
1369 log::info!("Applied Neovim-style rule settings (manual parse)");
1370 config.settings = Some(settings);
1371 drop(config);
1372 config_applied = true;
1373 } else {
1374 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1375 }
1376
1377 for warning in &warnings {
1379 log::warn!("{warning}");
1380 }
1381
1382 if !warnings.is_empty() {
1384 let message = if warnings.len() == 1 {
1385 format!("rumdl: {}", warnings[0])
1386 } else {
1387 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1388 };
1389 self.client.log_message(MessageType::WARNING, message).await;
1390 }
1391
1392 if !config_applied {
1393 log::debug!("No configuration changes applied");
1394 }
1395
1396 self.config_cache.write().await.clear();
1398
1399 let doc_list: Vec<_> = {
1401 let documents = self.documents.read().await;
1402 documents
1403 .iter()
1404 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1405 .collect()
1406 };
1407
1408 let tasks = doc_list.into_iter().map(|(uri, text)| {
1410 let server = self.clone();
1411 tokio::spawn(async move {
1412 server.update_diagnostics(uri, text).await;
1413 })
1414 });
1415
1416 let _ = join_all(tasks).await;
1418 }
1419
1420 async fn shutdown(&self) -> JsonRpcResult<()> {
1421 log::info!("Shutting down rumdl Language Server");
1422
1423 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1425
1426 Ok(())
1427 }
1428
1429 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1430 let uri = params.text_document.uri;
1431 let text = params.text_document.text;
1432 let version = params.text_document.version;
1433
1434 let entry = DocumentEntry {
1435 content: text.clone(),
1436 version: Some(version),
1437 from_disk: false,
1438 };
1439 self.documents.write().await.insert(uri.clone(), entry);
1440
1441 if let Ok(path) = uri.to_file_path() {
1443 let _ = self
1444 .update_tx
1445 .send(IndexUpdate::FileChanged {
1446 path,
1447 content: text.clone(),
1448 })
1449 .await;
1450 }
1451
1452 self.update_diagnostics(uri, text).await;
1453 }
1454
1455 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1456 let uri = params.text_document.uri;
1457 let version = params.text_document.version;
1458
1459 if let Some(change) = params.content_changes.into_iter().next() {
1460 let text = change.text;
1461
1462 let entry = DocumentEntry {
1463 content: text.clone(),
1464 version: Some(version),
1465 from_disk: false,
1466 };
1467 self.documents.write().await.insert(uri.clone(), entry);
1468
1469 if let Ok(path) = uri.to_file_path() {
1471 let _ = self
1472 .update_tx
1473 .send(IndexUpdate::FileChanged {
1474 path,
1475 content: text.clone(),
1476 })
1477 .await;
1478 }
1479
1480 self.update_diagnostics(uri, text).await;
1481 }
1482 }
1483
1484 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1485 let config_guard = self.config.read().await;
1486 let enable_auto_fix = config_guard.enable_auto_fix;
1487 drop(config_guard);
1488
1489 if !enable_auto_fix {
1490 return Ok(None);
1491 }
1492
1493 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1495 return Ok(None);
1496 };
1497
1498 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1500 Ok(Some(fixed_text)) => {
1501 Ok(Some(vec![TextEdit {
1503 range: Range {
1504 start: Position { line: 0, character: 0 },
1505 end: self.get_end_position(&text),
1506 },
1507 new_text: fixed_text,
1508 }]))
1509 }
1510 Ok(None) => Ok(None),
1511 Err(e) => {
1512 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1513 Ok(None)
1514 }
1515 }
1516 }
1517
1518 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1519 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1522 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1523 .await;
1524 }
1525 }
1526
1527 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1528 self.documents.write().await.remove(¶ms.text_document.uri);
1530
1531 self.client
1534 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1535 .await;
1536 }
1537
1538 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1539 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1541
1542 let mut config_changed = false;
1543
1544 for change in ¶ms.changes {
1545 if let Ok(path) = change.uri.to_file_path() {
1546 let file_name = path.file_name().and_then(|f| f.to_str());
1547 let extension = path.extension().and_then(|e| e.to_str());
1548
1549 if let Some(name) = file_name
1551 && CONFIG_FILES.contains(&name)
1552 && !config_changed
1553 {
1554 log::info!("Config file changed: {}, invalidating config cache", path.display());
1555
1556 let mut cache = self.config_cache.write().await;
1558 cache.retain(|_, entry| {
1559 if let Some(config_file) = &entry.config_file {
1560 config_file != &path
1561 } else {
1562 true
1563 }
1564 });
1565
1566 drop(cache);
1568 self.reload_configuration().await;
1569 config_changed = true;
1570 }
1571
1572 if let Some(ext) = extension
1574 && is_markdown_extension(ext)
1575 {
1576 match change.typ {
1577 FileChangeType::CREATED | FileChangeType::CHANGED => {
1578 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1580 let _ = self
1581 .update_tx
1582 .send(IndexUpdate::FileChanged {
1583 path: path.clone(),
1584 content,
1585 })
1586 .await;
1587 }
1588 }
1589 FileChangeType::DELETED => {
1590 let _ = self
1591 .update_tx
1592 .send(IndexUpdate::FileDeleted { path: path.clone() })
1593 .await;
1594 }
1595 _ => {}
1596 }
1597 }
1598 }
1599 }
1600
1601 if config_changed {
1603 let docs_to_update: Vec<(Url, String)> = {
1604 let docs = self.documents.read().await;
1605 docs.iter()
1606 .filter(|(_, entry)| !entry.from_disk)
1607 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1608 .collect()
1609 };
1610
1611 for (uri, text) in docs_to_update {
1612 self.update_diagnostics(uri, text).await;
1613 }
1614 }
1615 }
1616
1617 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1618 let uri = params.text_document.uri;
1619 let range = params.range;
1620
1621 if let Some(text) = self.get_document_content(&uri).await {
1622 match self.get_code_actions(&uri, &text, range).await {
1623 Ok(actions) => {
1624 let response: Vec<CodeActionOrCommand> =
1625 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1626 Ok(Some(response))
1627 }
1628 Err(e) => {
1629 log::error!("Failed to get code actions: {e}");
1630 Ok(None)
1631 }
1632 }
1633 } else {
1634 Ok(None)
1635 }
1636 }
1637
1638 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1639 log::debug!(
1644 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1645 params.range
1646 );
1647
1648 let formatting_params = DocumentFormattingParams {
1649 text_document: params.text_document,
1650 options: params.options,
1651 work_done_progress_params: params.work_done_progress_params,
1652 };
1653
1654 self.formatting(formatting_params).await
1655 }
1656
1657 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1658 let uri = params.text_document.uri;
1659
1660 log::debug!("Formatting request for: {uri}");
1661
1662 if let Some(text) = self.get_document_content(&uri).await {
1663 let config_guard = self.config.read().await;
1665 let lsp_config = config_guard.clone();
1666 drop(config_guard);
1667
1668 let file_config = if let Ok(file_path) = uri.to_file_path() {
1670 self.resolve_config_for_file(&file_path).await
1671 } else {
1672 self.rumdl_config.read().await.clone()
1674 };
1675
1676 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1678
1679 let all_rules = rules::all_rules(&rumdl_config);
1680 let flavor = rumdl_config.markdown_flavor();
1681
1682 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1684
1685 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1687
1688 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1690 Ok(warnings) => {
1691 log::debug!(
1692 "Found {} warnings, {} with fixes",
1693 warnings.len(),
1694 warnings.iter().filter(|w| w.fix.is_some()).count()
1695 );
1696
1697 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1698 if has_fixes {
1699 let fixable_warnings: Vec<_> = warnings
1703 .iter()
1704 .filter(|w| {
1705 if let Some(rule_name) = &w.rule_name {
1706 filtered_rules
1707 .iter()
1708 .find(|r| r.name() == rule_name)
1709 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1710 .unwrap_or(false)
1711 } else {
1712 false
1713 }
1714 })
1715 .cloned()
1716 .collect();
1717
1718 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1719 Ok(fixed_content) => {
1720 if fixed_content != text {
1721 log::debug!("Returning formatting edits");
1722 let end_position = self.get_end_position(&text);
1723 let edit = TextEdit {
1724 range: Range {
1725 start: Position { line: 0, character: 0 },
1726 end: end_position,
1727 },
1728 new_text: fixed_content,
1729 };
1730 return Ok(Some(vec![edit]));
1731 }
1732 }
1733 Err(e) => {
1734 log::error!("Failed to apply fixes: {e}");
1735 }
1736 }
1737 }
1738 Ok(Some(Vec::new()))
1739 }
1740 Err(e) => {
1741 log::error!("Failed to format document: {e}");
1742 Ok(Some(Vec::new()))
1743 }
1744 }
1745 } else {
1746 log::warn!("Document not found: {uri}");
1747 Ok(None)
1748 }
1749 }
1750
1751 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1752 let uri = params.text_document.uri;
1753
1754 if let Some(text) = self.get_document_content(&uri).await {
1755 match self.lint_document(&uri, &text).await {
1756 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1757 RelatedFullDocumentDiagnosticReport {
1758 related_documents: None,
1759 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1760 result_id: None,
1761 items: diagnostics,
1762 },
1763 },
1764 ))),
1765 Err(e) => {
1766 log::error!("Failed to get diagnostics: {e}");
1767 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1768 RelatedFullDocumentDiagnosticReport {
1769 related_documents: None,
1770 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1771 result_id: None,
1772 items: Vec::new(),
1773 },
1774 },
1775 )))
1776 }
1777 }
1778 } else {
1779 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1780 RelatedFullDocumentDiagnosticReport {
1781 related_documents: None,
1782 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1783 result_id: None,
1784 items: Vec::new(),
1785 },
1786 },
1787 )))
1788 }
1789 }
1790}
1791
1792#[cfg(test)]
1793mod tests {
1794 use super::*;
1795 use crate::rule::LintWarning;
1796 use tower_lsp::LspService;
1797
1798 fn create_test_server() -> RumdlLanguageServer {
1799 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1800 service.inner().clone()
1801 }
1802
1803 #[test]
1804 fn test_is_valid_rule_name() {
1805 assert!(is_valid_rule_name("MD001"));
1807 assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
1811 assert!(is_valid_rule_name("MD005"));
1812 assert!(is_valid_rule_name("MD007"));
1813 assert!(is_valid_rule_name("MD009"));
1814 assert!(is_valid_rule_name("MD041"));
1815 assert!(is_valid_rule_name("MD060"));
1816 assert!(is_valid_rule_name("MD061"));
1817
1818 assert!(is_valid_rule_name("all"));
1820 assert!(is_valid_rule_name("ALL"));
1821 assert!(is_valid_rule_name("All"));
1822
1823 assert!(is_valid_rule_name("line-length")); assert!(is_valid_rule_name("LINE-LENGTH")); assert!(is_valid_rule_name("heading-increment")); assert!(is_valid_rule_name("no-bare-urls")); assert!(is_valid_rule_name("ul-style")); assert!(is_valid_rule_name("ul_style")); assert!(!is_valid_rule_name("MD000")); assert!(!is_valid_rule_name("MD999")); assert!(!is_valid_rule_name("MD100")); assert!(!is_valid_rule_name("INVALID"));
1836 assert!(!is_valid_rule_name("not-a-rule"));
1837 assert!(!is_valid_rule_name(""));
1838 assert!(!is_valid_rule_name("random-text"));
1839 }
1840
1841 #[tokio::test]
1842 async fn test_server_creation() {
1843 let server = create_test_server();
1844
1845 let config = server.config.read().await;
1847 assert!(config.enable_linting);
1848 assert!(!config.enable_auto_fix);
1849 }
1850
1851 #[tokio::test]
1852 async fn test_lint_document() {
1853 let server = create_test_server();
1854
1855 let uri = Url::parse("file:///test.md").unwrap();
1857 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1858
1859 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1860
1861 assert!(!diagnostics.is_empty());
1863 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1864 }
1865
1866 #[tokio::test]
1867 async fn test_lint_document_disabled() {
1868 let server = create_test_server();
1869
1870 server.config.write().await.enable_linting = false;
1872
1873 let uri = Url::parse("file:///test.md").unwrap();
1874 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1875
1876 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1877
1878 assert!(diagnostics.is_empty());
1880 }
1881
1882 #[tokio::test]
1883 async fn test_get_code_actions() {
1884 let server = create_test_server();
1885
1886 let uri = Url::parse("file:///test.md").unwrap();
1887 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1888
1889 let range = Range {
1891 start: Position { line: 0, character: 0 },
1892 end: Position { line: 3, character: 21 },
1893 };
1894
1895 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1896
1897 assert!(!actions.is_empty());
1899 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1900 }
1901
1902 #[tokio::test]
1903 async fn test_get_code_actions_outside_range() {
1904 let server = create_test_server();
1905
1906 let uri = Url::parse("file:///test.md").unwrap();
1907 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1908
1909 let range = Range {
1911 start: Position { line: 0, character: 0 },
1912 end: Position { line: 0, character: 6 },
1913 };
1914
1915 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1916
1917 assert!(actions.is_empty());
1919 }
1920
1921 #[tokio::test]
1922 async fn test_document_storage() {
1923 let server = create_test_server();
1924
1925 let uri = Url::parse("file:///test.md").unwrap();
1926 let text = "# Test Document";
1927
1928 let entry = DocumentEntry {
1930 content: text.to_string(),
1931 version: Some(1),
1932 from_disk: false,
1933 };
1934 server.documents.write().await.insert(uri.clone(), entry);
1935
1936 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
1938 assert_eq!(stored, Some(text.to_string()));
1939
1940 server.documents.write().await.remove(&uri);
1942
1943 let stored = server.documents.read().await.get(&uri).cloned();
1945 assert_eq!(stored, None);
1946 }
1947
1948 #[tokio::test]
1949 async fn test_configuration_loading() {
1950 let server = create_test_server();
1951
1952 server.load_configuration(false).await;
1954
1955 let rumdl_config = server.rumdl_config.read().await;
1958 drop(rumdl_config); }
1961
1962 #[tokio::test]
1963 async fn test_load_config_for_lsp() {
1964 let result = RumdlLanguageServer::load_config_for_lsp(None);
1966 assert!(result.is_ok());
1967
1968 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
1970 assert!(result.is_err());
1971 }
1972
1973 #[tokio::test]
1974 async fn test_warning_conversion() {
1975 let warning = LintWarning {
1976 message: "Test warning".to_string(),
1977 line: 1,
1978 column: 1,
1979 end_line: 1,
1980 end_column: 10,
1981 severity: crate::rule::Severity::Warning,
1982 fix: None,
1983 rule_name: Some("MD001".to_string()),
1984 };
1985
1986 let diagnostic = warning_to_diagnostic(&warning);
1988 assert_eq!(diagnostic.message, "Test warning");
1989 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
1990 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
1991
1992 let uri = Url::parse("file:///test.md").unwrap();
1994 let actions = warning_to_code_actions(&warning, &uri, "Test content");
1995 assert_eq!(actions.len(), 1);
1997 assert_eq!(actions[0].title, "Ignore MD001 for this line");
1998 }
1999
2000 #[tokio::test]
2001 async fn test_multiple_documents() {
2002 let server = create_test_server();
2003
2004 let uri1 = Url::parse("file:///test1.md").unwrap();
2005 let uri2 = Url::parse("file:///test2.md").unwrap();
2006 let text1 = "# Document 1";
2007 let text2 = "# Document 2";
2008
2009 {
2011 let mut docs = server.documents.write().await;
2012 let entry1 = DocumentEntry {
2013 content: text1.to_string(),
2014 version: Some(1),
2015 from_disk: false,
2016 };
2017 let entry2 = DocumentEntry {
2018 content: text2.to_string(),
2019 version: Some(1),
2020 from_disk: false,
2021 };
2022 docs.insert(uri1.clone(), entry1);
2023 docs.insert(uri2.clone(), entry2);
2024 }
2025
2026 let docs = server.documents.read().await;
2028 assert_eq!(docs.len(), 2);
2029 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2030 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2031 }
2032
2033 #[tokio::test]
2034 async fn test_auto_fix_on_save() {
2035 let server = create_test_server();
2036
2037 {
2039 let mut config = server.config.write().await;
2040 config.enable_auto_fix = true;
2041 }
2042
2043 let uri = Url::parse("file:///test.md").unwrap();
2044 let text = "#Heading without space"; let entry = DocumentEntry {
2048 content: text.to_string(),
2049 version: Some(1),
2050 from_disk: false,
2051 };
2052 server.documents.write().await.insert(uri.clone(), entry);
2053
2054 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2056 assert!(fixed.is_some());
2057 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2059 }
2060
2061 #[tokio::test]
2062 async fn test_get_end_position() {
2063 let server = create_test_server();
2064
2065 let pos = server.get_end_position("Hello");
2067 assert_eq!(pos.line, 0);
2068 assert_eq!(pos.character, 5);
2069
2070 let pos = server.get_end_position("Hello\nWorld\nTest");
2072 assert_eq!(pos.line, 2);
2073 assert_eq!(pos.character, 4);
2074
2075 let pos = server.get_end_position("");
2077 assert_eq!(pos.line, 0);
2078 assert_eq!(pos.character, 0);
2079
2080 let pos = server.get_end_position("Hello\n");
2082 assert_eq!(pos.line, 1);
2083 assert_eq!(pos.character, 0);
2084 }
2085
2086 #[tokio::test]
2087 async fn test_empty_document_handling() {
2088 let server = create_test_server();
2089
2090 let uri = Url::parse("file:///empty.md").unwrap();
2091 let text = "";
2092
2093 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2095 assert!(diagnostics.is_empty());
2096
2097 let range = Range {
2099 start: Position { line: 0, character: 0 },
2100 end: Position { line: 0, character: 0 },
2101 };
2102 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2103 assert!(actions.is_empty());
2104 }
2105
2106 #[tokio::test]
2107 async fn test_config_update() {
2108 let server = create_test_server();
2109
2110 {
2112 let mut config = server.config.write().await;
2113 config.enable_auto_fix = true;
2114 config.config_path = Some("/custom/path.toml".to_string());
2115 }
2116
2117 let config = server.config.read().await;
2119 assert!(config.enable_auto_fix);
2120 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2121 }
2122
2123 #[tokio::test]
2124 async fn test_document_formatting() {
2125 let server = create_test_server();
2126 let uri = Url::parse("file:///test.md").unwrap();
2127 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2128
2129 let entry = DocumentEntry {
2131 content: text.to_string(),
2132 version: Some(1),
2133 from_disk: false,
2134 };
2135 server.documents.write().await.insert(uri.clone(), entry);
2136
2137 let params = DocumentFormattingParams {
2139 text_document: TextDocumentIdentifier { uri: uri.clone() },
2140 options: FormattingOptions {
2141 tab_size: 4,
2142 insert_spaces: true,
2143 properties: HashMap::new(),
2144 trim_trailing_whitespace: Some(true),
2145 insert_final_newline: Some(true),
2146 trim_final_newlines: Some(true),
2147 },
2148 work_done_progress_params: WorkDoneProgressParams::default(),
2149 };
2150
2151 let result = server.formatting(params).await.unwrap();
2153
2154 assert!(result.is_some());
2156 let edits = result.unwrap();
2157 assert!(!edits.is_empty());
2158
2159 let edit = &edits[0];
2161 let expected = "# Test\n\nThis is a test \nWith trailing spaces\n";
2164 assert_eq!(edit.new_text, expected);
2165 }
2166
2167 #[tokio::test]
2170 async fn test_unfixable_rules_excluded_from_formatting() {
2171 let server = create_test_server();
2172 let uri = Url::parse("file:///test.md").unwrap();
2173
2174 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2176
2177 let entry = DocumentEntry {
2179 content: text.to_string(),
2180 version: Some(1),
2181 from_disk: false,
2182 };
2183 server.documents.write().await.insert(uri.clone(), entry);
2184
2185 let format_params = DocumentFormattingParams {
2187 text_document: TextDocumentIdentifier { uri: uri.clone() },
2188 options: FormattingOptions {
2189 tab_size: 4,
2190 insert_spaces: true,
2191 properties: HashMap::new(),
2192 trim_trailing_whitespace: Some(true),
2193 insert_final_newline: Some(true),
2194 trim_final_newlines: Some(true),
2195 },
2196 work_done_progress_params: WorkDoneProgressParams::default(),
2197 };
2198
2199 let format_result = server.formatting(format_params).await.unwrap();
2200 assert!(format_result.is_some(), "Should return formatting edits");
2201
2202 let edits = format_result.unwrap();
2203 assert!(!edits.is_empty(), "Should have formatting edits");
2204
2205 let formatted = &edits[0].new_text;
2206 assert!(
2207 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2208 "HTML should be preserved during formatting (Unfixable rule)"
2209 );
2210 assert!(
2211 !formatted.contains("spaces "),
2212 "Trailing spaces should be removed (fixable rule)"
2213 );
2214
2215 let range = Range {
2217 start: Position { line: 0, character: 0 },
2218 end: Position { line: 10, character: 0 },
2219 };
2220
2221 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2222
2223 let html_fix_actions: Vec<_> = code_actions
2225 .iter()
2226 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2227 .collect();
2228
2229 assert!(
2230 !html_fix_actions.is_empty(),
2231 "Quick Fix actions should be available for HTML (Unfixable rules)"
2232 );
2233
2234 let fix_all_actions: Vec<_> = code_actions
2236 .iter()
2237 .filter(|action| action.title.contains("Fix all"))
2238 .collect();
2239
2240 if let Some(fix_all_action) = fix_all_actions.first()
2241 && let Some(ref edit) = fix_all_action.edit
2242 && let Some(ref changes) = edit.changes
2243 && let Some(text_edits) = changes.get(&uri)
2244 && let Some(text_edit) = text_edits.first()
2245 {
2246 let fixed_all = &text_edit.new_text;
2247 assert!(
2248 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2249 "Fix All should preserve HTML (Unfixable rules)"
2250 );
2251 assert!(
2252 !fixed_all.contains("spaces "),
2253 "Fix All should remove trailing spaces (fixable rules)"
2254 );
2255 }
2256 }
2257
2258 #[tokio::test]
2260 async fn test_resolve_config_for_file_multi_root() {
2261 use std::fs;
2262 use tempfile::tempdir;
2263
2264 let temp_dir = tempdir().unwrap();
2265 let temp_path = temp_dir.path();
2266
2267 let project_a = temp_path.join("project_a");
2269 let project_a_docs = project_a.join("docs");
2270 fs::create_dir_all(&project_a_docs).unwrap();
2271
2272 let config_a = project_a.join(".rumdl.toml");
2273 fs::write(
2274 &config_a,
2275 r#"
2276[global]
2277
2278[MD013]
2279line_length = 60
2280"#,
2281 )
2282 .unwrap();
2283
2284 let project_b = temp_path.join("project_b");
2286 fs::create_dir(&project_b).unwrap();
2287
2288 let config_b = project_b.join(".rumdl.toml");
2289 fs::write(
2290 &config_b,
2291 r#"
2292[global]
2293
2294[MD013]
2295line_length = 120
2296"#,
2297 )
2298 .unwrap();
2299
2300 let server = create_test_server();
2302
2303 {
2305 let mut roots = server.workspace_roots.write().await;
2306 roots.push(project_a.clone());
2307 roots.push(project_b.clone());
2308 }
2309
2310 let file_a = project_a_docs.join("test.md");
2312 fs::write(&file_a, "# Test A\n").unwrap();
2313
2314 let config_for_a = server.resolve_config_for_file(&file_a).await;
2315 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2316 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2317
2318 let file_b = project_b.join("test.md");
2320 fs::write(&file_b, "# Test B\n").unwrap();
2321
2322 let config_for_b = server.resolve_config_for_file(&file_b).await;
2323 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2324 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2325 }
2326
2327 #[tokio::test]
2329 async fn test_config_resolution_respects_workspace_boundaries() {
2330 use std::fs;
2331 use tempfile::tempdir;
2332
2333 let temp_dir = tempdir().unwrap();
2334 let temp_path = temp_dir.path();
2335
2336 let parent_config = temp_path.join(".rumdl.toml");
2338 fs::write(
2339 &parent_config,
2340 r#"
2341[global]
2342
2343[MD013]
2344line_length = 80
2345"#,
2346 )
2347 .unwrap();
2348
2349 let workspace_root = temp_path.join("workspace");
2351 let workspace_subdir = workspace_root.join("subdir");
2352 fs::create_dir_all(&workspace_subdir).unwrap();
2353
2354 let workspace_config = workspace_root.join(".rumdl.toml");
2355 fs::write(
2356 &workspace_config,
2357 r#"
2358[global]
2359
2360[MD013]
2361line_length = 100
2362"#,
2363 )
2364 .unwrap();
2365
2366 let server = create_test_server();
2367
2368 {
2370 let mut roots = server.workspace_roots.write().await;
2371 roots.push(workspace_root.clone());
2372 }
2373
2374 let test_file = workspace_subdir.join("deep").join("test.md");
2376 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2377 fs::write(&test_file, "# Test\n").unwrap();
2378
2379 let config = server.resolve_config_for_file(&test_file).await;
2380 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2381
2382 assert_eq!(
2384 line_length,
2385 Some(100),
2386 "Should find workspace config, not parent config outside workspace"
2387 );
2388 }
2389
2390 #[tokio::test]
2392 async fn test_config_cache_hit() {
2393 use std::fs;
2394 use tempfile::tempdir;
2395
2396 let temp_dir = tempdir().unwrap();
2397 let temp_path = temp_dir.path();
2398
2399 let project = temp_path.join("project");
2400 fs::create_dir(&project).unwrap();
2401
2402 let config_file = project.join(".rumdl.toml");
2403 fs::write(
2404 &config_file,
2405 r#"
2406[global]
2407
2408[MD013]
2409line_length = 75
2410"#,
2411 )
2412 .unwrap();
2413
2414 let server = create_test_server();
2415 {
2416 let mut roots = server.workspace_roots.write().await;
2417 roots.push(project.clone());
2418 }
2419
2420 let test_file = project.join("test.md");
2421 fs::write(&test_file, "# Test\n").unwrap();
2422
2423 let config1 = server.resolve_config_for_file(&test_file).await;
2425 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2426 assert_eq!(line_length1, Some(75));
2427
2428 {
2430 let cache = server.config_cache.read().await;
2431 let search_dir = test_file.parent().unwrap();
2432 assert!(
2433 cache.contains_key(search_dir),
2434 "Cache should be populated after first call"
2435 );
2436 }
2437
2438 let config2 = server.resolve_config_for_file(&test_file).await;
2440 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2441 assert_eq!(line_length2, Some(75));
2442 }
2443
2444 #[tokio::test]
2446 async fn test_nested_directory_config_search() {
2447 use std::fs;
2448 use tempfile::tempdir;
2449
2450 let temp_dir = tempdir().unwrap();
2451 let temp_path = temp_dir.path();
2452
2453 let project = temp_path.join("project");
2454 fs::create_dir(&project).unwrap();
2455
2456 let config = project.join(".rumdl.toml");
2458 fs::write(
2459 &config,
2460 r#"
2461[global]
2462
2463[MD013]
2464line_length = 110
2465"#,
2466 )
2467 .unwrap();
2468
2469 let deep_dir = project.join("src").join("docs").join("guides");
2471 fs::create_dir_all(&deep_dir).unwrap();
2472 let deep_file = deep_dir.join("test.md");
2473 fs::write(&deep_file, "# Test\n").unwrap();
2474
2475 let server = create_test_server();
2476 {
2477 let mut roots = server.workspace_roots.write().await;
2478 roots.push(project.clone());
2479 }
2480
2481 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2482 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2483
2484 assert_eq!(
2485 line_length,
2486 Some(110),
2487 "Should find config by searching upward from deep directory"
2488 );
2489 }
2490
2491 #[tokio::test]
2493 async fn test_fallback_to_default_config() {
2494 use std::fs;
2495 use tempfile::tempdir;
2496
2497 let temp_dir = tempdir().unwrap();
2498 let temp_path = temp_dir.path();
2499
2500 let project = temp_path.join("project");
2501 fs::create_dir(&project).unwrap();
2502
2503 let test_file = project.join("test.md");
2506 fs::write(&test_file, "# Test\n").unwrap();
2507
2508 let server = create_test_server();
2509 {
2510 let mut roots = server.workspace_roots.write().await;
2511 roots.push(project.clone());
2512 }
2513
2514 let config = server.resolve_config_for_file(&test_file).await;
2515
2516 assert_eq!(
2518 config.global.line_length.get(),
2519 80,
2520 "Should fall back to default config when no config file found"
2521 );
2522 }
2523
2524 #[tokio::test]
2526 async fn test_config_priority_closer_wins() {
2527 use std::fs;
2528 use tempfile::tempdir;
2529
2530 let temp_dir = tempdir().unwrap();
2531 let temp_path = temp_dir.path();
2532
2533 let project = temp_path.join("project");
2534 fs::create_dir(&project).unwrap();
2535
2536 let parent_config = project.join(".rumdl.toml");
2538 fs::write(
2539 &parent_config,
2540 r#"
2541[global]
2542
2543[MD013]
2544line_length = 100
2545"#,
2546 )
2547 .unwrap();
2548
2549 let subdir = project.join("subdir");
2551 fs::create_dir(&subdir).unwrap();
2552
2553 let subdir_config = subdir.join(".rumdl.toml");
2554 fs::write(
2555 &subdir_config,
2556 r#"
2557[global]
2558
2559[MD013]
2560line_length = 50
2561"#,
2562 )
2563 .unwrap();
2564
2565 let server = create_test_server();
2566 {
2567 let mut roots = server.workspace_roots.write().await;
2568 roots.push(project.clone());
2569 }
2570
2571 let test_file = subdir.join("test.md");
2573 fs::write(&test_file, "# Test\n").unwrap();
2574
2575 let config = server.resolve_config_for_file(&test_file).await;
2576 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2577
2578 assert_eq!(
2579 line_length,
2580 Some(50),
2581 "Closer config (subdir) should override parent config"
2582 );
2583 }
2584
2585 #[tokio::test]
2591 async fn test_issue_131_pyproject_without_rumdl_section() {
2592 use std::fs;
2593 use tempfile::tempdir;
2594
2595 let parent_dir = tempdir().unwrap();
2597
2598 let project_dir = parent_dir.path().join("project");
2600 fs::create_dir(&project_dir).unwrap();
2601
2602 fs::write(
2604 project_dir.join("pyproject.toml"),
2605 r#"
2606[project]
2607name = "test-project"
2608version = "0.1.0"
2609"#,
2610 )
2611 .unwrap();
2612
2613 fs::write(
2616 parent_dir.path().join(".rumdl.toml"),
2617 r#"
2618[global]
2619disable = ["MD013"]
2620"#,
2621 )
2622 .unwrap();
2623
2624 let test_file = project_dir.join("test.md");
2625 fs::write(&test_file, "# Test\n").unwrap();
2626
2627 let server = create_test_server();
2628
2629 {
2631 let mut roots = server.workspace_roots.write().await;
2632 roots.push(parent_dir.path().to_path_buf());
2633 }
2634
2635 let config = server.resolve_config_for_file(&test_file).await;
2637
2638 assert!(
2641 config.global.disable.contains(&"MD013".to_string()),
2642 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2643 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2644 );
2645
2646 let cache = server.config_cache.read().await;
2649 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2650
2651 assert!(
2652 cache_entry.config_file.is_some(),
2653 "Should have found a config file (parent .rumdl.toml)"
2654 );
2655
2656 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2657 assert!(
2658 found_config_path.ends_with(".rumdl.toml"),
2659 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2660 );
2661 assert!(
2662 found_config_path.parent().unwrap() == parent_dir.path(),
2663 "Should have loaded config from parent directory, not project_dir"
2664 );
2665 }
2666
2667 #[tokio::test]
2672 async fn test_issue_131_pyproject_with_rumdl_section() {
2673 use std::fs;
2674 use tempfile::tempdir;
2675
2676 let parent_dir = tempdir().unwrap();
2678
2679 let project_dir = parent_dir.path().join("project");
2681 fs::create_dir(&project_dir).unwrap();
2682
2683 fs::write(
2685 project_dir.join("pyproject.toml"),
2686 r#"
2687[project]
2688name = "test-project"
2689
2690[tool.rumdl.global]
2691disable = ["MD033"]
2692"#,
2693 )
2694 .unwrap();
2695
2696 fs::write(
2698 parent_dir.path().join(".rumdl.toml"),
2699 r#"
2700[global]
2701disable = ["MD041"]
2702"#,
2703 )
2704 .unwrap();
2705
2706 let test_file = project_dir.join("test.md");
2707 fs::write(&test_file, "# Test\n").unwrap();
2708
2709 let server = create_test_server();
2710
2711 {
2713 let mut roots = server.workspace_roots.write().await;
2714 roots.push(parent_dir.path().to_path_buf());
2715 }
2716
2717 let config = server.resolve_config_for_file(&test_file).await;
2719
2720 assert!(
2722 config.global.disable.contains(&"MD033".to_string()),
2723 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2724 Expected MD033 from project_dir pyproject.toml to be disabled."
2725 );
2726
2727 assert!(
2729 !config.global.disable.contains(&"MD041".to_string()),
2730 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2731 );
2732
2733 let cache = server.config_cache.read().await;
2735 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2736
2737 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2738
2739 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2740 assert!(
2741 found_config_path.ends_with("pyproject.toml"),
2742 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2743 );
2744 assert!(
2745 found_config_path.parent().unwrap() == project_dir,
2746 "Should have loaded pyproject.toml from project_dir, not parent"
2747 );
2748 }
2749
2750 #[tokio::test]
2755 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2756 use std::fs;
2757 use tempfile::tempdir;
2758
2759 let temp_dir = tempdir().unwrap();
2760
2761 fs::write(
2763 temp_dir.path().join("pyproject.toml"),
2764 r#"
2765[project]
2766name = "test-project"
2767
2768[tool.rumdl.global]
2769disable = ["MD022"]
2770"#,
2771 )
2772 .unwrap();
2773
2774 let test_file = temp_dir.path().join("test.md");
2775 fs::write(&test_file, "# Test\n").unwrap();
2776
2777 let server = create_test_server();
2778
2779 {
2781 let mut roots = server.workspace_roots.write().await;
2782 roots.push(temp_dir.path().to_path_buf());
2783 }
2784
2785 let config = server.resolve_config_for_file(&test_file).await;
2787
2788 assert!(
2790 config.global.disable.contains(&"MD022".to_string()),
2791 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2792 );
2793
2794 let cache = server.config_cache.read().await;
2796 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2797 assert!(
2798 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2799 "Should have loaded pyproject.toml"
2800 );
2801 }
2802
2803 #[tokio::test]
2808 async fn test_issue_182_pull_diagnostics_capability_default() {
2809 let server = create_test_server();
2810
2811 assert!(
2813 !*server.client_supports_pull_diagnostics.read().await,
2814 "Default should be false - push diagnostics by default"
2815 );
2816 }
2817
2818 #[tokio::test]
2820 async fn test_issue_182_pull_diagnostics_flag_update() {
2821 let server = create_test_server();
2822
2823 *server.client_supports_pull_diagnostics.write().await = true;
2825
2826 assert!(
2827 *server.client_supports_pull_diagnostics.read().await,
2828 "Flag should be settable to true"
2829 );
2830 }
2831
2832 #[tokio::test]
2836 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2837 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2838
2839 let caps_with_diagnostic = ClientCapabilities {
2841 text_document: Some(TextDocumentClientCapabilities {
2842 diagnostic: Some(DiagnosticClientCapabilities {
2843 dynamic_registration: Some(true),
2844 related_document_support: Some(false),
2845 }),
2846 ..Default::default()
2847 }),
2848 ..Default::default()
2849 };
2850
2851 let supports_pull = caps_with_diagnostic
2853 .text_document
2854 .as_ref()
2855 .and_then(|td| td.diagnostic.as_ref())
2856 .is_some();
2857
2858 assert!(supports_pull, "Should detect pull diagnostic support");
2859 }
2860
2861 #[tokio::test]
2863 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2864 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2865
2866 let caps_without_diagnostic = ClientCapabilities {
2868 text_document: Some(TextDocumentClientCapabilities {
2869 diagnostic: None, ..Default::default()
2871 }),
2872 ..Default::default()
2873 };
2874
2875 let supports_pull = caps_without_diagnostic
2877 .text_document
2878 .as_ref()
2879 .and_then(|td| td.diagnostic.as_ref())
2880 .is_some();
2881
2882 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2883 }
2884
2885 #[tokio::test]
2887 async fn test_issue_182_capability_detection_no_text_document() {
2888 use tower_lsp::lsp_types::ClientCapabilities;
2889
2890 let caps_no_text_doc = ClientCapabilities {
2892 text_document: None,
2893 ..Default::default()
2894 };
2895
2896 let supports_pull = caps_no_text_doc
2898 .text_document
2899 .as_ref()
2900 .and_then(|td| td.diagnostic.as_ref())
2901 .is_some();
2902
2903 assert!(
2904 !supports_pull,
2905 "Should NOT detect pull diagnostic support when text_document is None"
2906 );
2907 }
2908
2909 #[test]
2910 fn test_resource_limit_constants() {
2911 assert_eq!(MAX_RULE_LIST_SIZE, 100);
2913 assert_eq!(MAX_LINE_LENGTH, 10_000);
2914 }
2915
2916 #[test]
2917 fn test_is_valid_rule_name_edge_cases() {
2918 assert!(!is_valid_rule_name("MD/01")); assert!(!is_valid_rule_name("MD:01")); assert!(!is_valid_rule_name("ND001")); assert!(!is_valid_rule_name("ME001")); assert!(!is_valid_rule_name("MD0①1")); assert!(!is_valid_rule_name("MD001")); assert!(!is_valid_rule_name("MD\x00\x00\x00")); }
2931
2932 #[tokio::test]
2941 async fn test_lsp_toml_config_parity_generic() {
2942 use crate::config::RuleConfig;
2943 use crate::rule::Severity;
2944
2945 let server = create_test_server();
2946
2947 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
2951 (
2953 "severity only - error",
2954 serde_json::json!({"severity": "error"}),
2955 RuleConfig {
2956 severity: Some(Severity::Error),
2957 values: std::collections::BTreeMap::new(),
2958 },
2959 ),
2960 (
2961 "severity only - warning",
2962 serde_json::json!({"severity": "warning"}),
2963 RuleConfig {
2964 severity: Some(Severity::Warning),
2965 values: std::collections::BTreeMap::new(),
2966 },
2967 ),
2968 (
2969 "severity only - info",
2970 serde_json::json!({"severity": "info"}),
2971 RuleConfig {
2972 severity: Some(Severity::Info),
2973 values: std::collections::BTreeMap::new(),
2974 },
2975 ),
2976 (
2978 "integer value",
2979 serde_json::json!({"lineLength": 120}),
2980 RuleConfig {
2981 severity: None,
2982 values: [("line_length".to_string(), toml::Value::Integer(120))]
2983 .into_iter()
2984 .collect(),
2985 },
2986 ),
2987 (
2989 "boolean value",
2990 serde_json::json!({"enabled": true}),
2991 RuleConfig {
2992 severity: None,
2993 values: [("enabled".to_string(), toml::Value::Boolean(true))]
2994 .into_iter()
2995 .collect(),
2996 },
2997 ),
2998 (
3000 "string value",
3001 serde_json::json!({"style": "consistent"}),
3002 RuleConfig {
3003 severity: None,
3004 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3005 .into_iter()
3006 .collect(),
3007 },
3008 ),
3009 (
3011 "array value",
3012 serde_json::json!({"allowedElements": ["div", "span"]}),
3013 RuleConfig {
3014 severity: None,
3015 values: [(
3016 "allowed_elements".to_string(),
3017 toml::Value::Array(vec![
3018 toml::Value::String("div".to_string()),
3019 toml::Value::String("span".to_string()),
3020 ]),
3021 )]
3022 .into_iter()
3023 .collect(),
3024 },
3025 ),
3026 (
3028 "severity + integer",
3029 serde_json::json!({"severity": "info", "lineLength": 80}),
3030 RuleConfig {
3031 severity: Some(Severity::Info),
3032 values: [("line_length".to_string(), toml::Value::Integer(80))]
3033 .into_iter()
3034 .collect(),
3035 },
3036 ),
3037 (
3038 "severity + multiple values",
3039 serde_json::json!({
3040 "severity": "warning",
3041 "lineLength": 100,
3042 "strict": false,
3043 "style": "atx"
3044 }),
3045 RuleConfig {
3046 severity: Some(Severity::Warning),
3047 values: [
3048 ("line_length".to_string(), toml::Value::Integer(100)),
3049 ("strict".to_string(), toml::Value::Boolean(false)),
3050 ("style".to_string(), toml::Value::String("atx".to_string())),
3051 ]
3052 .into_iter()
3053 .collect(),
3054 },
3055 ),
3056 (
3058 "camelCase conversion",
3059 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3060 RuleConfig {
3061 severity: None,
3062 values: [
3063 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3064 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3065 ]
3066 .into_iter()
3067 .collect(),
3068 },
3069 ),
3070 ];
3071
3072 for (description, lsp_json, expected_toml_config) in test_configs {
3073 let mut lsp_config = crate::config::Config::default();
3074 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3075
3076 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3077
3078 assert_eq!(
3080 lsp_rule.severity, expected_toml_config.severity,
3081 "Parity failure [{description}]: severity mismatch. \
3082 LSP={:?}, TOML={:?}",
3083 lsp_rule.severity, expected_toml_config.severity
3084 );
3085
3086 assert_eq!(
3088 lsp_rule.values, expected_toml_config.values,
3089 "Parity failure [{description}]: values mismatch. \
3090 LSP={:?}, TOML={:?}",
3091 lsp_rule.values, expected_toml_config.values
3092 );
3093 }
3094 }
3095
3096 #[tokio::test]
3098 async fn test_lsp_config_if_absent_preserves_existing() {
3099 use crate::config::RuleConfig;
3100 use crate::rule::Severity;
3101
3102 let server = create_test_server();
3103
3104 let mut config = crate::config::Config::default();
3106 config.rules.insert(
3107 "MD013".to_string(),
3108 RuleConfig {
3109 severity: Some(Severity::Error),
3110 values: [("line_length".to_string(), toml::Value::Integer(80))]
3111 .into_iter()
3112 .collect(),
3113 },
3114 );
3115
3116 let lsp_json = serde_json::json!({
3118 "severity": "info",
3119 "lineLength": 120
3120 });
3121 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3122
3123 let rule = config.rules.get("MD013").expect("Rule should exist");
3124
3125 assert_eq!(
3127 rule.severity,
3128 Some(Severity::Error),
3129 "Existing severity should not be overwritten"
3130 );
3131
3132 assert_eq!(
3134 rule.values.get("line_length"),
3135 Some(&toml::Value::Integer(80)),
3136 "Existing values should not be overwritten"
3137 );
3138 }
3139}