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