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 = if let Some(ref path) = file_path {
535 rumdl_config.get_flavor_for_file(path)
536 } else {
537 rumdl_config.markdown_flavor()
538 };
539
540 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
542
543 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
545
546 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
548 Ok(warnings) => warnings,
549 Err(e) => {
550 log::error!("Failed to lint document {uri}: {e}");
551 return Ok(Vec::new());
552 }
553 };
554
555 if let Some(ref path) = file_path {
557 let index_state = self.index_state.read().await.clone();
558 if matches!(index_state, IndexState::Ready) {
559 let workspace_index = self.workspace_index.read().await;
560 if let Some(file_index) = workspace_index.get_file(path) {
561 match crate::run_cross_file_checks(
562 path,
563 file_index,
564 &filtered_rules,
565 &workspace_index,
566 Some(&rumdl_config),
567 ) {
568 Ok(cross_file_warnings) => {
569 all_warnings.extend(cross_file_warnings);
570 }
571 Err(e) => {
572 log::warn!("Failed to run cross-file checks for {uri}: {e}");
573 }
574 }
575 }
576 }
577 }
578
579 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
580 Ok(diagnostics)
581 }
582
583 async fn update_diagnostics(&self, uri: Url, text: String) {
589 if *self.client_supports_pull_diagnostics.read().await {
591 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
592 return;
593 }
594
595 let version = {
597 let docs = self.documents.read().await;
598 docs.get(&uri).and_then(|entry| entry.version)
599 };
600
601 match self.lint_document(&uri, &text).await {
602 Ok(diagnostics) => {
603 self.client.publish_diagnostics(uri, diagnostics, version).await;
604 }
605 Err(e) => {
606 log::error!("Failed to update diagnostics: {e}");
607 }
608 }
609 }
610
611 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
613 if self.should_exclude_uri(uri).await {
615 return Ok(None);
616 }
617
618 let config_guard = self.config.read().await;
619 let lsp_config = config_guard.clone();
620 drop(config_guard);
621
622 let file_path = uri.to_file_path().ok();
624 let file_config = if let Some(ref path) = file_path {
625 self.resolve_config_for_file(path).await
626 } else {
627 (*self.rumdl_config.read().await).clone()
629 };
630
631 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
633
634 let all_rules = rules::all_rules(&rumdl_config);
635 let flavor = if let Some(ref path) = file_path {
636 rumdl_config.get_flavor_for_file(path)
637 } else {
638 rumdl_config.markdown_flavor()
639 };
640
641 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
643
644 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
646
647 let mut rules_with_warnings = std::collections::HashSet::new();
650 let mut fixed_text = text.to_string();
651
652 match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
653 Ok(warnings) => {
654 for warning in warnings {
655 if let Some(rule_name) = &warning.rule_name {
656 rules_with_warnings.insert(rule_name.clone());
657 }
658 }
659 }
660 Err(e) => {
661 log::warn!("Failed to lint document for auto-fix: {e}");
662 return Ok(None);
663 }
664 }
665
666 if rules_with_warnings.is_empty() {
668 return Ok(None);
669 }
670
671 let mut any_changes = false;
673
674 for rule in &filtered_rules {
675 if !rules_with_warnings.contains(rule.name()) {
677 continue;
678 }
679
680 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
681 match rule.fix(&ctx) {
682 Ok(new_text) => {
683 if new_text != fixed_text {
684 fixed_text = new_text;
685 any_changes = true;
686 }
687 }
688 Err(e) => {
689 let msg = e.to_string();
691 if !msg.contains("does not support automatic fixing") {
692 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
693 }
694 }
695 }
696 }
697
698 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
699 }
700
701 fn get_end_position(&self, text: &str) -> Position {
703 let mut line = 0u32;
704 let mut character = 0u32;
705
706 for ch in text.chars() {
707 if ch == '\n' {
708 line += 1;
709 character = 0;
710 } else {
711 character += 1;
712 }
713 }
714
715 Position { line, character }
716 }
717
718 fn apply_formatting_options(content: String, options: &FormattingOptions) -> String {
729 if content.is_empty() {
732 return content;
733 }
734
735 let mut result = content.clone();
736 let original_ended_with_newline = content.ends_with('\n');
737
738 if options.trim_trailing_whitespace.unwrap_or(false) {
740 result = result
741 .lines()
742 .map(|line| line.trim_end())
743 .collect::<Vec<_>>()
744 .join("\n");
745 if original_ended_with_newline && !result.ends_with('\n') {
747 result.push('\n');
748 }
749 }
750
751 if options.trim_final_newlines.unwrap_or(false) {
755 while result.ends_with('\n') {
757 result.pop();
758 }
759 }
761
762 if options.insert_final_newline.unwrap_or(false) && !result.ends_with('\n') {
764 result.push('\n');
765 }
766
767 result
768 }
769
770 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
772 let config_guard = self.config.read().await;
773 let lsp_config = config_guard.clone();
774 drop(config_guard);
775
776 let file_path = uri.to_file_path().ok();
778 let file_config = if let Some(ref path) = file_path {
779 self.resolve_config_for_file(path).await
780 } else {
781 (*self.rumdl_config.read().await).clone()
783 };
784
785 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
787
788 let all_rules = rules::all_rules(&rumdl_config);
789 let flavor = if let Some(ref path) = file_path {
790 rumdl_config.get_flavor_for_file(path)
791 } else {
792 rumdl_config.markdown_flavor()
793 };
794
795 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
797
798 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
800
801 match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
802 Ok(warnings) => {
803 let mut actions = Vec::new();
804 let mut fixable_count = 0;
805
806 for warning in &warnings {
807 let warning_line = (warning.line.saturating_sub(1)) as u32;
809 if warning_line >= range.start.line && warning_line <= range.end.line {
810 let mut warning_actions = warning_to_code_actions(warning, uri, text);
812 actions.append(&mut warning_actions);
813
814 if warning.fix.is_some() {
815 fixable_count += 1;
816 }
817 }
818 }
819
820 if fixable_count > 1 {
822 let fixable_warnings: Vec<_> = warnings
825 .iter()
826 .filter(|w| {
827 if let Some(rule_name) = &w.rule_name {
828 filtered_rules
829 .iter()
830 .find(|r| r.name() == rule_name)
831 .map(|r| r.fix_capability() != FixCapability::Unfixable)
832 .unwrap_or(false)
833 } else {
834 false
835 }
836 })
837 .cloned()
838 .collect();
839
840 let total_fixable = fixable_warnings.len();
842
843 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
844 && fixed_content != text
845 {
846 let mut line = 0u32;
848 let mut character = 0u32;
849 for ch in text.chars() {
850 if ch == '\n' {
851 line += 1;
852 character = 0;
853 } else {
854 character += 1;
855 }
856 }
857
858 let fix_all_action = CodeAction {
859 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
860 kind: Some(CodeActionKind::new("source.fixAll.rumdl")),
861 diagnostics: Some(Vec::new()),
862 edit: Some(WorkspaceEdit {
863 changes: Some(
864 [(
865 uri.clone(),
866 vec![TextEdit {
867 range: Range {
868 start: Position { line: 0, character: 0 },
869 end: Position { line, character },
870 },
871 new_text: fixed_content,
872 }],
873 )]
874 .into_iter()
875 .collect(),
876 ),
877 ..Default::default()
878 }),
879 command: None,
880 is_preferred: Some(true),
881 disabled: None,
882 data: None,
883 };
884
885 actions.insert(0, fix_all_action);
887 }
888 }
889
890 Ok(actions)
891 }
892 Err(e) => {
893 log::error!("Failed to get code actions: {e}");
894 Ok(Vec::new())
895 }
896 }
897 }
898
899 async fn load_configuration(&self, notify_client: bool) {
901 let config_guard = self.config.read().await;
902 let explicit_config_path = config_guard.config_path.clone();
903 drop(config_guard);
904
905 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
907 Ok(sourced_config) => {
908 let loaded_files = sourced_config.loaded_files.clone();
909 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
911
912 if !loaded_files.is_empty() {
913 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
914 log::info!("{message}");
915 if notify_client {
916 self.client.log_message(MessageType::INFO, &message).await;
917 }
918 } else {
919 log::info!("Using default rumdl configuration (no config files found)");
920 }
921 }
922 Err(e) => {
923 let message = format!("Failed to load rumdl config: {e}");
924 log::warn!("{message}");
925 if notify_client {
926 self.client.log_message(MessageType::WARNING, &message).await;
927 }
928 *self.rumdl_config.write().await = crate::config::Config::default();
930 }
931 }
932 }
933
934 async fn reload_configuration(&self) {
936 self.load_configuration(true).await;
937 }
938
939 fn load_config_for_lsp(
941 config_path: Option<&str>,
942 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
943 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
945 }
946
947 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
954 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
956
957 {
959 let cache = self.config_cache.read().await;
960 if let Some(entry) = cache.get(&search_dir) {
961 let source_owned: String; let source: &str = if entry.from_global_fallback {
963 "global/user fallback"
964 } else if let Some(path) = &entry.config_file {
965 source_owned = path.to_string_lossy().to_string();
966 &source_owned
967 } else {
968 "<unknown>"
969 };
970 log::debug!(
971 "Config cache hit for directory: {} (loaded from: {})",
972 search_dir.display(),
973 source
974 );
975 return entry.config.clone();
976 }
977 }
978
979 log::debug!(
981 "Config cache miss for directory: {}, searching for config...",
982 search_dir.display()
983 );
984
985 let workspace_root = {
987 let workspace_roots = self.workspace_roots.read().await;
988 workspace_roots
989 .iter()
990 .find(|root| search_dir.starts_with(root))
991 .map(|p| p.to_path_buf())
992 };
993
994 let mut current_dir = search_dir.clone();
996 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
997
998 loop {
999 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1001
1002 for config_file_name in CONFIG_FILES {
1003 let config_path = current_dir.join(config_file_name);
1004 if config_path.exists() {
1005 if *config_file_name == "pyproject.toml" {
1007 if let Ok(content) = std::fs::read_to_string(&config_path) {
1008 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1009 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
1010 } else {
1011 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
1012 continue;
1013 }
1014 } else {
1015 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
1016 continue;
1017 }
1018 } else {
1019 log::debug!("Found config file: {}", config_path.display());
1020 }
1021
1022 if let Some(config_path_str) = config_path.to_str() {
1024 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
1025 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
1026 break;
1027 }
1028 } else {
1029 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
1030 }
1031 }
1032 }
1033
1034 if found_config.is_some() {
1035 break;
1036 }
1037
1038 if let Some(ref root) = workspace_root
1040 && ¤t_dir == root
1041 {
1042 log::debug!("Hit workspace root without finding config: {}", root.display());
1043 break;
1044 }
1045
1046 if let Some(parent) = current_dir.parent() {
1048 current_dir = parent.to_path_buf();
1049 } else {
1050 break;
1052 }
1053 }
1054
1055 let (config, config_file) = if let Some((cfg, path)) = found_config {
1057 (cfg, path)
1058 } else {
1059 log::debug!("No project config found; using global/user fallback config");
1060 let fallback = self.rumdl_config.read().await.clone();
1061 (fallback, None)
1062 };
1063
1064 let from_global = config_file.is_none();
1066 let entry = ConfigCacheEntry {
1067 config: config.clone(),
1068 config_file,
1069 from_global_fallback: from_global,
1070 };
1071
1072 self.config_cache.write().await.insert(search_dir, entry);
1073
1074 config
1075 }
1076}
1077
1078#[tower_lsp::async_trait]
1079impl LanguageServer for RumdlLanguageServer {
1080 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1081 log::info!("Initializing rumdl Language Server");
1082
1083 if let Some(options) = params.initialization_options
1085 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
1086 {
1087 *self.config.write().await = config;
1088 }
1089
1090 let supports_pull = params
1093 .capabilities
1094 .text_document
1095 .as_ref()
1096 .and_then(|td| td.diagnostic.as_ref())
1097 .is_some();
1098
1099 if supports_pull {
1100 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1101 *self.client_supports_pull_diagnostics.write().await = true;
1102 } else {
1103 log::info!("Client does not support pull diagnostics - using push model");
1104 }
1105
1106 let mut roots = Vec::new();
1108 if let Some(workspace_folders) = params.workspace_folders {
1109 for folder in workspace_folders {
1110 if let Ok(path) = folder.uri.to_file_path() {
1111 log::info!("Workspace root: {}", path.display());
1112 roots.push(path);
1113 }
1114 }
1115 } else if let Some(root_uri) = params.root_uri
1116 && let Ok(path) = root_uri.to_file_path()
1117 {
1118 log::info!("Workspace root: {}", path.display());
1119 roots.push(path);
1120 }
1121 *self.workspace_roots.write().await = roots;
1122
1123 self.load_configuration(false).await;
1125
1126 Ok(InitializeResult {
1127 capabilities: ServerCapabilities {
1128 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1129 open_close: Some(true),
1130 change: Some(TextDocumentSyncKind::FULL),
1131 will_save: Some(false),
1132 will_save_wait_until: Some(true),
1133 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1134 include_text: Some(false),
1135 })),
1136 })),
1137 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
1138 code_action_kinds: Some(vec![
1139 CodeActionKind::QUICKFIX,
1140 CodeActionKind::SOURCE_FIX_ALL,
1141 CodeActionKind::new("source.fixAll.rumdl"),
1142 ]),
1143 work_done_progress_options: WorkDoneProgressOptions::default(),
1144 resolve_provider: None,
1145 })),
1146 document_formatting_provider: Some(OneOf::Left(true)),
1147 document_range_formatting_provider: Some(OneOf::Left(true)),
1148 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1149 identifier: Some("rumdl".to_string()),
1150 inter_file_dependencies: true,
1151 workspace_diagnostics: false,
1152 work_done_progress_options: WorkDoneProgressOptions::default(),
1153 })),
1154 workspace: Some(WorkspaceServerCapabilities {
1155 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1156 supported: Some(true),
1157 change_notifications: Some(OneOf::Left(true)),
1158 }),
1159 file_operations: None,
1160 }),
1161 ..Default::default()
1162 },
1163 server_info: Some(ServerInfo {
1164 name: "rumdl".to_string(),
1165 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1166 }),
1167 })
1168 }
1169
1170 async fn initialized(&self, _: InitializedParams) {
1171 let version = env!("CARGO_PKG_VERSION");
1172
1173 let (binary_path, build_time) = std::env::current_exe()
1175 .ok()
1176 .map(|path| {
1177 let path_str = path.to_str().unwrap_or("unknown").to_string();
1178 let build_time = std::fs::metadata(&path)
1179 .ok()
1180 .and_then(|metadata| metadata.modified().ok())
1181 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1182 .and_then(|duration| {
1183 let secs = duration.as_secs();
1184 chrono::DateTime::from_timestamp(secs as i64, 0)
1185 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1186 })
1187 .unwrap_or_else(|| "unknown".to_string());
1188 (path_str, build_time)
1189 })
1190 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1191
1192 let working_dir = std::env::current_dir()
1193 .ok()
1194 .and_then(|p| p.to_str().map(|s| s.to_string()))
1195 .unwrap_or_else(|| "unknown".to_string());
1196
1197 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1198 log::info!("Working directory: {working_dir}");
1199
1200 self.client
1201 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1202 .await;
1203
1204 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1206 log::warn!("Failed to trigger initial workspace indexing");
1207 } else {
1208 log::info!("Triggered initial workspace indexing for cross-file analysis");
1209 }
1210
1211 let markdown_patterns = [
1214 "**/*.md",
1215 "**/*.markdown",
1216 "**/*.mdx",
1217 "**/*.mkd",
1218 "**/*.mkdn",
1219 "**/*.mdown",
1220 "**/*.mdwn",
1221 "**/*.qmd",
1222 "**/*.rmd",
1223 ];
1224 let watchers: Vec<_> = markdown_patterns
1225 .iter()
1226 .map(|pattern| FileSystemWatcher {
1227 glob_pattern: GlobPattern::String((*pattern).to_string()),
1228 kind: Some(WatchKind::all()),
1229 })
1230 .collect();
1231
1232 let registration = Registration {
1233 id: "markdown-watcher".to_string(),
1234 method: "workspace/didChangeWatchedFiles".to_string(),
1235 register_options: Some(
1236 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1237 ),
1238 };
1239
1240 if self.client.register_capability(vec![registration]).await.is_err() {
1241 log::debug!("Client does not support file watching capability");
1242 }
1243 }
1244
1245 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1246 let mut roots = self.workspace_roots.write().await;
1248
1249 for removed in ¶ms.event.removed {
1251 if let Ok(path) = removed.uri.to_file_path() {
1252 roots.retain(|r| r != &path);
1253 log::info!("Removed workspace root: {}", path.display());
1254 }
1255 }
1256
1257 for added in ¶ms.event.added {
1259 if let Ok(path) = added.uri.to_file_path()
1260 && !roots.contains(&path)
1261 {
1262 log::info!("Added workspace root: {}", path.display());
1263 roots.push(path);
1264 }
1265 }
1266 drop(roots);
1267
1268 self.config_cache.write().await.clear();
1270
1271 self.reload_configuration().await;
1273
1274 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1276 log::warn!("Failed to trigger workspace rescan after folder change");
1277 }
1278 }
1279
1280 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1281 log::debug!("Configuration changed: {:?}", params.settings);
1282
1283 let settings_value = params.settings;
1287
1288 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1290 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1291 } else {
1292 settings_value
1293 };
1294
1295 let mut config_applied = false;
1297 let mut warnings: Vec<String> = Vec::new();
1298
1299 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1303 && (rule_settings.disable.is_some()
1304 || rule_settings.enable.is_some()
1305 || rule_settings.line_length.is_some()
1306 || !rule_settings.rules.is_empty())
1307 {
1308 if let Some(ref disable) = rule_settings.disable {
1310 for rule in disable {
1311 if !is_valid_rule_name(rule) {
1312 warnings.push(format!("Unknown rule in disable list: {rule}"));
1313 }
1314 }
1315 }
1316 if let Some(ref enable) = rule_settings.enable {
1317 for rule in enable {
1318 if !is_valid_rule_name(rule) {
1319 warnings.push(format!("Unknown rule in enable list: {rule}"));
1320 }
1321 }
1322 }
1323 for rule_name in rule_settings.rules.keys() {
1325 if !is_valid_rule_name(rule_name) {
1326 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1327 }
1328 }
1329
1330 log::info!("Applied rule settings from configuration (Neovim style)");
1331 let mut config = self.config.write().await;
1332 config.settings = Some(rule_settings);
1333 drop(config);
1334 config_applied = true;
1335 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1336 && (full_config.config_path.is_some()
1337 || full_config.enable_rules.is_some()
1338 || full_config.disable_rules.is_some()
1339 || full_config.settings.is_some()
1340 || !full_config.enable_linting
1341 || full_config.enable_auto_fix)
1342 {
1343 if let Some(ref rules) = full_config.enable_rules {
1345 for rule in rules {
1346 if !is_valid_rule_name(rule) {
1347 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1348 }
1349 }
1350 }
1351 if let Some(ref rules) = full_config.disable_rules {
1352 for rule in rules {
1353 if !is_valid_rule_name(rule) {
1354 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1355 }
1356 }
1357 }
1358
1359 log::info!("Applied full LSP configuration from settings");
1360 *self.config.write().await = full_config;
1361 config_applied = true;
1362 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1363 let mut config = self.config.write().await;
1366
1367 let mut rules = std::collections::HashMap::new();
1369 let mut disable = Vec::new();
1370 let mut enable = Vec::new();
1371 let mut line_length = None;
1372
1373 for (key, value) in obj {
1374 match key.as_str() {
1375 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1376 Ok(d) => {
1377 if d.len() > MAX_RULE_LIST_SIZE {
1378 warnings.push(format!(
1379 "Too many rules in 'disable' ({} > {}), truncating",
1380 d.len(),
1381 MAX_RULE_LIST_SIZE
1382 ));
1383 }
1384 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1385 if !is_valid_rule_name(rule) {
1386 warnings.push(format!("Unknown rule in disable: {rule}"));
1387 }
1388 }
1389 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1390 }
1391 Err(_) => {
1392 warnings.push(format!(
1393 "Invalid 'disable' value: expected array of strings, got {value}"
1394 ));
1395 }
1396 },
1397 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1398 Ok(e) => {
1399 if e.len() > MAX_RULE_LIST_SIZE {
1400 warnings.push(format!(
1401 "Too many rules in 'enable' ({} > {}), truncating",
1402 e.len(),
1403 MAX_RULE_LIST_SIZE
1404 ));
1405 }
1406 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1407 if !is_valid_rule_name(rule) {
1408 warnings.push(format!("Unknown rule in enable: {rule}"));
1409 }
1410 }
1411 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1412 }
1413 Err(_) => {
1414 warnings.push(format!(
1415 "Invalid 'enable' value: expected array of strings, got {value}"
1416 ));
1417 }
1418 },
1419 "lineLength" | "line_length" | "line-length" => {
1420 if let Some(l) = value.as_u64() {
1421 match usize::try_from(l) {
1422 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1423 Ok(len) => warnings.push(format!(
1424 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1425 )),
1426 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1427 }
1428 } else {
1429 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1430 }
1431 }
1432 _ if key.starts_with("MD") || key.starts_with("md") => {
1434 let normalized = key.to_uppercase();
1435 if !is_valid_rule_name(&normalized) {
1436 warnings.push(format!("Unknown rule: {key}"));
1437 }
1438 rules.insert(normalized, value);
1439 }
1440 _ => {
1441 warnings.push(format!("Unknown configuration key: {key}"));
1443 }
1444 }
1445 }
1446
1447 let settings = LspRuleSettings {
1448 line_length,
1449 disable: if disable.is_empty() { None } else { Some(disable) },
1450 enable: if enable.is_empty() { None } else { Some(enable) },
1451 rules,
1452 };
1453
1454 log::info!("Applied Neovim-style rule settings (manual parse)");
1455 config.settings = Some(settings);
1456 drop(config);
1457 config_applied = true;
1458 } else {
1459 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1460 }
1461
1462 for warning in &warnings {
1464 log::warn!("{warning}");
1465 }
1466
1467 if !warnings.is_empty() {
1469 let message = if warnings.len() == 1 {
1470 format!("rumdl: {}", warnings[0])
1471 } else {
1472 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1473 };
1474 self.client.log_message(MessageType::WARNING, message).await;
1475 }
1476
1477 if !config_applied {
1478 log::debug!("No configuration changes applied");
1479 }
1480
1481 self.config_cache.write().await.clear();
1483
1484 let doc_list: Vec<_> = {
1486 let documents = self.documents.read().await;
1487 documents
1488 .iter()
1489 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1490 .collect()
1491 };
1492
1493 let tasks = doc_list.into_iter().map(|(uri, text)| {
1495 let server = self.clone();
1496 tokio::spawn(async move {
1497 server.update_diagnostics(uri, text).await;
1498 })
1499 });
1500
1501 let _ = join_all(tasks).await;
1503 }
1504
1505 async fn shutdown(&self) -> JsonRpcResult<()> {
1506 log::info!("Shutting down rumdl Language Server");
1507
1508 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1510
1511 Ok(())
1512 }
1513
1514 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1515 let uri = params.text_document.uri;
1516 let text = params.text_document.text;
1517 let version = params.text_document.version;
1518
1519 let entry = DocumentEntry {
1520 content: text.clone(),
1521 version: Some(version),
1522 from_disk: false,
1523 };
1524 self.documents.write().await.insert(uri.clone(), entry);
1525
1526 if let Ok(path) = uri.to_file_path() {
1528 let _ = self
1529 .update_tx
1530 .send(IndexUpdate::FileChanged {
1531 path,
1532 content: text.clone(),
1533 })
1534 .await;
1535 }
1536
1537 self.update_diagnostics(uri, text).await;
1538 }
1539
1540 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1541 let uri = params.text_document.uri;
1542 let version = params.text_document.version;
1543
1544 if let Some(change) = params.content_changes.into_iter().next() {
1545 let text = change.text;
1546
1547 let entry = DocumentEntry {
1548 content: text.clone(),
1549 version: Some(version),
1550 from_disk: false,
1551 };
1552 self.documents.write().await.insert(uri.clone(), entry);
1553
1554 if let Ok(path) = uri.to_file_path() {
1556 let _ = self
1557 .update_tx
1558 .send(IndexUpdate::FileChanged {
1559 path,
1560 content: text.clone(),
1561 })
1562 .await;
1563 }
1564
1565 self.update_diagnostics(uri, text).await;
1566 }
1567 }
1568
1569 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1570 let config_guard = self.config.read().await;
1571 let enable_auto_fix = config_guard.enable_auto_fix;
1572 drop(config_guard);
1573
1574 if !enable_auto_fix {
1575 return Ok(None);
1576 }
1577
1578 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1580 return Ok(None);
1581 };
1582
1583 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1585 Ok(Some(fixed_text)) => {
1586 Ok(Some(vec![TextEdit {
1588 range: Range {
1589 start: Position { line: 0, character: 0 },
1590 end: self.get_end_position(&text),
1591 },
1592 new_text: fixed_text,
1593 }]))
1594 }
1595 Ok(None) => Ok(None),
1596 Err(e) => {
1597 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1598 Ok(None)
1599 }
1600 }
1601 }
1602
1603 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1604 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1607 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1608 .await;
1609 }
1610 }
1611
1612 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1613 self.documents.write().await.remove(¶ms.text_document.uri);
1615
1616 self.client
1619 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1620 .await;
1621 }
1622
1623 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1624 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1626
1627 let mut config_changed = false;
1628
1629 for change in ¶ms.changes {
1630 if let Ok(path) = change.uri.to_file_path() {
1631 let file_name = path.file_name().and_then(|f| f.to_str());
1632 let extension = path.extension().and_then(|e| e.to_str());
1633
1634 if let Some(name) = file_name
1636 && CONFIG_FILES.contains(&name)
1637 && !config_changed
1638 {
1639 log::info!("Config file changed: {}, invalidating config cache", path.display());
1640
1641 let mut cache = self.config_cache.write().await;
1643 cache.retain(|_, entry| {
1644 if let Some(config_file) = &entry.config_file {
1645 config_file != &path
1646 } else {
1647 true
1648 }
1649 });
1650
1651 drop(cache);
1653 self.reload_configuration().await;
1654 config_changed = true;
1655 }
1656
1657 if let Some(ext) = extension
1659 && is_markdown_extension(ext)
1660 {
1661 match change.typ {
1662 FileChangeType::CREATED | FileChangeType::CHANGED => {
1663 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1665 let _ = self
1666 .update_tx
1667 .send(IndexUpdate::FileChanged {
1668 path: path.clone(),
1669 content,
1670 })
1671 .await;
1672 }
1673 }
1674 FileChangeType::DELETED => {
1675 let _ = self
1676 .update_tx
1677 .send(IndexUpdate::FileDeleted { path: path.clone() })
1678 .await;
1679 }
1680 _ => {}
1681 }
1682 }
1683 }
1684 }
1685
1686 if config_changed {
1688 let docs_to_update: Vec<(Url, String)> = {
1689 let docs = self.documents.read().await;
1690 docs.iter()
1691 .filter(|(_, entry)| !entry.from_disk)
1692 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1693 .collect()
1694 };
1695
1696 for (uri, text) in docs_to_update {
1697 self.update_diagnostics(uri, text).await;
1698 }
1699 }
1700 }
1701
1702 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1703 let uri = params.text_document.uri;
1704 let range = params.range;
1705 let requested_kinds = params.context.only;
1706
1707 if let Some(text) = self.get_document_content(&uri).await {
1708 match self.get_code_actions(&uri, &text, range).await {
1709 Ok(actions) => {
1710 let filtered_actions = if let Some(ref kinds) = requested_kinds
1714 && !kinds.is_empty()
1715 {
1716 actions
1717 .into_iter()
1718 .filter(|action| {
1719 action.kind.as_ref().is_some_and(|action_kind| {
1720 let action_kind_str = action_kind.as_str();
1721 kinds.iter().any(|requested| {
1722 let requested_str = requested.as_str();
1723 action_kind_str.starts_with(requested_str)
1726 })
1727 })
1728 })
1729 .collect()
1730 } else {
1731 actions
1732 };
1733
1734 let response: Vec<CodeActionOrCommand> = filtered_actions
1735 .into_iter()
1736 .map(CodeActionOrCommand::CodeAction)
1737 .collect();
1738 Ok(Some(response))
1739 }
1740 Err(e) => {
1741 log::error!("Failed to get code actions: {e}");
1742 Ok(None)
1743 }
1744 }
1745 } else {
1746 Ok(None)
1747 }
1748 }
1749
1750 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1751 log::debug!(
1756 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1757 params.range
1758 );
1759
1760 let formatting_params = DocumentFormattingParams {
1761 text_document: params.text_document,
1762 options: params.options,
1763 work_done_progress_params: params.work_done_progress_params,
1764 };
1765
1766 self.formatting(formatting_params).await
1767 }
1768
1769 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1770 let uri = params.text_document.uri;
1771 let options = params.options;
1772
1773 log::debug!("Formatting request for: {uri}");
1774 log::debug!(
1775 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1776 options.insert_final_newline,
1777 options.trim_final_newlines,
1778 options.trim_trailing_whitespace
1779 );
1780
1781 if let Some(text) = self.get_document_content(&uri).await {
1782 let config_guard = self.config.read().await;
1784 let lsp_config = config_guard.clone();
1785 drop(config_guard);
1786
1787 let file_path = uri.to_file_path().ok();
1789 let file_config = if let Some(ref path) = file_path {
1790 self.resolve_config_for_file(path).await
1791 } else {
1792 self.rumdl_config.read().await.clone()
1794 };
1795
1796 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1798
1799 let all_rules = rules::all_rules(&rumdl_config);
1800 let flavor = if let Some(ref path) = file_path {
1801 rumdl_config.get_flavor_for_file(path)
1802 } else {
1803 rumdl_config.markdown_flavor()
1804 };
1805
1806 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1808
1809 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1811
1812 let mut result = text.clone();
1814 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1815 Ok(warnings) => {
1816 log::debug!(
1817 "Found {} warnings, {} with fixes",
1818 warnings.len(),
1819 warnings.iter().filter(|w| w.fix.is_some()).count()
1820 );
1821
1822 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1823 if has_fixes {
1824 let fixable_warnings: Vec<_> = warnings
1826 .iter()
1827 .filter(|w| {
1828 if let Some(rule_name) = &w.rule_name {
1829 filtered_rules
1830 .iter()
1831 .find(|r| r.name() == rule_name)
1832 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1833 .unwrap_or(false)
1834 } else {
1835 false
1836 }
1837 })
1838 .cloned()
1839 .collect();
1840
1841 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1842 Ok(fixed_content) => {
1843 result = fixed_content;
1844 }
1845 Err(e) => {
1846 log::error!("Failed to apply fixes: {e}");
1847 }
1848 }
1849 }
1850 }
1851 Err(e) => {
1852 log::error!("Failed to lint document: {e}");
1853 }
1854 }
1855
1856 result = Self::apply_formatting_options(result, &options);
1859
1860 if result != text {
1862 log::debug!("Returning formatting edits");
1863 let end_position = self.get_end_position(&text);
1864 let edit = TextEdit {
1865 range: Range {
1866 start: Position { line: 0, character: 0 },
1867 end: end_position,
1868 },
1869 new_text: result,
1870 };
1871 return Ok(Some(vec![edit]));
1872 }
1873
1874 Ok(Some(Vec::new()))
1875 } else {
1876 log::warn!("Document not found: {uri}");
1877 Ok(None)
1878 }
1879 }
1880
1881 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1882 let uri = params.text_document.uri;
1883
1884 if let Some(text) = self.get_open_document_content(&uri).await {
1885 match self.lint_document(&uri, &text).await {
1886 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1887 RelatedFullDocumentDiagnosticReport {
1888 related_documents: None,
1889 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1890 result_id: None,
1891 items: diagnostics,
1892 },
1893 },
1894 ))),
1895 Err(e) => {
1896 log::error!("Failed to get diagnostics: {e}");
1897 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1898 RelatedFullDocumentDiagnosticReport {
1899 related_documents: None,
1900 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1901 result_id: None,
1902 items: Vec::new(),
1903 },
1904 },
1905 )))
1906 }
1907 }
1908 } else {
1909 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1910 RelatedFullDocumentDiagnosticReport {
1911 related_documents: None,
1912 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1913 result_id: None,
1914 items: Vec::new(),
1915 },
1916 },
1917 )))
1918 }
1919 }
1920}
1921
1922#[cfg(test)]
1923mod tests {
1924 use super::*;
1925 use crate::rule::LintWarning;
1926 use tower_lsp::LspService;
1927
1928 fn create_test_server() -> RumdlLanguageServer {
1929 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1930 service.inner().clone()
1931 }
1932
1933 #[test]
1934 fn test_is_valid_rule_name() {
1935 assert!(is_valid_rule_name("MD001"));
1937 assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
1941 assert!(is_valid_rule_name("MD005"));
1942 assert!(is_valid_rule_name("MD007"));
1943 assert!(is_valid_rule_name("MD009"));
1944 assert!(is_valid_rule_name("MD041"));
1945 assert!(is_valid_rule_name("MD060"));
1946 assert!(is_valid_rule_name("MD061"));
1947
1948 assert!(is_valid_rule_name("all"));
1950 assert!(is_valid_rule_name("ALL"));
1951 assert!(is_valid_rule_name("All"));
1952
1953 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"));
1966 assert!(!is_valid_rule_name("not-a-rule"));
1967 assert!(!is_valid_rule_name(""));
1968 assert!(!is_valid_rule_name("random-text"));
1969 }
1970
1971 #[tokio::test]
1972 async fn test_server_creation() {
1973 let server = create_test_server();
1974
1975 let config = server.config.read().await;
1977 assert!(config.enable_linting);
1978 assert!(!config.enable_auto_fix);
1979 }
1980
1981 #[tokio::test]
1982 async fn test_lint_document() {
1983 let server = create_test_server();
1984
1985 let uri = Url::parse("file:///test.md").unwrap();
1987 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1988
1989 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1990
1991 assert!(!diagnostics.is_empty());
1993 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1994 }
1995
1996 #[tokio::test]
1997 async fn test_lint_document_disabled() {
1998 let server = create_test_server();
1999
2000 server.config.write().await.enable_linting = false;
2002
2003 let uri = Url::parse("file:///test.md").unwrap();
2004 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2005
2006 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2007
2008 assert!(diagnostics.is_empty());
2010 }
2011
2012 #[tokio::test]
2013 async fn test_get_code_actions() {
2014 let server = create_test_server();
2015
2016 let uri = Url::parse("file:///test.md").unwrap();
2017 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2018
2019 let range = Range {
2021 start: Position { line: 0, character: 0 },
2022 end: Position { line: 3, character: 21 },
2023 };
2024
2025 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2026
2027 assert!(!actions.is_empty());
2029 assert!(actions.iter().any(|a| a.title.contains("trailing")));
2030 }
2031
2032 #[tokio::test]
2033 async fn test_get_code_actions_outside_range() {
2034 let server = create_test_server();
2035
2036 let uri = Url::parse("file:///test.md").unwrap();
2037 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2038
2039 let range = Range {
2041 start: Position { line: 0, character: 0 },
2042 end: Position { line: 0, character: 6 },
2043 };
2044
2045 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2046
2047 assert!(actions.is_empty());
2049 }
2050
2051 #[tokio::test]
2052 async fn test_document_storage() {
2053 let server = create_test_server();
2054
2055 let uri = Url::parse("file:///test.md").unwrap();
2056 let text = "# Test Document";
2057
2058 let entry = DocumentEntry {
2060 content: text.to_string(),
2061 version: Some(1),
2062 from_disk: false,
2063 };
2064 server.documents.write().await.insert(uri.clone(), entry);
2065
2066 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
2068 assert_eq!(stored, Some(text.to_string()));
2069
2070 server.documents.write().await.remove(&uri);
2072
2073 let stored = server.documents.read().await.get(&uri).cloned();
2075 assert_eq!(stored, None);
2076 }
2077
2078 #[tokio::test]
2079 async fn test_configuration_loading() {
2080 let server = create_test_server();
2081
2082 server.load_configuration(false).await;
2084
2085 let rumdl_config = server.rumdl_config.read().await;
2088 drop(rumdl_config); }
2091
2092 #[tokio::test]
2093 async fn test_load_config_for_lsp() {
2094 let result = RumdlLanguageServer::load_config_for_lsp(None);
2096 assert!(result.is_ok());
2097
2098 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2100 assert!(result.is_err());
2101 }
2102
2103 #[tokio::test]
2104 async fn test_warning_conversion() {
2105 let warning = LintWarning {
2106 message: "Test warning".to_string(),
2107 line: 1,
2108 column: 1,
2109 end_line: 1,
2110 end_column: 10,
2111 severity: crate::rule::Severity::Warning,
2112 fix: None,
2113 rule_name: Some("MD001".to_string()),
2114 };
2115
2116 let diagnostic = warning_to_diagnostic(&warning);
2118 assert_eq!(diagnostic.message, "Test warning");
2119 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2120 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2121
2122 let uri = Url::parse("file:///test.md").unwrap();
2124 let actions = warning_to_code_actions(&warning, &uri, "Test content");
2125 assert_eq!(actions.len(), 1);
2127 assert_eq!(actions[0].title, "Ignore MD001 for this line");
2128 }
2129
2130 #[tokio::test]
2131 async fn test_multiple_documents() {
2132 let server = create_test_server();
2133
2134 let uri1 = Url::parse("file:///test1.md").unwrap();
2135 let uri2 = Url::parse("file:///test2.md").unwrap();
2136 let text1 = "# Document 1";
2137 let text2 = "# Document 2";
2138
2139 {
2141 let mut docs = server.documents.write().await;
2142 let entry1 = DocumentEntry {
2143 content: text1.to_string(),
2144 version: Some(1),
2145 from_disk: false,
2146 };
2147 let entry2 = DocumentEntry {
2148 content: text2.to_string(),
2149 version: Some(1),
2150 from_disk: false,
2151 };
2152 docs.insert(uri1.clone(), entry1);
2153 docs.insert(uri2.clone(), entry2);
2154 }
2155
2156 let docs = server.documents.read().await;
2158 assert_eq!(docs.len(), 2);
2159 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2160 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2161 }
2162
2163 #[tokio::test]
2164 async fn test_auto_fix_on_save() {
2165 let server = create_test_server();
2166
2167 {
2169 let mut config = server.config.write().await;
2170 config.enable_auto_fix = true;
2171 }
2172
2173 let uri = Url::parse("file:///test.md").unwrap();
2174 let text = "#Heading without space"; let entry = DocumentEntry {
2178 content: text.to_string(),
2179 version: Some(1),
2180 from_disk: false,
2181 };
2182 server.documents.write().await.insert(uri.clone(), entry);
2183
2184 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2186 assert!(fixed.is_some());
2187 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2189 }
2190
2191 #[tokio::test]
2192 async fn test_get_end_position() {
2193 let server = create_test_server();
2194
2195 let pos = server.get_end_position("Hello");
2197 assert_eq!(pos.line, 0);
2198 assert_eq!(pos.character, 5);
2199
2200 let pos = server.get_end_position("Hello\nWorld\nTest");
2202 assert_eq!(pos.line, 2);
2203 assert_eq!(pos.character, 4);
2204
2205 let pos = server.get_end_position("");
2207 assert_eq!(pos.line, 0);
2208 assert_eq!(pos.character, 0);
2209
2210 let pos = server.get_end_position("Hello\n");
2212 assert_eq!(pos.line, 1);
2213 assert_eq!(pos.character, 0);
2214 }
2215
2216 #[tokio::test]
2217 async fn test_empty_document_handling() {
2218 let server = create_test_server();
2219
2220 let uri = Url::parse("file:///empty.md").unwrap();
2221 let text = "";
2222
2223 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2225 assert!(diagnostics.is_empty());
2226
2227 let range = Range {
2229 start: Position { line: 0, character: 0 },
2230 end: Position { line: 0, character: 0 },
2231 };
2232 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2233 assert!(actions.is_empty());
2234 }
2235
2236 #[tokio::test]
2237 async fn test_config_update() {
2238 let server = create_test_server();
2239
2240 {
2242 let mut config = server.config.write().await;
2243 config.enable_auto_fix = true;
2244 config.config_path = Some("/custom/path.toml".to_string());
2245 }
2246
2247 let config = server.config.read().await;
2249 assert!(config.enable_auto_fix);
2250 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2251 }
2252
2253 #[tokio::test]
2254 async fn test_document_formatting() {
2255 let server = create_test_server();
2256 let uri = Url::parse("file:///test.md").unwrap();
2257 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2258
2259 let entry = DocumentEntry {
2261 content: text.to_string(),
2262 version: Some(1),
2263 from_disk: false,
2264 };
2265 server.documents.write().await.insert(uri.clone(), entry);
2266
2267 let params = DocumentFormattingParams {
2269 text_document: TextDocumentIdentifier { uri: uri.clone() },
2270 options: FormattingOptions {
2271 tab_size: 4,
2272 insert_spaces: true,
2273 properties: HashMap::new(),
2274 trim_trailing_whitespace: Some(true),
2275 insert_final_newline: Some(true),
2276 trim_final_newlines: Some(true),
2277 },
2278 work_done_progress_params: WorkDoneProgressParams::default(),
2279 };
2280
2281 let result = server.formatting(params).await.unwrap();
2283
2284 assert!(result.is_some());
2286 let edits = result.unwrap();
2287 assert!(!edits.is_empty());
2288
2289 let edit = &edits[0];
2292 let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
2296 assert_eq!(edit.new_text, expected);
2297 }
2298
2299 #[tokio::test]
2302 async fn test_unfixable_rules_excluded_from_formatting() {
2303 let server = create_test_server();
2304 let uri = Url::parse("file:///test.md").unwrap();
2305
2306 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2308
2309 let entry = DocumentEntry {
2311 content: text.to_string(),
2312 version: Some(1),
2313 from_disk: false,
2314 };
2315 server.documents.write().await.insert(uri.clone(), entry);
2316
2317 let format_params = DocumentFormattingParams {
2319 text_document: TextDocumentIdentifier { uri: uri.clone() },
2320 options: FormattingOptions {
2321 tab_size: 4,
2322 insert_spaces: true,
2323 properties: HashMap::new(),
2324 trim_trailing_whitespace: Some(true),
2325 insert_final_newline: Some(true),
2326 trim_final_newlines: Some(true),
2327 },
2328 work_done_progress_params: WorkDoneProgressParams::default(),
2329 };
2330
2331 let format_result = server.formatting(format_params).await.unwrap();
2332 assert!(format_result.is_some(), "Should return formatting edits");
2333
2334 let edits = format_result.unwrap();
2335 assert!(!edits.is_empty(), "Should have formatting edits");
2336
2337 let formatted = &edits[0].new_text;
2338 assert!(
2339 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2340 "HTML should be preserved during formatting (Unfixable rule)"
2341 );
2342 assert!(
2343 !formatted.contains("spaces "),
2344 "Trailing spaces should be removed (fixable rule)"
2345 );
2346
2347 let range = Range {
2349 start: Position { line: 0, character: 0 },
2350 end: Position { line: 10, character: 0 },
2351 };
2352
2353 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2354
2355 let html_fix_actions: Vec<_> = code_actions
2357 .iter()
2358 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2359 .collect();
2360
2361 assert!(
2362 !html_fix_actions.is_empty(),
2363 "Quick Fix actions should be available for HTML (Unfixable rules)"
2364 );
2365
2366 let fix_all_actions: Vec<_> = code_actions
2368 .iter()
2369 .filter(|action| action.title.contains("Fix all"))
2370 .collect();
2371
2372 if let Some(fix_all_action) = fix_all_actions.first()
2373 && let Some(ref edit) = fix_all_action.edit
2374 && let Some(ref changes) = edit.changes
2375 && let Some(text_edits) = changes.get(&uri)
2376 && let Some(text_edit) = text_edits.first()
2377 {
2378 let fixed_all = &text_edit.new_text;
2379 assert!(
2380 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2381 "Fix All should preserve HTML (Unfixable rules)"
2382 );
2383 assert!(
2384 !fixed_all.contains("spaces "),
2385 "Fix All should remove trailing spaces (fixable rules)"
2386 );
2387 }
2388 }
2389
2390 #[tokio::test]
2392 async fn test_resolve_config_for_file_multi_root() {
2393 use std::fs;
2394 use tempfile::tempdir;
2395
2396 let temp_dir = tempdir().unwrap();
2397 let temp_path = temp_dir.path();
2398
2399 let project_a = temp_path.join("project_a");
2401 let project_a_docs = project_a.join("docs");
2402 fs::create_dir_all(&project_a_docs).unwrap();
2403
2404 let config_a = project_a.join(".rumdl.toml");
2405 fs::write(
2406 &config_a,
2407 r#"
2408[global]
2409
2410[MD013]
2411line_length = 60
2412"#,
2413 )
2414 .unwrap();
2415
2416 let project_b = temp_path.join("project_b");
2418 fs::create_dir(&project_b).unwrap();
2419
2420 let config_b = project_b.join(".rumdl.toml");
2421 fs::write(
2422 &config_b,
2423 r#"
2424[global]
2425
2426[MD013]
2427line_length = 120
2428"#,
2429 )
2430 .unwrap();
2431
2432 let server = create_test_server();
2434
2435 {
2437 let mut roots = server.workspace_roots.write().await;
2438 roots.push(project_a.clone());
2439 roots.push(project_b.clone());
2440 }
2441
2442 let file_a = project_a_docs.join("test.md");
2444 fs::write(&file_a, "# Test A\n").unwrap();
2445
2446 let config_for_a = server.resolve_config_for_file(&file_a).await;
2447 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2448 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2449
2450 let file_b = project_b.join("test.md");
2452 fs::write(&file_b, "# Test B\n").unwrap();
2453
2454 let config_for_b = server.resolve_config_for_file(&file_b).await;
2455 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2456 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2457 }
2458
2459 #[tokio::test]
2461 async fn test_config_resolution_respects_workspace_boundaries() {
2462 use std::fs;
2463 use tempfile::tempdir;
2464
2465 let temp_dir = tempdir().unwrap();
2466 let temp_path = temp_dir.path();
2467
2468 let parent_config = temp_path.join(".rumdl.toml");
2470 fs::write(
2471 &parent_config,
2472 r#"
2473[global]
2474
2475[MD013]
2476line_length = 80
2477"#,
2478 )
2479 .unwrap();
2480
2481 let workspace_root = temp_path.join("workspace");
2483 let workspace_subdir = workspace_root.join("subdir");
2484 fs::create_dir_all(&workspace_subdir).unwrap();
2485
2486 let workspace_config = workspace_root.join(".rumdl.toml");
2487 fs::write(
2488 &workspace_config,
2489 r#"
2490[global]
2491
2492[MD013]
2493line_length = 100
2494"#,
2495 )
2496 .unwrap();
2497
2498 let server = create_test_server();
2499
2500 {
2502 let mut roots = server.workspace_roots.write().await;
2503 roots.push(workspace_root.clone());
2504 }
2505
2506 let test_file = workspace_subdir.join("deep").join("test.md");
2508 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2509 fs::write(&test_file, "# Test\n").unwrap();
2510
2511 let config = server.resolve_config_for_file(&test_file).await;
2512 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2513
2514 assert_eq!(
2516 line_length,
2517 Some(100),
2518 "Should find workspace config, not parent config outside workspace"
2519 );
2520 }
2521
2522 #[tokio::test]
2524 async fn test_config_cache_hit() {
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_file = project.join(".rumdl.toml");
2535 fs::write(
2536 &config_file,
2537 r#"
2538[global]
2539
2540[MD013]
2541line_length = 75
2542"#,
2543 )
2544 .unwrap();
2545
2546 let server = create_test_server();
2547 {
2548 let mut roots = server.workspace_roots.write().await;
2549 roots.push(project.clone());
2550 }
2551
2552 let test_file = project.join("test.md");
2553 fs::write(&test_file, "# Test\n").unwrap();
2554
2555 let config1 = server.resolve_config_for_file(&test_file).await;
2557 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2558 assert_eq!(line_length1, Some(75));
2559
2560 {
2562 let cache = server.config_cache.read().await;
2563 let search_dir = test_file.parent().unwrap();
2564 assert!(
2565 cache.contains_key(search_dir),
2566 "Cache should be populated after first call"
2567 );
2568 }
2569
2570 let config2 = server.resolve_config_for_file(&test_file).await;
2572 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2573 assert_eq!(line_length2, Some(75));
2574 }
2575
2576 #[tokio::test]
2578 async fn test_nested_directory_config_search() {
2579 use std::fs;
2580 use tempfile::tempdir;
2581
2582 let temp_dir = tempdir().unwrap();
2583 let temp_path = temp_dir.path();
2584
2585 let project = temp_path.join("project");
2586 fs::create_dir(&project).unwrap();
2587
2588 let config = project.join(".rumdl.toml");
2590 fs::write(
2591 &config,
2592 r#"
2593[global]
2594
2595[MD013]
2596line_length = 110
2597"#,
2598 )
2599 .unwrap();
2600
2601 let deep_dir = project.join("src").join("docs").join("guides");
2603 fs::create_dir_all(&deep_dir).unwrap();
2604 let deep_file = deep_dir.join("test.md");
2605 fs::write(&deep_file, "# Test\n").unwrap();
2606
2607 let server = create_test_server();
2608 {
2609 let mut roots = server.workspace_roots.write().await;
2610 roots.push(project.clone());
2611 }
2612
2613 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2614 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2615
2616 assert_eq!(
2617 line_length,
2618 Some(110),
2619 "Should find config by searching upward from deep directory"
2620 );
2621 }
2622
2623 #[tokio::test]
2625 async fn test_fallback_to_default_config() {
2626 use std::fs;
2627 use tempfile::tempdir;
2628
2629 let temp_dir = tempdir().unwrap();
2630 let temp_path = temp_dir.path();
2631
2632 let project = temp_path.join("project");
2633 fs::create_dir(&project).unwrap();
2634
2635 let test_file = project.join("test.md");
2638 fs::write(&test_file, "# Test\n").unwrap();
2639
2640 let server = create_test_server();
2641 {
2642 let mut roots = server.workspace_roots.write().await;
2643 roots.push(project.clone());
2644 }
2645
2646 let config = server.resolve_config_for_file(&test_file).await;
2647
2648 assert_eq!(
2650 config.global.line_length.get(),
2651 80,
2652 "Should fall back to default config when no config file found"
2653 );
2654 }
2655
2656 #[tokio::test]
2658 async fn test_config_priority_closer_wins() {
2659 use std::fs;
2660 use tempfile::tempdir;
2661
2662 let temp_dir = tempdir().unwrap();
2663 let temp_path = temp_dir.path();
2664
2665 let project = temp_path.join("project");
2666 fs::create_dir(&project).unwrap();
2667
2668 let parent_config = project.join(".rumdl.toml");
2670 fs::write(
2671 &parent_config,
2672 r#"
2673[global]
2674
2675[MD013]
2676line_length = 100
2677"#,
2678 )
2679 .unwrap();
2680
2681 let subdir = project.join("subdir");
2683 fs::create_dir(&subdir).unwrap();
2684
2685 let subdir_config = subdir.join(".rumdl.toml");
2686 fs::write(
2687 &subdir_config,
2688 r#"
2689[global]
2690
2691[MD013]
2692line_length = 50
2693"#,
2694 )
2695 .unwrap();
2696
2697 let server = create_test_server();
2698 {
2699 let mut roots = server.workspace_roots.write().await;
2700 roots.push(project.clone());
2701 }
2702
2703 let test_file = subdir.join("test.md");
2705 fs::write(&test_file, "# Test\n").unwrap();
2706
2707 let config = server.resolve_config_for_file(&test_file).await;
2708 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2709
2710 assert_eq!(
2711 line_length,
2712 Some(50),
2713 "Closer config (subdir) should override parent config"
2714 );
2715 }
2716
2717 #[tokio::test]
2723 async fn test_issue_131_pyproject_without_rumdl_section() {
2724 use std::fs;
2725 use tempfile::tempdir;
2726
2727 let parent_dir = tempdir().unwrap();
2729
2730 let project_dir = parent_dir.path().join("project");
2732 fs::create_dir(&project_dir).unwrap();
2733
2734 fs::write(
2736 project_dir.join("pyproject.toml"),
2737 r#"
2738[project]
2739name = "test-project"
2740version = "0.1.0"
2741"#,
2742 )
2743 .unwrap();
2744
2745 fs::write(
2748 parent_dir.path().join(".rumdl.toml"),
2749 r#"
2750[global]
2751disable = ["MD013"]
2752"#,
2753 )
2754 .unwrap();
2755
2756 let test_file = project_dir.join("test.md");
2757 fs::write(&test_file, "# Test\n").unwrap();
2758
2759 let server = create_test_server();
2760
2761 {
2763 let mut roots = server.workspace_roots.write().await;
2764 roots.push(parent_dir.path().to_path_buf());
2765 }
2766
2767 let config = server.resolve_config_for_file(&test_file).await;
2769
2770 assert!(
2773 config.global.disable.contains(&"MD013".to_string()),
2774 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2775 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2776 );
2777
2778 let cache = server.config_cache.read().await;
2781 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2782
2783 assert!(
2784 cache_entry.config_file.is_some(),
2785 "Should have found a config file (parent .rumdl.toml)"
2786 );
2787
2788 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2789 assert!(
2790 found_config_path.ends_with(".rumdl.toml"),
2791 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2792 );
2793 assert!(
2794 found_config_path.parent().unwrap() == parent_dir.path(),
2795 "Should have loaded config from parent directory, not project_dir"
2796 );
2797 }
2798
2799 #[tokio::test]
2804 async fn test_issue_131_pyproject_with_rumdl_section() {
2805 use std::fs;
2806 use tempfile::tempdir;
2807
2808 let parent_dir = tempdir().unwrap();
2810
2811 let project_dir = parent_dir.path().join("project");
2813 fs::create_dir(&project_dir).unwrap();
2814
2815 fs::write(
2817 project_dir.join("pyproject.toml"),
2818 r#"
2819[project]
2820name = "test-project"
2821
2822[tool.rumdl.global]
2823disable = ["MD033"]
2824"#,
2825 )
2826 .unwrap();
2827
2828 fs::write(
2830 parent_dir.path().join(".rumdl.toml"),
2831 r#"
2832[global]
2833disable = ["MD041"]
2834"#,
2835 )
2836 .unwrap();
2837
2838 let test_file = project_dir.join("test.md");
2839 fs::write(&test_file, "# Test\n").unwrap();
2840
2841 let server = create_test_server();
2842
2843 {
2845 let mut roots = server.workspace_roots.write().await;
2846 roots.push(parent_dir.path().to_path_buf());
2847 }
2848
2849 let config = server.resolve_config_for_file(&test_file).await;
2851
2852 assert!(
2854 config.global.disable.contains(&"MD033".to_string()),
2855 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2856 Expected MD033 from project_dir pyproject.toml to be disabled."
2857 );
2858
2859 assert!(
2861 !config.global.disable.contains(&"MD041".to_string()),
2862 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2863 );
2864
2865 let cache = server.config_cache.read().await;
2867 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2868
2869 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2870
2871 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2872 assert!(
2873 found_config_path.ends_with("pyproject.toml"),
2874 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2875 );
2876 assert!(
2877 found_config_path.parent().unwrap() == project_dir,
2878 "Should have loaded pyproject.toml from project_dir, not parent"
2879 );
2880 }
2881
2882 #[tokio::test]
2887 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2888 use std::fs;
2889 use tempfile::tempdir;
2890
2891 let temp_dir = tempdir().unwrap();
2892
2893 fs::write(
2895 temp_dir.path().join("pyproject.toml"),
2896 r#"
2897[project]
2898name = "test-project"
2899
2900[tool.rumdl.global]
2901disable = ["MD022"]
2902"#,
2903 )
2904 .unwrap();
2905
2906 let test_file = temp_dir.path().join("test.md");
2907 fs::write(&test_file, "# Test\n").unwrap();
2908
2909 let server = create_test_server();
2910
2911 {
2913 let mut roots = server.workspace_roots.write().await;
2914 roots.push(temp_dir.path().to_path_buf());
2915 }
2916
2917 let config = server.resolve_config_for_file(&test_file).await;
2919
2920 assert!(
2922 config.global.disable.contains(&"MD022".to_string()),
2923 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2924 );
2925
2926 let cache = server.config_cache.read().await;
2928 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2929 assert!(
2930 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2931 "Should have loaded pyproject.toml"
2932 );
2933 }
2934
2935 #[tokio::test]
2940 async fn test_issue_182_pull_diagnostics_capability_default() {
2941 let server = create_test_server();
2942
2943 assert!(
2945 !*server.client_supports_pull_diagnostics.read().await,
2946 "Default should be false - push diagnostics by default"
2947 );
2948 }
2949
2950 #[tokio::test]
2952 async fn test_issue_182_pull_diagnostics_flag_update() {
2953 let server = create_test_server();
2954
2955 *server.client_supports_pull_diagnostics.write().await = true;
2957
2958 assert!(
2959 *server.client_supports_pull_diagnostics.read().await,
2960 "Flag should be settable to true"
2961 );
2962 }
2963
2964 #[tokio::test]
2968 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2969 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2970
2971 let caps_with_diagnostic = ClientCapabilities {
2973 text_document: Some(TextDocumentClientCapabilities {
2974 diagnostic: Some(DiagnosticClientCapabilities {
2975 dynamic_registration: Some(true),
2976 related_document_support: Some(false),
2977 }),
2978 ..Default::default()
2979 }),
2980 ..Default::default()
2981 };
2982
2983 let supports_pull = caps_with_diagnostic
2985 .text_document
2986 .as_ref()
2987 .and_then(|td| td.diagnostic.as_ref())
2988 .is_some();
2989
2990 assert!(supports_pull, "Should detect pull diagnostic support");
2991 }
2992
2993 #[tokio::test]
2995 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2996 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2997
2998 let caps_without_diagnostic = ClientCapabilities {
3000 text_document: Some(TextDocumentClientCapabilities {
3001 diagnostic: None, ..Default::default()
3003 }),
3004 ..Default::default()
3005 };
3006
3007 let supports_pull = caps_without_diagnostic
3009 .text_document
3010 .as_ref()
3011 .and_then(|td| td.diagnostic.as_ref())
3012 .is_some();
3013
3014 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
3015 }
3016
3017 #[tokio::test]
3019 async fn test_issue_182_capability_detection_no_text_document() {
3020 use tower_lsp::lsp_types::ClientCapabilities;
3021
3022 let caps_no_text_doc = ClientCapabilities {
3024 text_document: None,
3025 ..Default::default()
3026 };
3027
3028 let supports_pull = caps_no_text_doc
3030 .text_document
3031 .as_ref()
3032 .and_then(|td| td.diagnostic.as_ref())
3033 .is_some();
3034
3035 assert!(
3036 !supports_pull,
3037 "Should NOT detect pull diagnostic support when text_document is None"
3038 );
3039 }
3040
3041 #[test]
3042 fn test_resource_limit_constants() {
3043 assert_eq!(MAX_RULE_LIST_SIZE, 100);
3045 assert_eq!(MAX_LINE_LENGTH, 10_000);
3046 }
3047
3048 #[test]
3049 fn test_is_valid_rule_name_edge_cases() {
3050 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")); }
3063
3064 #[tokio::test]
3073 async fn test_lsp_toml_config_parity_generic() {
3074 use crate::config::RuleConfig;
3075 use crate::rule::Severity;
3076
3077 let server = create_test_server();
3078
3079 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3083 (
3085 "severity only - error",
3086 serde_json::json!({"severity": "error"}),
3087 RuleConfig {
3088 severity: Some(Severity::Error),
3089 values: std::collections::BTreeMap::new(),
3090 },
3091 ),
3092 (
3093 "severity only - warning",
3094 serde_json::json!({"severity": "warning"}),
3095 RuleConfig {
3096 severity: Some(Severity::Warning),
3097 values: std::collections::BTreeMap::new(),
3098 },
3099 ),
3100 (
3101 "severity only - info",
3102 serde_json::json!({"severity": "info"}),
3103 RuleConfig {
3104 severity: Some(Severity::Info),
3105 values: std::collections::BTreeMap::new(),
3106 },
3107 ),
3108 (
3110 "integer value",
3111 serde_json::json!({"lineLength": 120}),
3112 RuleConfig {
3113 severity: None,
3114 values: [("line_length".to_string(), toml::Value::Integer(120))]
3115 .into_iter()
3116 .collect(),
3117 },
3118 ),
3119 (
3121 "boolean value",
3122 serde_json::json!({"enabled": true}),
3123 RuleConfig {
3124 severity: None,
3125 values: [("enabled".to_string(), toml::Value::Boolean(true))]
3126 .into_iter()
3127 .collect(),
3128 },
3129 ),
3130 (
3132 "string value",
3133 serde_json::json!({"style": "consistent"}),
3134 RuleConfig {
3135 severity: None,
3136 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3137 .into_iter()
3138 .collect(),
3139 },
3140 ),
3141 (
3143 "array value",
3144 serde_json::json!({"allowedElements": ["div", "span"]}),
3145 RuleConfig {
3146 severity: None,
3147 values: [(
3148 "allowed_elements".to_string(),
3149 toml::Value::Array(vec![
3150 toml::Value::String("div".to_string()),
3151 toml::Value::String("span".to_string()),
3152 ]),
3153 )]
3154 .into_iter()
3155 .collect(),
3156 },
3157 ),
3158 (
3160 "severity + integer",
3161 serde_json::json!({"severity": "info", "lineLength": 80}),
3162 RuleConfig {
3163 severity: Some(Severity::Info),
3164 values: [("line_length".to_string(), toml::Value::Integer(80))]
3165 .into_iter()
3166 .collect(),
3167 },
3168 ),
3169 (
3170 "severity + multiple values",
3171 serde_json::json!({
3172 "severity": "warning",
3173 "lineLength": 100,
3174 "strict": false,
3175 "style": "atx"
3176 }),
3177 RuleConfig {
3178 severity: Some(Severity::Warning),
3179 values: [
3180 ("line_length".to_string(), toml::Value::Integer(100)),
3181 ("strict".to_string(), toml::Value::Boolean(false)),
3182 ("style".to_string(), toml::Value::String("atx".to_string())),
3183 ]
3184 .into_iter()
3185 .collect(),
3186 },
3187 ),
3188 (
3190 "camelCase conversion",
3191 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3192 RuleConfig {
3193 severity: None,
3194 values: [
3195 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3196 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3197 ]
3198 .into_iter()
3199 .collect(),
3200 },
3201 ),
3202 ];
3203
3204 for (description, lsp_json, expected_toml_config) in test_configs {
3205 let mut lsp_config = crate::config::Config::default();
3206 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3207
3208 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3209
3210 assert_eq!(
3212 lsp_rule.severity, expected_toml_config.severity,
3213 "Parity failure [{description}]: severity mismatch. \
3214 LSP={:?}, TOML={:?}",
3215 lsp_rule.severity, expected_toml_config.severity
3216 );
3217
3218 assert_eq!(
3220 lsp_rule.values, expected_toml_config.values,
3221 "Parity failure [{description}]: values mismatch. \
3222 LSP={:?}, TOML={:?}",
3223 lsp_rule.values, expected_toml_config.values
3224 );
3225 }
3226 }
3227
3228 #[tokio::test]
3230 async fn test_lsp_config_if_absent_preserves_existing() {
3231 use crate::config::RuleConfig;
3232 use crate::rule::Severity;
3233
3234 let server = create_test_server();
3235
3236 let mut config = crate::config::Config::default();
3238 config.rules.insert(
3239 "MD013".to_string(),
3240 RuleConfig {
3241 severity: Some(Severity::Error),
3242 values: [("line_length".to_string(), toml::Value::Integer(80))]
3243 .into_iter()
3244 .collect(),
3245 },
3246 );
3247
3248 let lsp_json = serde_json::json!({
3250 "severity": "info",
3251 "lineLength": 120
3252 });
3253 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3254
3255 let rule = config.rules.get("MD013").expect("Rule should exist");
3256
3257 assert_eq!(
3259 rule.severity,
3260 Some(Severity::Error),
3261 "Existing severity should not be overwritten"
3262 );
3263
3264 assert_eq!(
3266 rule.values.get("line_length"),
3267 Some(&toml::Value::Integer(80)),
3268 "Existing values should not be overwritten"
3269 );
3270 }
3271
3272 #[test]
3275 fn test_apply_formatting_options_insert_final_newline() {
3276 let options = FormattingOptions {
3277 tab_size: 4,
3278 insert_spaces: true,
3279 properties: HashMap::new(),
3280 trim_trailing_whitespace: None,
3281 insert_final_newline: Some(true),
3282 trim_final_newlines: None,
3283 };
3284
3285 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3287 assert_eq!(result, "hello\n");
3288
3289 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3291 assert_eq!(result, "hello\n");
3292 }
3293
3294 #[test]
3295 fn test_apply_formatting_options_trim_final_newlines() {
3296 let options = FormattingOptions {
3297 tab_size: 4,
3298 insert_spaces: true,
3299 properties: HashMap::new(),
3300 trim_trailing_whitespace: None,
3301 insert_final_newline: None,
3302 trim_final_newlines: Some(true),
3303 };
3304
3305 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3307 assert_eq!(result, "hello");
3308
3309 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3311 assert_eq!(result, "hello");
3312 }
3313
3314 #[test]
3315 fn test_apply_formatting_options_trim_and_insert_combined() {
3316 let options = FormattingOptions {
3318 tab_size: 4,
3319 insert_spaces: true,
3320 properties: HashMap::new(),
3321 trim_trailing_whitespace: None,
3322 insert_final_newline: Some(true),
3323 trim_final_newlines: Some(true),
3324 };
3325
3326 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3328 assert_eq!(result, "hello\n");
3329
3330 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3332 assert_eq!(result, "hello\n");
3333
3334 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3336 assert_eq!(result, "hello\n");
3337 }
3338
3339 #[test]
3340 fn test_apply_formatting_options_trim_trailing_whitespace() {
3341 let options = FormattingOptions {
3342 tab_size: 4,
3343 insert_spaces: true,
3344 properties: HashMap::new(),
3345 trim_trailing_whitespace: Some(true),
3346 insert_final_newline: Some(true),
3347 trim_final_newlines: None,
3348 };
3349
3350 let result = RumdlLanguageServer::apply_formatting_options("hello \nworld\t\n".to_string(), &options);
3352 assert_eq!(result, "hello\nworld\n");
3353 }
3354
3355 #[test]
3356 fn test_apply_formatting_options_issue_265_scenario() {
3357 let options = FormattingOptions {
3362 tab_size: 4,
3363 insert_spaces: true,
3364 properties: HashMap::new(),
3365 trim_trailing_whitespace: None,
3366 insert_final_newline: Some(true),
3367 trim_final_newlines: Some(true),
3368 };
3369
3370 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
3372 assert_eq!(
3373 result, "hello foobar hello.\n",
3374 "Should have exactly one trailing newline"
3375 );
3376
3377 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
3379 assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
3380
3381 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
3383 assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
3384 }
3385
3386 #[test]
3387 fn test_apply_formatting_options_no_options() {
3388 let options = FormattingOptions {
3390 tab_size: 4,
3391 insert_spaces: true,
3392 properties: HashMap::new(),
3393 trim_trailing_whitespace: None,
3394 insert_final_newline: None,
3395 trim_final_newlines: None,
3396 };
3397
3398 let content = "hello \nworld\n\n\n";
3399 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3400 assert_eq!(result, content, "Content should be unchanged when no options set");
3401 }
3402
3403 #[test]
3404 fn test_apply_formatting_options_empty_content() {
3405 let options = FormattingOptions {
3406 tab_size: 4,
3407 insert_spaces: true,
3408 properties: HashMap::new(),
3409 trim_trailing_whitespace: Some(true),
3410 insert_final_newline: Some(true),
3411 trim_final_newlines: Some(true),
3412 };
3413
3414 let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
3416 assert_eq!(result, "");
3417
3418 let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
3420 assert_eq!(result, "\n");
3421 }
3422
3423 #[test]
3424 fn test_apply_formatting_options_multiline_content() {
3425 let options = FormattingOptions {
3426 tab_size: 4,
3427 insert_spaces: true,
3428 properties: HashMap::new(),
3429 trim_trailing_whitespace: Some(true),
3430 insert_final_newline: Some(true),
3431 trim_final_newlines: Some(true),
3432 };
3433
3434 let content = "# Heading \n\nParagraph \n- List item \n\n\n";
3435 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3436 assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
3437 }
3438
3439 #[test]
3440 fn test_code_action_kind_filtering() {
3441 let matches = |action_kind: &str, requested: &str| -> bool { action_kind.starts_with(requested) };
3445
3446 assert!(matches("source.fixAll.rumdl", "source.fixAll"));
3448
3449 assert!(matches("source.fixAll.rumdl", "source.fixAll.rumdl"));
3451
3452 assert!(matches("source.fixAll.rumdl", "source"));
3454
3455 assert!(matches("quickfix", "quickfix"));
3457
3458 assert!(!matches("source.fixAll.rumdl", "quickfix"));
3460
3461 assert!(!matches("quickfix", "source.fixAll"));
3463
3464 assert!(!matches("source.fixAll", "source.fixAll.rumdl"));
3466 }
3467
3468 #[test]
3469 fn test_code_action_kind_filter_with_empty_array() {
3470 let filter_actions = |kinds: Option<Vec<&str>>| -> bool {
3474 if let Some(ref k) = kinds
3476 && !k.is_empty()
3477 {
3478 false
3480 } else {
3481 true
3483 }
3484 };
3485
3486 assert!(filter_actions(None));
3488
3489 assert!(filter_actions(Some(vec![])));
3491
3492 assert!(!filter_actions(Some(vec!["source.fixAll"])));
3494 }
3495
3496 #[test]
3497 fn test_code_action_kind_constants() {
3498 let fix_all_rumdl = CodeActionKind::new("source.fixAll.rumdl");
3500 assert_eq!(fix_all_rumdl.as_str(), "source.fixAll.rumdl");
3501
3502 assert!(
3504 fix_all_rumdl
3505 .as_str()
3506 .starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str())
3507 );
3508 }
3509}