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 fn apply_formatting_options(content: String, options: &FormattingOptions) -> String {
720 if content.is_empty() {
723 return content;
724 }
725
726 let mut result = content.clone();
727 let original_ended_with_newline = content.ends_with('\n');
728
729 if options.trim_trailing_whitespace.unwrap_or(false) {
731 result = result
732 .lines()
733 .map(|line| line.trim_end())
734 .collect::<Vec<_>>()
735 .join("\n");
736 if original_ended_with_newline && !result.ends_with('\n') {
738 result.push('\n');
739 }
740 }
741
742 if options.trim_final_newlines.unwrap_or(false) {
746 while result.ends_with('\n') {
748 result.pop();
749 }
750 }
752
753 if options.insert_final_newline.unwrap_or(false) && !result.ends_with('\n') {
755 result.push('\n');
756 }
757
758 result
759 }
760
761 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
763 let config_guard = self.config.read().await;
764 let lsp_config = config_guard.clone();
765 drop(config_guard);
766
767 let file_config = if let Ok(file_path) = uri.to_file_path() {
769 self.resolve_config_for_file(&file_path).await
770 } else {
771 (*self.rumdl_config.read().await).clone()
773 };
774
775 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
777
778 let all_rules = rules::all_rules(&rumdl_config);
779 let flavor = rumdl_config.markdown_flavor();
780
781 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
783
784 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
786
787 match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
788 Ok(warnings) => {
789 let mut actions = Vec::new();
790 let mut fixable_count = 0;
791
792 for warning in &warnings {
793 let warning_line = (warning.line.saturating_sub(1)) as u32;
795 if warning_line >= range.start.line && warning_line <= range.end.line {
796 let mut warning_actions = warning_to_code_actions(warning, uri, text);
798 actions.append(&mut warning_actions);
799
800 if warning.fix.is_some() {
801 fixable_count += 1;
802 }
803 }
804 }
805
806 if fixable_count > 1 {
808 let fixable_warnings: Vec<_> = warnings
811 .iter()
812 .filter(|w| {
813 if let Some(rule_name) = &w.rule_name {
814 filtered_rules
815 .iter()
816 .find(|r| r.name() == rule_name)
817 .map(|r| r.fix_capability() != FixCapability::Unfixable)
818 .unwrap_or(false)
819 } else {
820 false
821 }
822 })
823 .cloned()
824 .collect();
825
826 let total_fixable = fixable_warnings.len();
828
829 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
830 && fixed_content != text
831 {
832 let mut line = 0u32;
834 let mut character = 0u32;
835 for ch in text.chars() {
836 if ch == '\n' {
837 line += 1;
838 character = 0;
839 } else {
840 character += 1;
841 }
842 }
843
844 let fix_all_action = CodeAction {
845 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
846 kind: Some(CodeActionKind::QUICKFIX),
847 diagnostics: Some(Vec::new()),
848 edit: Some(WorkspaceEdit {
849 changes: Some(
850 [(
851 uri.clone(),
852 vec![TextEdit {
853 range: Range {
854 start: Position { line: 0, character: 0 },
855 end: Position { line, character },
856 },
857 new_text: fixed_content,
858 }],
859 )]
860 .into_iter()
861 .collect(),
862 ),
863 ..Default::default()
864 }),
865 command: None,
866 is_preferred: Some(true),
867 disabled: None,
868 data: None,
869 };
870
871 actions.insert(0, fix_all_action);
873 }
874 }
875
876 Ok(actions)
877 }
878 Err(e) => {
879 log::error!("Failed to get code actions: {e}");
880 Ok(Vec::new())
881 }
882 }
883 }
884
885 async fn load_configuration(&self, notify_client: bool) {
887 let config_guard = self.config.read().await;
888 let explicit_config_path = config_guard.config_path.clone();
889 drop(config_guard);
890
891 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
893 Ok(sourced_config) => {
894 let loaded_files = sourced_config.loaded_files.clone();
895 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
897
898 if !loaded_files.is_empty() {
899 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
900 log::info!("{message}");
901 if notify_client {
902 self.client.log_message(MessageType::INFO, &message).await;
903 }
904 } else {
905 log::info!("Using default rumdl configuration (no config files found)");
906 }
907 }
908 Err(e) => {
909 let message = format!("Failed to load rumdl config: {e}");
910 log::warn!("{message}");
911 if notify_client {
912 self.client.log_message(MessageType::WARNING, &message).await;
913 }
914 *self.rumdl_config.write().await = crate::config::Config::default();
916 }
917 }
918 }
919
920 async fn reload_configuration(&self) {
922 self.load_configuration(true).await;
923 }
924
925 fn load_config_for_lsp(
927 config_path: Option<&str>,
928 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
929 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
931 }
932
933 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
940 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
942
943 {
945 let cache = self.config_cache.read().await;
946 if let Some(entry) = cache.get(&search_dir) {
947 let source_owned: String; let source: &str = if entry.from_global_fallback {
949 "global/user fallback"
950 } else if let Some(path) = &entry.config_file {
951 source_owned = path.to_string_lossy().to_string();
952 &source_owned
953 } else {
954 "<unknown>"
955 };
956 log::debug!(
957 "Config cache hit for directory: {} (loaded from: {})",
958 search_dir.display(),
959 source
960 );
961 return entry.config.clone();
962 }
963 }
964
965 log::debug!(
967 "Config cache miss for directory: {}, searching for config...",
968 search_dir.display()
969 );
970
971 let workspace_root = {
973 let workspace_roots = self.workspace_roots.read().await;
974 workspace_roots
975 .iter()
976 .find(|root| search_dir.starts_with(root))
977 .map(|p| p.to_path_buf())
978 };
979
980 let mut current_dir = search_dir.clone();
982 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
983
984 loop {
985 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
987
988 for config_file_name in CONFIG_FILES {
989 let config_path = current_dir.join(config_file_name);
990 if config_path.exists() {
991 if *config_file_name == "pyproject.toml" {
993 if let Ok(content) = std::fs::read_to_string(&config_path) {
994 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
995 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
996 } else {
997 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
998 continue;
999 }
1000 } else {
1001 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
1002 continue;
1003 }
1004 } else {
1005 log::debug!("Found config file: {}", config_path.display());
1006 }
1007
1008 if let Some(config_path_str) = config_path.to_str() {
1010 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
1011 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
1012 break;
1013 }
1014 } else {
1015 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
1016 }
1017 }
1018 }
1019
1020 if found_config.is_some() {
1021 break;
1022 }
1023
1024 if let Some(ref root) = workspace_root
1026 && ¤t_dir == root
1027 {
1028 log::debug!("Hit workspace root without finding config: {}", root.display());
1029 break;
1030 }
1031
1032 if let Some(parent) = current_dir.parent() {
1034 current_dir = parent.to_path_buf();
1035 } else {
1036 break;
1038 }
1039 }
1040
1041 let (config, config_file) = if let Some((cfg, path)) = found_config {
1043 (cfg, path)
1044 } else {
1045 log::debug!("No project config found; using global/user fallback config");
1046 let fallback = self.rumdl_config.read().await.clone();
1047 (fallback, None)
1048 };
1049
1050 let from_global = config_file.is_none();
1052 let entry = ConfigCacheEntry {
1053 config: config.clone(),
1054 config_file,
1055 from_global_fallback: from_global,
1056 };
1057
1058 self.config_cache.write().await.insert(search_dir, entry);
1059
1060 config
1061 }
1062}
1063
1064#[tower_lsp::async_trait]
1065impl LanguageServer for RumdlLanguageServer {
1066 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1067 log::info!("Initializing rumdl Language Server");
1068
1069 if let Some(options) = params.initialization_options
1071 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
1072 {
1073 *self.config.write().await = config;
1074 }
1075
1076 let supports_pull = params
1079 .capabilities
1080 .text_document
1081 .as_ref()
1082 .and_then(|td| td.diagnostic.as_ref())
1083 .is_some();
1084
1085 if supports_pull {
1086 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1087 *self.client_supports_pull_diagnostics.write().await = true;
1088 } else {
1089 log::info!("Client does not support pull diagnostics - using push model");
1090 }
1091
1092 let mut roots = Vec::new();
1094 if let Some(workspace_folders) = params.workspace_folders {
1095 for folder in workspace_folders {
1096 if let Ok(path) = folder.uri.to_file_path() {
1097 log::info!("Workspace root: {}", path.display());
1098 roots.push(path);
1099 }
1100 }
1101 } else if let Some(root_uri) = params.root_uri
1102 && let Ok(path) = root_uri.to_file_path()
1103 {
1104 log::info!("Workspace root: {}", path.display());
1105 roots.push(path);
1106 }
1107 *self.workspace_roots.write().await = roots;
1108
1109 self.load_configuration(false).await;
1111
1112 Ok(InitializeResult {
1113 capabilities: ServerCapabilities {
1114 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1115 open_close: Some(true),
1116 change: Some(TextDocumentSyncKind::FULL),
1117 will_save: Some(false),
1118 will_save_wait_until: Some(true),
1119 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1120 include_text: Some(false),
1121 })),
1122 })),
1123 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1124 document_formatting_provider: Some(OneOf::Left(true)),
1125 document_range_formatting_provider: Some(OneOf::Left(true)),
1126 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1127 identifier: Some("rumdl".to_string()),
1128 inter_file_dependencies: true,
1129 workspace_diagnostics: false,
1130 work_done_progress_options: WorkDoneProgressOptions::default(),
1131 })),
1132 workspace: Some(WorkspaceServerCapabilities {
1133 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1134 supported: Some(true),
1135 change_notifications: Some(OneOf::Left(true)),
1136 }),
1137 file_operations: None,
1138 }),
1139 ..Default::default()
1140 },
1141 server_info: Some(ServerInfo {
1142 name: "rumdl".to_string(),
1143 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1144 }),
1145 })
1146 }
1147
1148 async fn initialized(&self, _: InitializedParams) {
1149 let version = env!("CARGO_PKG_VERSION");
1150
1151 let (binary_path, build_time) = std::env::current_exe()
1153 .ok()
1154 .map(|path| {
1155 let path_str = path.to_str().unwrap_or("unknown").to_string();
1156 let build_time = std::fs::metadata(&path)
1157 .ok()
1158 .and_then(|metadata| metadata.modified().ok())
1159 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1160 .and_then(|duration| {
1161 let secs = duration.as_secs();
1162 chrono::DateTime::from_timestamp(secs as i64, 0)
1163 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1164 })
1165 .unwrap_or_else(|| "unknown".to_string());
1166 (path_str, build_time)
1167 })
1168 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1169
1170 let working_dir = std::env::current_dir()
1171 .ok()
1172 .and_then(|p| p.to_str().map(|s| s.to_string()))
1173 .unwrap_or_else(|| "unknown".to_string());
1174
1175 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1176 log::info!("Working directory: {working_dir}");
1177
1178 self.client
1179 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1180 .await;
1181
1182 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1184 log::warn!("Failed to trigger initial workspace indexing");
1185 } else {
1186 log::info!("Triggered initial workspace indexing for cross-file analysis");
1187 }
1188
1189 let markdown_patterns = [
1192 "**/*.md",
1193 "**/*.markdown",
1194 "**/*.mdx",
1195 "**/*.mkd",
1196 "**/*.mkdn",
1197 "**/*.mdown",
1198 "**/*.mdwn",
1199 "**/*.qmd",
1200 "**/*.rmd",
1201 ];
1202 let watchers: Vec<_> = markdown_patterns
1203 .iter()
1204 .map(|pattern| FileSystemWatcher {
1205 glob_pattern: GlobPattern::String((*pattern).to_string()),
1206 kind: Some(WatchKind::all()),
1207 })
1208 .collect();
1209
1210 let registration = Registration {
1211 id: "markdown-watcher".to_string(),
1212 method: "workspace/didChangeWatchedFiles".to_string(),
1213 register_options: Some(
1214 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1215 ),
1216 };
1217
1218 if self.client.register_capability(vec![registration]).await.is_err() {
1219 log::debug!("Client does not support file watching capability");
1220 }
1221 }
1222
1223 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1224 let mut roots = self.workspace_roots.write().await;
1226
1227 for removed in ¶ms.event.removed {
1229 if let Ok(path) = removed.uri.to_file_path() {
1230 roots.retain(|r| r != &path);
1231 log::info!("Removed workspace root: {}", path.display());
1232 }
1233 }
1234
1235 for added in ¶ms.event.added {
1237 if let Ok(path) = added.uri.to_file_path()
1238 && !roots.contains(&path)
1239 {
1240 log::info!("Added workspace root: {}", path.display());
1241 roots.push(path);
1242 }
1243 }
1244 drop(roots);
1245
1246 self.config_cache.write().await.clear();
1248
1249 self.reload_configuration().await;
1251
1252 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1254 log::warn!("Failed to trigger workspace rescan after folder change");
1255 }
1256 }
1257
1258 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1259 log::debug!("Configuration changed: {:?}", params.settings);
1260
1261 let settings_value = params.settings;
1265
1266 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1268 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1269 } else {
1270 settings_value
1271 };
1272
1273 let mut config_applied = false;
1275 let mut warnings: Vec<String> = Vec::new();
1276
1277 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1281 && (rule_settings.disable.is_some()
1282 || rule_settings.enable.is_some()
1283 || rule_settings.line_length.is_some()
1284 || !rule_settings.rules.is_empty())
1285 {
1286 if let Some(ref disable) = rule_settings.disable {
1288 for rule in disable {
1289 if !is_valid_rule_name(rule) {
1290 warnings.push(format!("Unknown rule in disable list: {rule}"));
1291 }
1292 }
1293 }
1294 if let Some(ref enable) = rule_settings.enable {
1295 for rule in enable {
1296 if !is_valid_rule_name(rule) {
1297 warnings.push(format!("Unknown rule in enable list: {rule}"));
1298 }
1299 }
1300 }
1301 for rule_name in rule_settings.rules.keys() {
1303 if !is_valid_rule_name(rule_name) {
1304 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1305 }
1306 }
1307
1308 log::info!("Applied rule settings from configuration (Neovim style)");
1309 let mut config = self.config.write().await;
1310 config.settings = Some(rule_settings);
1311 drop(config);
1312 config_applied = true;
1313 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1314 && (full_config.config_path.is_some()
1315 || full_config.enable_rules.is_some()
1316 || full_config.disable_rules.is_some()
1317 || full_config.settings.is_some()
1318 || !full_config.enable_linting
1319 || full_config.enable_auto_fix)
1320 {
1321 if let Some(ref rules) = full_config.enable_rules {
1323 for rule in rules {
1324 if !is_valid_rule_name(rule) {
1325 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1326 }
1327 }
1328 }
1329 if let Some(ref rules) = full_config.disable_rules {
1330 for rule in rules {
1331 if !is_valid_rule_name(rule) {
1332 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1333 }
1334 }
1335 }
1336
1337 log::info!("Applied full LSP configuration from settings");
1338 *self.config.write().await = full_config;
1339 config_applied = true;
1340 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1341 let mut config = self.config.write().await;
1344
1345 let mut rules = std::collections::HashMap::new();
1347 let mut disable = Vec::new();
1348 let mut enable = Vec::new();
1349 let mut line_length = None;
1350
1351 for (key, value) in obj {
1352 match key.as_str() {
1353 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1354 Ok(d) => {
1355 if d.len() > MAX_RULE_LIST_SIZE {
1356 warnings.push(format!(
1357 "Too many rules in 'disable' ({} > {}), truncating",
1358 d.len(),
1359 MAX_RULE_LIST_SIZE
1360 ));
1361 }
1362 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1363 if !is_valid_rule_name(rule) {
1364 warnings.push(format!("Unknown rule in disable: {rule}"));
1365 }
1366 }
1367 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1368 }
1369 Err(_) => {
1370 warnings.push(format!(
1371 "Invalid 'disable' value: expected array of strings, got {value}"
1372 ));
1373 }
1374 },
1375 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1376 Ok(e) => {
1377 if e.len() > MAX_RULE_LIST_SIZE {
1378 warnings.push(format!(
1379 "Too many rules in 'enable' ({} > {}), truncating",
1380 e.len(),
1381 MAX_RULE_LIST_SIZE
1382 ));
1383 }
1384 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1385 if !is_valid_rule_name(rule) {
1386 warnings.push(format!("Unknown rule in enable: {rule}"));
1387 }
1388 }
1389 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1390 }
1391 Err(_) => {
1392 warnings.push(format!(
1393 "Invalid 'enable' value: expected array of strings, got {value}"
1394 ));
1395 }
1396 },
1397 "lineLength" | "line_length" | "line-length" => {
1398 if let Some(l) = value.as_u64() {
1399 match usize::try_from(l) {
1400 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1401 Ok(len) => warnings.push(format!(
1402 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1403 )),
1404 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1405 }
1406 } else {
1407 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1408 }
1409 }
1410 _ if key.starts_with("MD") || key.starts_with("md") => {
1412 let normalized = key.to_uppercase();
1413 if !is_valid_rule_name(&normalized) {
1414 warnings.push(format!("Unknown rule: {key}"));
1415 }
1416 rules.insert(normalized, value);
1417 }
1418 _ => {
1419 warnings.push(format!("Unknown configuration key: {key}"));
1421 }
1422 }
1423 }
1424
1425 let settings = LspRuleSettings {
1426 line_length,
1427 disable: if disable.is_empty() { None } else { Some(disable) },
1428 enable: if enable.is_empty() { None } else { Some(enable) },
1429 rules,
1430 };
1431
1432 log::info!("Applied Neovim-style rule settings (manual parse)");
1433 config.settings = Some(settings);
1434 drop(config);
1435 config_applied = true;
1436 } else {
1437 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1438 }
1439
1440 for warning in &warnings {
1442 log::warn!("{warning}");
1443 }
1444
1445 if !warnings.is_empty() {
1447 let message = if warnings.len() == 1 {
1448 format!("rumdl: {}", warnings[0])
1449 } else {
1450 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1451 };
1452 self.client.log_message(MessageType::WARNING, message).await;
1453 }
1454
1455 if !config_applied {
1456 log::debug!("No configuration changes applied");
1457 }
1458
1459 self.config_cache.write().await.clear();
1461
1462 let doc_list: Vec<_> = {
1464 let documents = self.documents.read().await;
1465 documents
1466 .iter()
1467 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1468 .collect()
1469 };
1470
1471 let tasks = doc_list.into_iter().map(|(uri, text)| {
1473 let server = self.clone();
1474 tokio::spawn(async move {
1475 server.update_diagnostics(uri, text).await;
1476 })
1477 });
1478
1479 let _ = join_all(tasks).await;
1481 }
1482
1483 async fn shutdown(&self) -> JsonRpcResult<()> {
1484 log::info!("Shutting down rumdl Language Server");
1485
1486 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1488
1489 Ok(())
1490 }
1491
1492 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1493 let uri = params.text_document.uri;
1494 let text = params.text_document.text;
1495 let version = params.text_document.version;
1496
1497 let entry = DocumentEntry {
1498 content: text.clone(),
1499 version: Some(version),
1500 from_disk: false,
1501 };
1502 self.documents.write().await.insert(uri.clone(), entry);
1503
1504 if let Ok(path) = uri.to_file_path() {
1506 let _ = self
1507 .update_tx
1508 .send(IndexUpdate::FileChanged {
1509 path,
1510 content: text.clone(),
1511 })
1512 .await;
1513 }
1514
1515 self.update_diagnostics(uri, text).await;
1516 }
1517
1518 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1519 let uri = params.text_document.uri;
1520 let version = params.text_document.version;
1521
1522 if let Some(change) = params.content_changes.into_iter().next() {
1523 let text = change.text;
1524
1525 let entry = DocumentEntry {
1526 content: text.clone(),
1527 version: Some(version),
1528 from_disk: false,
1529 };
1530 self.documents.write().await.insert(uri.clone(), entry);
1531
1532 if let Ok(path) = uri.to_file_path() {
1534 let _ = self
1535 .update_tx
1536 .send(IndexUpdate::FileChanged {
1537 path,
1538 content: text.clone(),
1539 })
1540 .await;
1541 }
1542
1543 self.update_diagnostics(uri, text).await;
1544 }
1545 }
1546
1547 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1548 let config_guard = self.config.read().await;
1549 let enable_auto_fix = config_guard.enable_auto_fix;
1550 drop(config_guard);
1551
1552 if !enable_auto_fix {
1553 return Ok(None);
1554 }
1555
1556 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1558 return Ok(None);
1559 };
1560
1561 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1563 Ok(Some(fixed_text)) => {
1564 Ok(Some(vec![TextEdit {
1566 range: Range {
1567 start: Position { line: 0, character: 0 },
1568 end: self.get_end_position(&text),
1569 },
1570 new_text: fixed_text,
1571 }]))
1572 }
1573 Ok(None) => Ok(None),
1574 Err(e) => {
1575 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1576 Ok(None)
1577 }
1578 }
1579 }
1580
1581 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1582 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1585 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1586 .await;
1587 }
1588 }
1589
1590 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1591 self.documents.write().await.remove(¶ms.text_document.uri);
1593
1594 self.client
1597 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1598 .await;
1599 }
1600
1601 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1602 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1604
1605 let mut config_changed = false;
1606
1607 for change in ¶ms.changes {
1608 if let Ok(path) = change.uri.to_file_path() {
1609 let file_name = path.file_name().and_then(|f| f.to_str());
1610 let extension = path.extension().and_then(|e| e.to_str());
1611
1612 if let Some(name) = file_name
1614 && CONFIG_FILES.contains(&name)
1615 && !config_changed
1616 {
1617 log::info!("Config file changed: {}, invalidating config cache", path.display());
1618
1619 let mut cache = self.config_cache.write().await;
1621 cache.retain(|_, entry| {
1622 if let Some(config_file) = &entry.config_file {
1623 config_file != &path
1624 } else {
1625 true
1626 }
1627 });
1628
1629 drop(cache);
1631 self.reload_configuration().await;
1632 config_changed = true;
1633 }
1634
1635 if let Some(ext) = extension
1637 && is_markdown_extension(ext)
1638 {
1639 match change.typ {
1640 FileChangeType::CREATED | FileChangeType::CHANGED => {
1641 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1643 let _ = self
1644 .update_tx
1645 .send(IndexUpdate::FileChanged {
1646 path: path.clone(),
1647 content,
1648 })
1649 .await;
1650 }
1651 }
1652 FileChangeType::DELETED => {
1653 let _ = self
1654 .update_tx
1655 .send(IndexUpdate::FileDeleted { path: path.clone() })
1656 .await;
1657 }
1658 _ => {}
1659 }
1660 }
1661 }
1662 }
1663
1664 if config_changed {
1666 let docs_to_update: Vec<(Url, String)> = {
1667 let docs = self.documents.read().await;
1668 docs.iter()
1669 .filter(|(_, entry)| !entry.from_disk)
1670 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1671 .collect()
1672 };
1673
1674 for (uri, text) in docs_to_update {
1675 self.update_diagnostics(uri, text).await;
1676 }
1677 }
1678 }
1679
1680 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1681 let uri = params.text_document.uri;
1682 let range = params.range;
1683
1684 if let Some(text) = self.get_document_content(&uri).await {
1685 match self.get_code_actions(&uri, &text, range).await {
1686 Ok(actions) => {
1687 let response: Vec<CodeActionOrCommand> =
1688 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1689 Ok(Some(response))
1690 }
1691 Err(e) => {
1692 log::error!("Failed to get code actions: {e}");
1693 Ok(None)
1694 }
1695 }
1696 } else {
1697 Ok(None)
1698 }
1699 }
1700
1701 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1702 log::debug!(
1707 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1708 params.range
1709 );
1710
1711 let formatting_params = DocumentFormattingParams {
1712 text_document: params.text_document,
1713 options: params.options,
1714 work_done_progress_params: params.work_done_progress_params,
1715 };
1716
1717 self.formatting(formatting_params).await
1718 }
1719
1720 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1721 let uri = params.text_document.uri;
1722 let options = params.options;
1723
1724 log::debug!("Formatting request for: {uri}");
1725 log::debug!(
1726 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1727 options.insert_final_newline,
1728 options.trim_final_newlines,
1729 options.trim_trailing_whitespace
1730 );
1731
1732 if let Some(text) = self.get_document_content(&uri).await {
1733 let config_guard = self.config.read().await;
1735 let lsp_config = config_guard.clone();
1736 drop(config_guard);
1737
1738 let file_config = if let Ok(file_path) = uri.to_file_path() {
1740 self.resolve_config_for_file(&file_path).await
1741 } else {
1742 self.rumdl_config.read().await.clone()
1744 };
1745
1746 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1748
1749 let all_rules = rules::all_rules(&rumdl_config);
1750 let flavor = rumdl_config.markdown_flavor();
1751
1752 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1754
1755 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1757
1758 let mut result = text.clone();
1760 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1761 Ok(warnings) => {
1762 log::debug!(
1763 "Found {} warnings, {} with fixes",
1764 warnings.len(),
1765 warnings.iter().filter(|w| w.fix.is_some()).count()
1766 );
1767
1768 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1769 if has_fixes {
1770 let fixable_warnings: Vec<_> = warnings
1772 .iter()
1773 .filter(|w| {
1774 if let Some(rule_name) = &w.rule_name {
1775 filtered_rules
1776 .iter()
1777 .find(|r| r.name() == rule_name)
1778 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1779 .unwrap_or(false)
1780 } else {
1781 false
1782 }
1783 })
1784 .cloned()
1785 .collect();
1786
1787 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1788 Ok(fixed_content) => {
1789 result = fixed_content;
1790 }
1791 Err(e) => {
1792 log::error!("Failed to apply fixes: {e}");
1793 }
1794 }
1795 }
1796 }
1797 Err(e) => {
1798 log::error!("Failed to lint document: {e}");
1799 }
1800 }
1801
1802 result = Self::apply_formatting_options(result, &options);
1805
1806 if result != text {
1808 log::debug!("Returning formatting edits");
1809 let end_position = self.get_end_position(&text);
1810 let edit = TextEdit {
1811 range: Range {
1812 start: Position { line: 0, character: 0 },
1813 end: end_position,
1814 },
1815 new_text: result,
1816 };
1817 return Ok(Some(vec![edit]));
1818 }
1819
1820 Ok(Some(Vec::new()))
1821 } else {
1822 log::warn!("Document not found: {uri}");
1823 Ok(None)
1824 }
1825 }
1826
1827 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1828 let uri = params.text_document.uri;
1829
1830 if let Some(text) = self.get_open_document_content(&uri).await {
1831 match self.lint_document(&uri, &text).await {
1832 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1833 RelatedFullDocumentDiagnosticReport {
1834 related_documents: None,
1835 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1836 result_id: None,
1837 items: diagnostics,
1838 },
1839 },
1840 ))),
1841 Err(e) => {
1842 log::error!("Failed to get diagnostics: {e}");
1843 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1844 RelatedFullDocumentDiagnosticReport {
1845 related_documents: None,
1846 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1847 result_id: None,
1848 items: Vec::new(),
1849 },
1850 },
1851 )))
1852 }
1853 }
1854 } else {
1855 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1856 RelatedFullDocumentDiagnosticReport {
1857 related_documents: None,
1858 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1859 result_id: None,
1860 items: Vec::new(),
1861 },
1862 },
1863 )))
1864 }
1865 }
1866}
1867
1868#[cfg(test)]
1869mod tests {
1870 use super::*;
1871 use crate::rule::LintWarning;
1872 use tower_lsp::LspService;
1873
1874 fn create_test_server() -> RumdlLanguageServer {
1875 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1876 service.inner().clone()
1877 }
1878
1879 #[test]
1880 fn test_is_valid_rule_name() {
1881 assert!(is_valid_rule_name("MD001"));
1883 assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
1887 assert!(is_valid_rule_name("MD005"));
1888 assert!(is_valid_rule_name("MD007"));
1889 assert!(is_valid_rule_name("MD009"));
1890 assert!(is_valid_rule_name("MD041"));
1891 assert!(is_valid_rule_name("MD060"));
1892 assert!(is_valid_rule_name("MD061"));
1893
1894 assert!(is_valid_rule_name("all"));
1896 assert!(is_valid_rule_name("ALL"));
1897 assert!(is_valid_rule_name("All"));
1898
1899 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"));
1912 assert!(!is_valid_rule_name("not-a-rule"));
1913 assert!(!is_valid_rule_name(""));
1914 assert!(!is_valid_rule_name("random-text"));
1915 }
1916
1917 #[tokio::test]
1918 async fn test_server_creation() {
1919 let server = create_test_server();
1920
1921 let config = server.config.read().await;
1923 assert!(config.enable_linting);
1924 assert!(!config.enable_auto_fix);
1925 }
1926
1927 #[tokio::test]
1928 async fn test_lint_document() {
1929 let server = create_test_server();
1930
1931 let uri = Url::parse("file:///test.md").unwrap();
1933 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1934
1935 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1936
1937 assert!(!diagnostics.is_empty());
1939 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1940 }
1941
1942 #[tokio::test]
1943 async fn test_lint_document_disabled() {
1944 let server = create_test_server();
1945
1946 server.config.write().await.enable_linting = false;
1948
1949 let uri = Url::parse("file:///test.md").unwrap();
1950 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1951
1952 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1953
1954 assert!(diagnostics.is_empty());
1956 }
1957
1958 #[tokio::test]
1959 async fn test_get_code_actions() {
1960 let server = create_test_server();
1961
1962 let uri = Url::parse("file:///test.md").unwrap();
1963 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1964
1965 let range = Range {
1967 start: Position { line: 0, character: 0 },
1968 end: Position { line: 3, character: 21 },
1969 };
1970
1971 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1972
1973 assert!(!actions.is_empty());
1975 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1976 }
1977
1978 #[tokio::test]
1979 async fn test_get_code_actions_outside_range() {
1980 let server = create_test_server();
1981
1982 let uri = Url::parse("file:///test.md").unwrap();
1983 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1984
1985 let range = Range {
1987 start: Position { line: 0, character: 0 },
1988 end: Position { line: 0, character: 6 },
1989 };
1990
1991 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1992
1993 assert!(actions.is_empty());
1995 }
1996
1997 #[tokio::test]
1998 async fn test_document_storage() {
1999 let server = create_test_server();
2000
2001 let uri = Url::parse("file:///test.md").unwrap();
2002 let text = "# Test Document";
2003
2004 let entry = DocumentEntry {
2006 content: text.to_string(),
2007 version: Some(1),
2008 from_disk: false,
2009 };
2010 server.documents.write().await.insert(uri.clone(), entry);
2011
2012 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
2014 assert_eq!(stored, Some(text.to_string()));
2015
2016 server.documents.write().await.remove(&uri);
2018
2019 let stored = server.documents.read().await.get(&uri).cloned();
2021 assert_eq!(stored, None);
2022 }
2023
2024 #[tokio::test]
2025 async fn test_configuration_loading() {
2026 let server = create_test_server();
2027
2028 server.load_configuration(false).await;
2030
2031 let rumdl_config = server.rumdl_config.read().await;
2034 drop(rumdl_config); }
2037
2038 #[tokio::test]
2039 async fn test_load_config_for_lsp() {
2040 let result = RumdlLanguageServer::load_config_for_lsp(None);
2042 assert!(result.is_ok());
2043
2044 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2046 assert!(result.is_err());
2047 }
2048
2049 #[tokio::test]
2050 async fn test_warning_conversion() {
2051 let warning = LintWarning {
2052 message: "Test warning".to_string(),
2053 line: 1,
2054 column: 1,
2055 end_line: 1,
2056 end_column: 10,
2057 severity: crate::rule::Severity::Warning,
2058 fix: None,
2059 rule_name: Some("MD001".to_string()),
2060 };
2061
2062 let diagnostic = warning_to_diagnostic(&warning);
2064 assert_eq!(diagnostic.message, "Test warning");
2065 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2066 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2067
2068 let uri = Url::parse("file:///test.md").unwrap();
2070 let actions = warning_to_code_actions(&warning, &uri, "Test content");
2071 assert_eq!(actions.len(), 1);
2073 assert_eq!(actions[0].title, "Ignore MD001 for this line");
2074 }
2075
2076 #[tokio::test]
2077 async fn test_multiple_documents() {
2078 let server = create_test_server();
2079
2080 let uri1 = Url::parse("file:///test1.md").unwrap();
2081 let uri2 = Url::parse("file:///test2.md").unwrap();
2082 let text1 = "# Document 1";
2083 let text2 = "# Document 2";
2084
2085 {
2087 let mut docs = server.documents.write().await;
2088 let entry1 = DocumentEntry {
2089 content: text1.to_string(),
2090 version: Some(1),
2091 from_disk: false,
2092 };
2093 let entry2 = DocumentEntry {
2094 content: text2.to_string(),
2095 version: Some(1),
2096 from_disk: false,
2097 };
2098 docs.insert(uri1.clone(), entry1);
2099 docs.insert(uri2.clone(), entry2);
2100 }
2101
2102 let docs = server.documents.read().await;
2104 assert_eq!(docs.len(), 2);
2105 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2106 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2107 }
2108
2109 #[tokio::test]
2110 async fn test_auto_fix_on_save() {
2111 let server = create_test_server();
2112
2113 {
2115 let mut config = server.config.write().await;
2116 config.enable_auto_fix = true;
2117 }
2118
2119 let uri = Url::parse("file:///test.md").unwrap();
2120 let text = "#Heading without space"; let entry = DocumentEntry {
2124 content: text.to_string(),
2125 version: Some(1),
2126 from_disk: false,
2127 };
2128 server.documents.write().await.insert(uri.clone(), entry);
2129
2130 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2132 assert!(fixed.is_some());
2133 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2135 }
2136
2137 #[tokio::test]
2138 async fn test_get_end_position() {
2139 let server = create_test_server();
2140
2141 let pos = server.get_end_position("Hello");
2143 assert_eq!(pos.line, 0);
2144 assert_eq!(pos.character, 5);
2145
2146 let pos = server.get_end_position("Hello\nWorld\nTest");
2148 assert_eq!(pos.line, 2);
2149 assert_eq!(pos.character, 4);
2150
2151 let pos = server.get_end_position("");
2153 assert_eq!(pos.line, 0);
2154 assert_eq!(pos.character, 0);
2155
2156 let pos = server.get_end_position("Hello\n");
2158 assert_eq!(pos.line, 1);
2159 assert_eq!(pos.character, 0);
2160 }
2161
2162 #[tokio::test]
2163 async fn test_empty_document_handling() {
2164 let server = create_test_server();
2165
2166 let uri = Url::parse("file:///empty.md").unwrap();
2167 let text = "";
2168
2169 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2171 assert!(diagnostics.is_empty());
2172
2173 let range = Range {
2175 start: Position { line: 0, character: 0 },
2176 end: Position { line: 0, character: 0 },
2177 };
2178 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2179 assert!(actions.is_empty());
2180 }
2181
2182 #[tokio::test]
2183 async fn test_config_update() {
2184 let server = create_test_server();
2185
2186 {
2188 let mut config = server.config.write().await;
2189 config.enable_auto_fix = true;
2190 config.config_path = Some("/custom/path.toml".to_string());
2191 }
2192
2193 let config = server.config.read().await;
2195 assert!(config.enable_auto_fix);
2196 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2197 }
2198
2199 #[tokio::test]
2200 async fn test_document_formatting() {
2201 let server = create_test_server();
2202 let uri = Url::parse("file:///test.md").unwrap();
2203 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2204
2205 let entry = DocumentEntry {
2207 content: text.to_string(),
2208 version: Some(1),
2209 from_disk: false,
2210 };
2211 server.documents.write().await.insert(uri.clone(), entry);
2212
2213 let params = DocumentFormattingParams {
2215 text_document: TextDocumentIdentifier { uri: uri.clone() },
2216 options: FormattingOptions {
2217 tab_size: 4,
2218 insert_spaces: true,
2219 properties: HashMap::new(),
2220 trim_trailing_whitespace: Some(true),
2221 insert_final_newline: Some(true),
2222 trim_final_newlines: Some(true),
2223 },
2224 work_done_progress_params: WorkDoneProgressParams::default(),
2225 };
2226
2227 let result = server.formatting(params).await.unwrap();
2229
2230 assert!(result.is_some());
2232 let edits = result.unwrap();
2233 assert!(!edits.is_empty());
2234
2235 let edit = &edits[0];
2238 let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
2242 assert_eq!(edit.new_text, expected);
2243 }
2244
2245 #[tokio::test]
2248 async fn test_unfixable_rules_excluded_from_formatting() {
2249 let server = create_test_server();
2250 let uri = Url::parse("file:///test.md").unwrap();
2251
2252 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2254
2255 let entry = DocumentEntry {
2257 content: text.to_string(),
2258 version: Some(1),
2259 from_disk: false,
2260 };
2261 server.documents.write().await.insert(uri.clone(), entry);
2262
2263 let format_params = DocumentFormattingParams {
2265 text_document: TextDocumentIdentifier { uri: uri.clone() },
2266 options: FormattingOptions {
2267 tab_size: 4,
2268 insert_spaces: true,
2269 properties: HashMap::new(),
2270 trim_trailing_whitespace: Some(true),
2271 insert_final_newline: Some(true),
2272 trim_final_newlines: Some(true),
2273 },
2274 work_done_progress_params: WorkDoneProgressParams::default(),
2275 };
2276
2277 let format_result = server.formatting(format_params).await.unwrap();
2278 assert!(format_result.is_some(), "Should return formatting edits");
2279
2280 let edits = format_result.unwrap();
2281 assert!(!edits.is_empty(), "Should have formatting edits");
2282
2283 let formatted = &edits[0].new_text;
2284 assert!(
2285 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2286 "HTML should be preserved during formatting (Unfixable rule)"
2287 );
2288 assert!(
2289 !formatted.contains("spaces "),
2290 "Trailing spaces should be removed (fixable rule)"
2291 );
2292
2293 let range = Range {
2295 start: Position { line: 0, character: 0 },
2296 end: Position { line: 10, character: 0 },
2297 };
2298
2299 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2300
2301 let html_fix_actions: Vec<_> = code_actions
2303 .iter()
2304 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2305 .collect();
2306
2307 assert!(
2308 !html_fix_actions.is_empty(),
2309 "Quick Fix actions should be available for HTML (Unfixable rules)"
2310 );
2311
2312 let fix_all_actions: Vec<_> = code_actions
2314 .iter()
2315 .filter(|action| action.title.contains("Fix all"))
2316 .collect();
2317
2318 if let Some(fix_all_action) = fix_all_actions.first()
2319 && let Some(ref edit) = fix_all_action.edit
2320 && let Some(ref changes) = edit.changes
2321 && let Some(text_edits) = changes.get(&uri)
2322 && let Some(text_edit) = text_edits.first()
2323 {
2324 let fixed_all = &text_edit.new_text;
2325 assert!(
2326 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2327 "Fix All should preserve HTML (Unfixable rules)"
2328 );
2329 assert!(
2330 !fixed_all.contains("spaces "),
2331 "Fix All should remove trailing spaces (fixable rules)"
2332 );
2333 }
2334 }
2335
2336 #[tokio::test]
2338 async fn test_resolve_config_for_file_multi_root() {
2339 use std::fs;
2340 use tempfile::tempdir;
2341
2342 let temp_dir = tempdir().unwrap();
2343 let temp_path = temp_dir.path();
2344
2345 let project_a = temp_path.join("project_a");
2347 let project_a_docs = project_a.join("docs");
2348 fs::create_dir_all(&project_a_docs).unwrap();
2349
2350 let config_a = project_a.join(".rumdl.toml");
2351 fs::write(
2352 &config_a,
2353 r#"
2354[global]
2355
2356[MD013]
2357line_length = 60
2358"#,
2359 )
2360 .unwrap();
2361
2362 let project_b = temp_path.join("project_b");
2364 fs::create_dir(&project_b).unwrap();
2365
2366 let config_b = project_b.join(".rumdl.toml");
2367 fs::write(
2368 &config_b,
2369 r#"
2370[global]
2371
2372[MD013]
2373line_length = 120
2374"#,
2375 )
2376 .unwrap();
2377
2378 let server = create_test_server();
2380
2381 {
2383 let mut roots = server.workspace_roots.write().await;
2384 roots.push(project_a.clone());
2385 roots.push(project_b.clone());
2386 }
2387
2388 let file_a = project_a_docs.join("test.md");
2390 fs::write(&file_a, "# Test A\n").unwrap();
2391
2392 let config_for_a = server.resolve_config_for_file(&file_a).await;
2393 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2394 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2395
2396 let file_b = project_b.join("test.md");
2398 fs::write(&file_b, "# Test B\n").unwrap();
2399
2400 let config_for_b = server.resolve_config_for_file(&file_b).await;
2401 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2402 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2403 }
2404
2405 #[tokio::test]
2407 async fn test_config_resolution_respects_workspace_boundaries() {
2408 use std::fs;
2409 use tempfile::tempdir;
2410
2411 let temp_dir = tempdir().unwrap();
2412 let temp_path = temp_dir.path();
2413
2414 let parent_config = temp_path.join(".rumdl.toml");
2416 fs::write(
2417 &parent_config,
2418 r#"
2419[global]
2420
2421[MD013]
2422line_length = 80
2423"#,
2424 )
2425 .unwrap();
2426
2427 let workspace_root = temp_path.join("workspace");
2429 let workspace_subdir = workspace_root.join("subdir");
2430 fs::create_dir_all(&workspace_subdir).unwrap();
2431
2432 let workspace_config = workspace_root.join(".rumdl.toml");
2433 fs::write(
2434 &workspace_config,
2435 r#"
2436[global]
2437
2438[MD013]
2439line_length = 100
2440"#,
2441 )
2442 .unwrap();
2443
2444 let server = create_test_server();
2445
2446 {
2448 let mut roots = server.workspace_roots.write().await;
2449 roots.push(workspace_root.clone());
2450 }
2451
2452 let test_file = workspace_subdir.join("deep").join("test.md");
2454 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2455 fs::write(&test_file, "# Test\n").unwrap();
2456
2457 let config = server.resolve_config_for_file(&test_file).await;
2458 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2459
2460 assert_eq!(
2462 line_length,
2463 Some(100),
2464 "Should find workspace config, not parent config outside workspace"
2465 );
2466 }
2467
2468 #[tokio::test]
2470 async fn test_config_cache_hit() {
2471 use std::fs;
2472 use tempfile::tempdir;
2473
2474 let temp_dir = tempdir().unwrap();
2475 let temp_path = temp_dir.path();
2476
2477 let project = temp_path.join("project");
2478 fs::create_dir(&project).unwrap();
2479
2480 let config_file = project.join(".rumdl.toml");
2481 fs::write(
2482 &config_file,
2483 r#"
2484[global]
2485
2486[MD013]
2487line_length = 75
2488"#,
2489 )
2490 .unwrap();
2491
2492 let server = create_test_server();
2493 {
2494 let mut roots = server.workspace_roots.write().await;
2495 roots.push(project.clone());
2496 }
2497
2498 let test_file = project.join("test.md");
2499 fs::write(&test_file, "# Test\n").unwrap();
2500
2501 let config1 = server.resolve_config_for_file(&test_file).await;
2503 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2504 assert_eq!(line_length1, Some(75));
2505
2506 {
2508 let cache = server.config_cache.read().await;
2509 let search_dir = test_file.parent().unwrap();
2510 assert!(
2511 cache.contains_key(search_dir),
2512 "Cache should be populated after first call"
2513 );
2514 }
2515
2516 let config2 = server.resolve_config_for_file(&test_file).await;
2518 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2519 assert_eq!(line_length2, Some(75));
2520 }
2521
2522 #[tokio::test]
2524 async fn test_nested_directory_config_search() {
2525 use std::fs;
2526 use tempfile::tempdir;
2527
2528 let temp_dir = tempdir().unwrap();
2529 let temp_path = temp_dir.path();
2530
2531 let project = temp_path.join("project");
2532 fs::create_dir(&project).unwrap();
2533
2534 let config = project.join(".rumdl.toml");
2536 fs::write(
2537 &config,
2538 r#"
2539[global]
2540
2541[MD013]
2542line_length = 110
2543"#,
2544 )
2545 .unwrap();
2546
2547 let deep_dir = project.join("src").join("docs").join("guides");
2549 fs::create_dir_all(&deep_dir).unwrap();
2550 let deep_file = deep_dir.join("test.md");
2551 fs::write(&deep_file, "# Test\n").unwrap();
2552
2553 let server = create_test_server();
2554 {
2555 let mut roots = server.workspace_roots.write().await;
2556 roots.push(project.clone());
2557 }
2558
2559 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2560 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2561
2562 assert_eq!(
2563 line_length,
2564 Some(110),
2565 "Should find config by searching upward from deep directory"
2566 );
2567 }
2568
2569 #[tokio::test]
2571 async fn test_fallback_to_default_config() {
2572 use std::fs;
2573 use tempfile::tempdir;
2574
2575 let temp_dir = tempdir().unwrap();
2576 let temp_path = temp_dir.path();
2577
2578 let project = temp_path.join("project");
2579 fs::create_dir(&project).unwrap();
2580
2581 let test_file = project.join("test.md");
2584 fs::write(&test_file, "# Test\n").unwrap();
2585
2586 let server = create_test_server();
2587 {
2588 let mut roots = server.workspace_roots.write().await;
2589 roots.push(project.clone());
2590 }
2591
2592 let config = server.resolve_config_for_file(&test_file).await;
2593
2594 assert_eq!(
2596 config.global.line_length.get(),
2597 80,
2598 "Should fall back to default config when no config file found"
2599 );
2600 }
2601
2602 #[tokio::test]
2604 async fn test_config_priority_closer_wins() {
2605 use std::fs;
2606 use tempfile::tempdir;
2607
2608 let temp_dir = tempdir().unwrap();
2609 let temp_path = temp_dir.path();
2610
2611 let project = temp_path.join("project");
2612 fs::create_dir(&project).unwrap();
2613
2614 let parent_config = project.join(".rumdl.toml");
2616 fs::write(
2617 &parent_config,
2618 r#"
2619[global]
2620
2621[MD013]
2622line_length = 100
2623"#,
2624 )
2625 .unwrap();
2626
2627 let subdir = project.join("subdir");
2629 fs::create_dir(&subdir).unwrap();
2630
2631 let subdir_config = subdir.join(".rumdl.toml");
2632 fs::write(
2633 &subdir_config,
2634 r#"
2635[global]
2636
2637[MD013]
2638line_length = 50
2639"#,
2640 )
2641 .unwrap();
2642
2643 let server = create_test_server();
2644 {
2645 let mut roots = server.workspace_roots.write().await;
2646 roots.push(project.clone());
2647 }
2648
2649 let test_file = subdir.join("test.md");
2651 fs::write(&test_file, "# Test\n").unwrap();
2652
2653 let config = server.resolve_config_for_file(&test_file).await;
2654 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2655
2656 assert_eq!(
2657 line_length,
2658 Some(50),
2659 "Closer config (subdir) should override parent config"
2660 );
2661 }
2662
2663 #[tokio::test]
2669 async fn test_issue_131_pyproject_without_rumdl_section() {
2670 use std::fs;
2671 use tempfile::tempdir;
2672
2673 let parent_dir = tempdir().unwrap();
2675
2676 let project_dir = parent_dir.path().join("project");
2678 fs::create_dir(&project_dir).unwrap();
2679
2680 fs::write(
2682 project_dir.join("pyproject.toml"),
2683 r#"
2684[project]
2685name = "test-project"
2686version = "0.1.0"
2687"#,
2688 )
2689 .unwrap();
2690
2691 fs::write(
2694 parent_dir.path().join(".rumdl.toml"),
2695 r#"
2696[global]
2697disable = ["MD013"]
2698"#,
2699 )
2700 .unwrap();
2701
2702 let test_file = project_dir.join("test.md");
2703 fs::write(&test_file, "# Test\n").unwrap();
2704
2705 let server = create_test_server();
2706
2707 {
2709 let mut roots = server.workspace_roots.write().await;
2710 roots.push(parent_dir.path().to_path_buf());
2711 }
2712
2713 let config = server.resolve_config_for_file(&test_file).await;
2715
2716 assert!(
2719 config.global.disable.contains(&"MD013".to_string()),
2720 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2721 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2722 );
2723
2724 let cache = server.config_cache.read().await;
2727 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2728
2729 assert!(
2730 cache_entry.config_file.is_some(),
2731 "Should have found a config file (parent .rumdl.toml)"
2732 );
2733
2734 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2735 assert!(
2736 found_config_path.ends_with(".rumdl.toml"),
2737 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2738 );
2739 assert!(
2740 found_config_path.parent().unwrap() == parent_dir.path(),
2741 "Should have loaded config from parent directory, not project_dir"
2742 );
2743 }
2744
2745 #[tokio::test]
2750 async fn test_issue_131_pyproject_with_rumdl_section() {
2751 use std::fs;
2752 use tempfile::tempdir;
2753
2754 let parent_dir = tempdir().unwrap();
2756
2757 let project_dir = parent_dir.path().join("project");
2759 fs::create_dir(&project_dir).unwrap();
2760
2761 fs::write(
2763 project_dir.join("pyproject.toml"),
2764 r#"
2765[project]
2766name = "test-project"
2767
2768[tool.rumdl.global]
2769disable = ["MD033"]
2770"#,
2771 )
2772 .unwrap();
2773
2774 fs::write(
2776 parent_dir.path().join(".rumdl.toml"),
2777 r#"
2778[global]
2779disable = ["MD041"]
2780"#,
2781 )
2782 .unwrap();
2783
2784 let test_file = project_dir.join("test.md");
2785 fs::write(&test_file, "# Test\n").unwrap();
2786
2787 let server = create_test_server();
2788
2789 {
2791 let mut roots = server.workspace_roots.write().await;
2792 roots.push(parent_dir.path().to_path_buf());
2793 }
2794
2795 let config = server.resolve_config_for_file(&test_file).await;
2797
2798 assert!(
2800 config.global.disable.contains(&"MD033".to_string()),
2801 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2802 Expected MD033 from project_dir pyproject.toml to be disabled."
2803 );
2804
2805 assert!(
2807 !config.global.disable.contains(&"MD041".to_string()),
2808 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2809 );
2810
2811 let cache = server.config_cache.read().await;
2813 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2814
2815 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2816
2817 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2818 assert!(
2819 found_config_path.ends_with("pyproject.toml"),
2820 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2821 );
2822 assert!(
2823 found_config_path.parent().unwrap() == project_dir,
2824 "Should have loaded pyproject.toml from project_dir, not parent"
2825 );
2826 }
2827
2828 #[tokio::test]
2833 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2834 use std::fs;
2835 use tempfile::tempdir;
2836
2837 let temp_dir = tempdir().unwrap();
2838
2839 fs::write(
2841 temp_dir.path().join("pyproject.toml"),
2842 r#"
2843[project]
2844name = "test-project"
2845
2846[tool.rumdl.global]
2847disable = ["MD022"]
2848"#,
2849 )
2850 .unwrap();
2851
2852 let test_file = temp_dir.path().join("test.md");
2853 fs::write(&test_file, "# Test\n").unwrap();
2854
2855 let server = create_test_server();
2856
2857 {
2859 let mut roots = server.workspace_roots.write().await;
2860 roots.push(temp_dir.path().to_path_buf());
2861 }
2862
2863 let config = server.resolve_config_for_file(&test_file).await;
2865
2866 assert!(
2868 config.global.disable.contains(&"MD022".to_string()),
2869 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2870 );
2871
2872 let cache = server.config_cache.read().await;
2874 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2875 assert!(
2876 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2877 "Should have loaded pyproject.toml"
2878 );
2879 }
2880
2881 #[tokio::test]
2886 async fn test_issue_182_pull_diagnostics_capability_default() {
2887 let server = create_test_server();
2888
2889 assert!(
2891 !*server.client_supports_pull_diagnostics.read().await,
2892 "Default should be false - push diagnostics by default"
2893 );
2894 }
2895
2896 #[tokio::test]
2898 async fn test_issue_182_pull_diagnostics_flag_update() {
2899 let server = create_test_server();
2900
2901 *server.client_supports_pull_diagnostics.write().await = true;
2903
2904 assert!(
2905 *server.client_supports_pull_diagnostics.read().await,
2906 "Flag should be settable to true"
2907 );
2908 }
2909
2910 #[tokio::test]
2914 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2915 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2916
2917 let caps_with_diagnostic = ClientCapabilities {
2919 text_document: Some(TextDocumentClientCapabilities {
2920 diagnostic: Some(DiagnosticClientCapabilities {
2921 dynamic_registration: Some(true),
2922 related_document_support: Some(false),
2923 }),
2924 ..Default::default()
2925 }),
2926 ..Default::default()
2927 };
2928
2929 let supports_pull = caps_with_diagnostic
2931 .text_document
2932 .as_ref()
2933 .and_then(|td| td.diagnostic.as_ref())
2934 .is_some();
2935
2936 assert!(supports_pull, "Should detect pull diagnostic support");
2937 }
2938
2939 #[tokio::test]
2941 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2942 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2943
2944 let caps_without_diagnostic = ClientCapabilities {
2946 text_document: Some(TextDocumentClientCapabilities {
2947 diagnostic: None, ..Default::default()
2949 }),
2950 ..Default::default()
2951 };
2952
2953 let supports_pull = caps_without_diagnostic
2955 .text_document
2956 .as_ref()
2957 .and_then(|td| td.diagnostic.as_ref())
2958 .is_some();
2959
2960 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2961 }
2962
2963 #[tokio::test]
2965 async fn test_issue_182_capability_detection_no_text_document() {
2966 use tower_lsp::lsp_types::ClientCapabilities;
2967
2968 let caps_no_text_doc = ClientCapabilities {
2970 text_document: None,
2971 ..Default::default()
2972 };
2973
2974 let supports_pull = caps_no_text_doc
2976 .text_document
2977 .as_ref()
2978 .and_then(|td| td.diagnostic.as_ref())
2979 .is_some();
2980
2981 assert!(
2982 !supports_pull,
2983 "Should NOT detect pull diagnostic support when text_document is None"
2984 );
2985 }
2986
2987 #[test]
2988 fn test_resource_limit_constants() {
2989 assert_eq!(MAX_RULE_LIST_SIZE, 100);
2991 assert_eq!(MAX_LINE_LENGTH, 10_000);
2992 }
2993
2994 #[test]
2995 fn test_is_valid_rule_name_edge_cases() {
2996 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")); }
3009
3010 #[tokio::test]
3019 async fn test_lsp_toml_config_parity_generic() {
3020 use crate::config::RuleConfig;
3021 use crate::rule::Severity;
3022
3023 let server = create_test_server();
3024
3025 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3029 (
3031 "severity only - error",
3032 serde_json::json!({"severity": "error"}),
3033 RuleConfig {
3034 severity: Some(Severity::Error),
3035 values: std::collections::BTreeMap::new(),
3036 },
3037 ),
3038 (
3039 "severity only - warning",
3040 serde_json::json!({"severity": "warning"}),
3041 RuleConfig {
3042 severity: Some(Severity::Warning),
3043 values: std::collections::BTreeMap::new(),
3044 },
3045 ),
3046 (
3047 "severity only - info",
3048 serde_json::json!({"severity": "info"}),
3049 RuleConfig {
3050 severity: Some(Severity::Info),
3051 values: std::collections::BTreeMap::new(),
3052 },
3053 ),
3054 (
3056 "integer value",
3057 serde_json::json!({"lineLength": 120}),
3058 RuleConfig {
3059 severity: None,
3060 values: [("line_length".to_string(), toml::Value::Integer(120))]
3061 .into_iter()
3062 .collect(),
3063 },
3064 ),
3065 (
3067 "boolean value",
3068 serde_json::json!({"enabled": true}),
3069 RuleConfig {
3070 severity: None,
3071 values: [("enabled".to_string(), toml::Value::Boolean(true))]
3072 .into_iter()
3073 .collect(),
3074 },
3075 ),
3076 (
3078 "string value",
3079 serde_json::json!({"style": "consistent"}),
3080 RuleConfig {
3081 severity: None,
3082 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3083 .into_iter()
3084 .collect(),
3085 },
3086 ),
3087 (
3089 "array value",
3090 serde_json::json!({"allowedElements": ["div", "span"]}),
3091 RuleConfig {
3092 severity: None,
3093 values: [(
3094 "allowed_elements".to_string(),
3095 toml::Value::Array(vec![
3096 toml::Value::String("div".to_string()),
3097 toml::Value::String("span".to_string()),
3098 ]),
3099 )]
3100 .into_iter()
3101 .collect(),
3102 },
3103 ),
3104 (
3106 "severity + integer",
3107 serde_json::json!({"severity": "info", "lineLength": 80}),
3108 RuleConfig {
3109 severity: Some(Severity::Info),
3110 values: [("line_length".to_string(), toml::Value::Integer(80))]
3111 .into_iter()
3112 .collect(),
3113 },
3114 ),
3115 (
3116 "severity + multiple values",
3117 serde_json::json!({
3118 "severity": "warning",
3119 "lineLength": 100,
3120 "strict": false,
3121 "style": "atx"
3122 }),
3123 RuleConfig {
3124 severity: Some(Severity::Warning),
3125 values: [
3126 ("line_length".to_string(), toml::Value::Integer(100)),
3127 ("strict".to_string(), toml::Value::Boolean(false)),
3128 ("style".to_string(), toml::Value::String("atx".to_string())),
3129 ]
3130 .into_iter()
3131 .collect(),
3132 },
3133 ),
3134 (
3136 "camelCase conversion",
3137 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3138 RuleConfig {
3139 severity: None,
3140 values: [
3141 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3142 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3143 ]
3144 .into_iter()
3145 .collect(),
3146 },
3147 ),
3148 ];
3149
3150 for (description, lsp_json, expected_toml_config) in test_configs {
3151 let mut lsp_config = crate::config::Config::default();
3152 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3153
3154 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3155
3156 assert_eq!(
3158 lsp_rule.severity, expected_toml_config.severity,
3159 "Parity failure [{description}]: severity mismatch. \
3160 LSP={:?}, TOML={:?}",
3161 lsp_rule.severity, expected_toml_config.severity
3162 );
3163
3164 assert_eq!(
3166 lsp_rule.values, expected_toml_config.values,
3167 "Parity failure [{description}]: values mismatch. \
3168 LSP={:?}, TOML={:?}",
3169 lsp_rule.values, expected_toml_config.values
3170 );
3171 }
3172 }
3173
3174 #[tokio::test]
3176 async fn test_lsp_config_if_absent_preserves_existing() {
3177 use crate::config::RuleConfig;
3178 use crate::rule::Severity;
3179
3180 let server = create_test_server();
3181
3182 let mut config = crate::config::Config::default();
3184 config.rules.insert(
3185 "MD013".to_string(),
3186 RuleConfig {
3187 severity: Some(Severity::Error),
3188 values: [("line_length".to_string(), toml::Value::Integer(80))]
3189 .into_iter()
3190 .collect(),
3191 },
3192 );
3193
3194 let lsp_json = serde_json::json!({
3196 "severity": "info",
3197 "lineLength": 120
3198 });
3199 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3200
3201 let rule = config.rules.get("MD013").expect("Rule should exist");
3202
3203 assert_eq!(
3205 rule.severity,
3206 Some(Severity::Error),
3207 "Existing severity should not be overwritten"
3208 );
3209
3210 assert_eq!(
3212 rule.values.get("line_length"),
3213 Some(&toml::Value::Integer(80)),
3214 "Existing values should not be overwritten"
3215 );
3216 }
3217
3218 #[test]
3221 fn test_apply_formatting_options_insert_final_newline() {
3222 let options = FormattingOptions {
3223 tab_size: 4,
3224 insert_spaces: true,
3225 properties: HashMap::new(),
3226 trim_trailing_whitespace: None,
3227 insert_final_newline: Some(true),
3228 trim_final_newlines: None,
3229 };
3230
3231 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3233 assert_eq!(result, "hello\n");
3234
3235 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3237 assert_eq!(result, "hello\n");
3238 }
3239
3240 #[test]
3241 fn test_apply_formatting_options_trim_final_newlines() {
3242 let options = FormattingOptions {
3243 tab_size: 4,
3244 insert_spaces: true,
3245 properties: HashMap::new(),
3246 trim_trailing_whitespace: None,
3247 insert_final_newline: None,
3248 trim_final_newlines: Some(true),
3249 };
3250
3251 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3253 assert_eq!(result, "hello");
3254
3255 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3257 assert_eq!(result, "hello");
3258 }
3259
3260 #[test]
3261 fn test_apply_formatting_options_trim_and_insert_combined() {
3262 let options = FormattingOptions {
3264 tab_size: 4,
3265 insert_spaces: true,
3266 properties: HashMap::new(),
3267 trim_trailing_whitespace: None,
3268 insert_final_newline: Some(true),
3269 trim_final_newlines: Some(true),
3270 };
3271
3272 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3274 assert_eq!(result, "hello\n");
3275
3276 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3278 assert_eq!(result, "hello\n");
3279
3280 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3282 assert_eq!(result, "hello\n");
3283 }
3284
3285 #[test]
3286 fn test_apply_formatting_options_trim_trailing_whitespace() {
3287 let options = FormattingOptions {
3288 tab_size: 4,
3289 insert_spaces: true,
3290 properties: HashMap::new(),
3291 trim_trailing_whitespace: Some(true),
3292 insert_final_newline: Some(true),
3293 trim_final_newlines: None,
3294 };
3295
3296 let result = RumdlLanguageServer::apply_formatting_options("hello \nworld\t\n".to_string(), &options);
3298 assert_eq!(result, "hello\nworld\n");
3299 }
3300
3301 #[test]
3302 fn test_apply_formatting_options_issue_265_scenario() {
3303 let options = FormattingOptions {
3308 tab_size: 4,
3309 insert_spaces: true,
3310 properties: HashMap::new(),
3311 trim_trailing_whitespace: None,
3312 insert_final_newline: Some(true),
3313 trim_final_newlines: Some(true),
3314 };
3315
3316 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
3318 assert_eq!(
3319 result, "hello foobar hello.\n",
3320 "Should have exactly one trailing newline"
3321 );
3322
3323 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
3325 assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
3326
3327 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
3329 assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
3330 }
3331
3332 #[test]
3333 fn test_apply_formatting_options_no_options() {
3334 let options = FormattingOptions {
3336 tab_size: 4,
3337 insert_spaces: true,
3338 properties: HashMap::new(),
3339 trim_trailing_whitespace: None,
3340 insert_final_newline: None,
3341 trim_final_newlines: None,
3342 };
3343
3344 let content = "hello \nworld\n\n\n";
3345 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3346 assert_eq!(result, content, "Content should be unchanged when no options set");
3347 }
3348
3349 #[test]
3350 fn test_apply_formatting_options_empty_content() {
3351 let options = FormattingOptions {
3352 tab_size: 4,
3353 insert_spaces: true,
3354 properties: HashMap::new(),
3355 trim_trailing_whitespace: Some(true),
3356 insert_final_newline: Some(true),
3357 trim_final_newlines: Some(true),
3358 };
3359
3360 let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
3362 assert_eq!(result, "");
3363
3364 let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
3366 assert_eq!(result, "\n");
3367 }
3368
3369 #[test]
3370 fn test_apply_formatting_options_multiline_content() {
3371 let options = FormattingOptions {
3372 tab_size: 4,
3373 insert_spaces: true,
3374 properties: HashMap::new(),
3375 trim_trailing_whitespace: Some(true),
3376 insert_final_newline: Some(true),
3377 trim_final_newlines: Some(true),
3378 };
3379
3380 let content = "# Heading \n\nParagraph \n- List item \n\n\n";
3381 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3382 assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
3383 }
3384}