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 if params.reason != TextDocumentSaveReason::MANUAL {
1573 return Ok(None);
1574 }
1575
1576 let config_guard = self.config.read().await;
1577 let enable_auto_fix = config_guard.enable_auto_fix;
1578 drop(config_guard);
1579
1580 if !enable_auto_fix {
1581 return Ok(None);
1582 }
1583
1584 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1586 return Ok(None);
1587 };
1588
1589 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1591 Ok(Some(fixed_text)) => {
1592 Ok(Some(vec![TextEdit {
1594 range: Range {
1595 start: Position { line: 0, character: 0 },
1596 end: self.get_end_position(&text),
1597 },
1598 new_text: fixed_text,
1599 }]))
1600 }
1601 Ok(None) => Ok(None),
1602 Err(e) => {
1603 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1604 Ok(None)
1605 }
1606 }
1607 }
1608
1609 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1610 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1613 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1614 .await;
1615 }
1616 }
1617
1618 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1619 self.documents.write().await.remove(¶ms.text_document.uri);
1621
1622 self.client
1625 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1626 .await;
1627 }
1628
1629 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1630 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1632
1633 let mut config_changed = false;
1634
1635 for change in ¶ms.changes {
1636 if let Ok(path) = change.uri.to_file_path() {
1637 let file_name = path.file_name().and_then(|f| f.to_str());
1638 let extension = path.extension().and_then(|e| e.to_str());
1639
1640 if let Some(name) = file_name
1642 && CONFIG_FILES.contains(&name)
1643 && !config_changed
1644 {
1645 log::info!("Config file changed: {}, invalidating config cache", path.display());
1646
1647 let mut cache = self.config_cache.write().await;
1649 cache.retain(|_, entry| {
1650 if let Some(config_file) = &entry.config_file {
1651 config_file != &path
1652 } else {
1653 true
1654 }
1655 });
1656
1657 drop(cache);
1659 self.reload_configuration().await;
1660 config_changed = true;
1661 }
1662
1663 if let Some(ext) = extension
1665 && is_markdown_extension(ext)
1666 {
1667 match change.typ {
1668 FileChangeType::CREATED | FileChangeType::CHANGED => {
1669 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1671 let _ = self
1672 .update_tx
1673 .send(IndexUpdate::FileChanged {
1674 path: path.clone(),
1675 content,
1676 })
1677 .await;
1678 }
1679 }
1680 FileChangeType::DELETED => {
1681 let _ = self
1682 .update_tx
1683 .send(IndexUpdate::FileDeleted { path: path.clone() })
1684 .await;
1685 }
1686 _ => {}
1687 }
1688 }
1689 }
1690 }
1691
1692 if config_changed {
1694 let docs_to_update: Vec<(Url, String)> = {
1695 let docs = self.documents.read().await;
1696 docs.iter()
1697 .filter(|(_, entry)| !entry.from_disk)
1698 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1699 .collect()
1700 };
1701
1702 for (uri, text) in docs_to_update {
1703 self.update_diagnostics(uri, text).await;
1704 }
1705 }
1706 }
1707
1708 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1709 let uri = params.text_document.uri;
1710 let range = params.range;
1711 let requested_kinds = params.context.only;
1712
1713 if let Some(text) = self.get_document_content(&uri).await {
1714 match self.get_code_actions(&uri, &text, range).await {
1715 Ok(actions) => {
1716 let filtered_actions = if let Some(ref kinds) = requested_kinds
1720 && !kinds.is_empty()
1721 {
1722 actions
1723 .into_iter()
1724 .filter(|action| {
1725 action.kind.as_ref().is_some_and(|action_kind| {
1726 let action_kind_str = action_kind.as_str();
1727 kinds.iter().any(|requested| {
1728 let requested_str = requested.as_str();
1729 action_kind_str.starts_with(requested_str)
1732 })
1733 })
1734 })
1735 .collect()
1736 } else {
1737 actions
1738 };
1739
1740 let response: Vec<CodeActionOrCommand> = filtered_actions
1741 .into_iter()
1742 .map(CodeActionOrCommand::CodeAction)
1743 .collect();
1744 Ok(Some(response))
1745 }
1746 Err(e) => {
1747 log::error!("Failed to get code actions: {e}");
1748 Ok(None)
1749 }
1750 }
1751 } else {
1752 Ok(None)
1753 }
1754 }
1755
1756 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1757 log::debug!(
1762 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1763 params.range
1764 );
1765
1766 let formatting_params = DocumentFormattingParams {
1767 text_document: params.text_document,
1768 options: params.options,
1769 work_done_progress_params: params.work_done_progress_params,
1770 };
1771
1772 self.formatting(formatting_params).await
1773 }
1774
1775 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1776 let uri = params.text_document.uri;
1777 let options = params.options;
1778
1779 log::debug!("Formatting request for: {uri}");
1780 log::debug!(
1781 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1782 options.insert_final_newline,
1783 options.trim_final_newlines,
1784 options.trim_trailing_whitespace
1785 );
1786
1787 if let Some(text) = self.get_document_content(&uri).await {
1788 let config_guard = self.config.read().await;
1790 let lsp_config = config_guard.clone();
1791 drop(config_guard);
1792
1793 let file_path = uri.to_file_path().ok();
1795 let file_config = if let Some(ref path) = file_path {
1796 self.resolve_config_for_file(path).await
1797 } else {
1798 self.rumdl_config.read().await.clone()
1800 };
1801
1802 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1804
1805 let all_rules = rules::all_rules(&rumdl_config);
1806 let flavor = if let Some(ref path) = file_path {
1807 rumdl_config.get_flavor_for_file(path)
1808 } else {
1809 rumdl_config.markdown_flavor()
1810 };
1811
1812 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1814
1815 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1817
1818 let mut result = text.clone();
1820 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1821 Ok(warnings) => {
1822 log::debug!(
1823 "Found {} warnings, {} with fixes",
1824 warnings.len(),
1825 warnings.iter().filter(|w| w.fix.is_some()).count()
1826 );
1827
1828 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1829 if has_fixes {
1830 let fixable_warnings: Vec<_> = warnings
1832 .iter()
1833 .filter(|w| {
1834 if let Some(rule_name) = &w.rule_name {
1835 filtered_rules
1836 .iter()
1837 .find(|r| r.name() == rule_name)
1838 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1839 .unwrap_or(false)
1840 } else {
1841 false
1842 }
1843 })
1844 .cloned()
1845 .collect();
1846
1847 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1848 Ok(fixed_content) => {
1849 result = fixed_content;
1850 }
1851 Err(e) => {
1852 log::error!("Failed to apply fixes: {e}");
1853 }
1854 }
1855 }
1856 }
1857 Err(e) => {
1858 log::error!("Failed to lint document: {e}");
1859 }
1860 }
1861
1862 result = Self::apply_formatting_options(result, &options);
1865
1866 if result != text {
1868 log::debug!("Returning formatting edits");
1869 let end_position = self.get_end_position(&text);
1870 let edit = TextEdit {
1871 range: Range {
1872 start: Position { line: 0, character: 0 },
1873 end: end_position,
1874 },
1875 new_text: result,
1876 };
1877 return Ok(Some(vec![edit]));
1878 }
1879
1880 Ok(Some(Vec::new()))
1881 } else {
1882 log::warn!("Document not found: {uri}");
1883 Ok(None)
1884 }
1885 }
1886
1887 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1888 let uri = params.text_document.uri;
1889
1890 if let Some(text) = self.get_open_document_content(&uri).await {
1891 match self.lint_document(&uri, &text).await {
1892 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1893 RelatedFullDocumentDiagnosticReport {
1894 related_documents: None,
1895 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1896 result_id: None,
1897 items: diagnostics,
1898 },
1899 },
1900 ))),
1901 Err(e) => {
1902 log::error!("Failed to get diagnostics: {e}");
1903 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1904 RelatedFullDocumentDiagnosticReport {
1905 related_documents: None,
1906 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1907 result_id: None,
1908 items: Vec::new(),
1909 },
1910 },
1911 )))
1912 }
1913 }
1914 } else {
1915 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1916 RelatedFullDocumentDiagnosticReport {
1917 related_documents: None,
1918 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1919 result_id: None,
1920 items: Vec::new(),
1921 },
1922 },
1923 )))
1924 }
1925 }
1926}
1927
1928#[cfg(test)]
1929mod tests {
1930 use super::*;
1931 use crate::rule::LintWarning;
1932 use tower_lsp::LspService;
1933
1934 fn create_test_server() -> RumdlLanguageServer {
1935 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1936 service.inner().clone()
1937 }
1938
1939 #[test]
1940 fn test_is_valid_rule_name() {
1941 assert!(is_valid_rule_name("MD001"));
1943 assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
1947 assert!(is_valid_rule_name("MD005"));
1948 assert!(is_valid_rule_name("MD007"));
1949 assert!(is_valid_rule_name("MD009"));
1950 assert!(is_valid_rule_name("MD041"));
1951 assert!(is_valid_rule_name("MD060"));
1952 assert!(is_valid_rule_name("MD061"));
1953
1954 assert!(is_valid_rule_name("all"));
1956 assert!(is_valid_rule_name("ALL"));
1957 assert!(is_valid_rule_name("All"));
1958
1959 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"));
1972 assert!(!is_valid_rule_name("not-a-rule"));
1973 assert!(!is_valid_rule_name(""));
1974 assert!(!is_valid_rule_name("random-text"));
1975 }
1976
1977 #[tokio::test]
1978 async fn test_server_creation() {
1979 let server = create_test_server();
1980
1981 let config = server.config.read().await;
1983 assert!(config.enable_linting);
1984 assert!(!config.enable_auto_fix);
1985 }
1986
1987 #[tokio::test]
1988 async fn test_lint_document() {
1989 let server = create_test_server();
1990
1991 let uri = Url::parse("file:///test.md").unwrap();
1993 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1994
1995 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1996
1997 assert!(!diagnostics.is_empty());
1999 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
2000 }
2001
2002 #[tokio::test]
2003 async fn test_lint_document_disabled() {
2004 let server = create_test_server();
2005
2006 server.config.write().await.enable_linting = false;
2008
2009 let uri = Url::parse("file:///test.md").unwrap();
2010 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2011
2012 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2013
2014 assert!(diagnostics.is_empty());
2016 }
2017
2018 #[tokio::test]
2019 async fn test_get_code_actions() {
2020 let server = create_test_server();
2021
2022 let uri = Url::parse("file:///test.md").unwrap();
2023 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2024
2025 let range = Range {
2027 start: Position { line: 0, character: 0 },
2028 end: Position { line: 3, character: 21 },
2029 };
2030
2031 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2032
2033 assert!(!actions.is_empty());
2035 assert!(actions.iter().any(|a| a.title.contains("trailing")));
2036 }
2037
2038 #[tokio::test]
2039 async fn test_get_code_actions_outside_range() {
2040 let server = create_test_server();
2041
2042 let uri = Url::parse("file:///test.md").unwrap();
2043 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2044
2045 let range = Range {
2047 start: Position { line: 0, character: 0 },
2048 end: Position { line: 0, character: 6 },
2049 };
2050
2051 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2052
2053 assert!(actions.is_empty());
2055 }
2056
2057 #[tokio::test]
2058 async fn test_document_storage() {
2059 let server = create_test_server();
2060
2061 let uri = Url::parse("file:///test.md").unwrap();
2062 let text = "# Test Document";
2063
2064 let entry = DocumentEntry {
2066 content: text.to_string(),
2067 version: Some(1),
2068 from_disk: false,
2069 };
2070 server.documents.write().await.insert(uri.clone(), entry);
2071
2072 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
2074 assert_eq!(stored, Some(text.to_string()));
2075
2076 server.documents.write().await.remove(&uri);
2078
2079 let stored = server.documents.read().await.get(&uri).cloned();
2081 assert_eq!(stored, None);
2082 }
2083
2084 #[tokio::test]
2085 async fn test_configuration_loading() {
2086 let server = create_test_server();
2087
2088 server.load_configuration(false).await;
2090
2091 let rumdl_config = server.rumdl_config.read().await;
2094 drop(rumdl_config); }
2097
2098 #[tokio::test]
2099 async fn test_load_config_for_lsp() {
2100 let result = RumdlLanguageServer::load_config_for_lsp(None);
2102 assert!(result.is_ok());
2103
2104 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2106 assert!(result.is_err());
2107 }
2108
2109 #[tokio::test]
2110 async fn test_warning_conversion() {
2111 let warning = LintWarning {
2112 message: "Test warning".to_string(),
2113 line: 1,
2114 column: 1,
2115 end_line: 1,
2116 end_column: 10,
2117 severity: crate::rule::Severity::Warning,
2118 fix: None,
2119 rule_name: Some("MD001".to_string()),
2120 };
2121
2122 let diagnostic = warning_to_diagnostic(&warning);
2124 assert_eq!(diagnostic.message, "Test warning");
2125 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2126 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2127
2128 let uri = Url::parse("file:///test.md").unwrap();
2130 let actions = warning_to_code_actions(&warning, &uri, "Test content");
2131 assert_eq!(actions.len(), 1);
2133 assert_eq!(actions[0].title, "Ignore MD001 for this line");
2134 }
2135
2136 #[tokio::test]
2137 async fn test_multiple_documents() {
2138 let server = create_test_server();
2139
2140 let uri1 = Url::parse("file:///test1.md").unwrap();
2141 let uri2 = Url::parse("file:///test2.md").unwrap();
2142 let text1 = "# Document 1";
2143 let text2 = "# Document 2";
2144
2145 {
2147 let mut docs = server.documents.write().await;
2148 let entry1 = DocumentEntry {
2149 content: text1.to_string(),
2150 version: Some(1),
2151 from_disk: false,
2152 };
2153 let entry2 = DocumentEntry {
2154 content: text2.to_string(),
2155 version: Some(1),
2156 from_disk: false,
2157 };
2158 docs.insert(uri1.clone(), entry1);
2159 docs.insert(uri2.clone(), entry2);
2160 }
2161
2162 let docs = server.documents.read().await;
2164 assert_eq!(docs.len(), 2);
2165 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2166 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2167 }
2168
2169 #[tokio::test]
2170 async fn test_auto_fix_on_save() {
2171 let server = create_test_server();
2172
2173 {
2175 let mut config = server.config.write().await;
2176 config.enable_auto_fix = true;
2177 }
2178
2179 let uri = Url::parse("file:///test.md").unwrap();
2180 let text = "#Heading without space"; let entry = DocumentEntry {
2184 content: text.to_string(),
2185 version: Some(1),
2186 from_disk: false,
2187 };
2188 server.documents.write().await.insert(uri.clone(), entry);
2189
2190 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2192 assert!(fixed.is_some());
2193 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2195 }
2196
2197 #[tokio::test]
2198 async fn test_get_end_position() {
2199 let server = create_test_server();
2200
2201 let pos = server.get_end_position("Hello");
2203 assert_eq!(pos.line, 0);
2204 assert_eq!(pos.character, 5);
2205
2206 let pos = server.get_end_position("Hello\nWorld\nTest");
2208 assert_eq!(pos.line, 2);
2209 assert_eq!(pos.character, 4);
2210
2211 let pos = server.get_end_position("");
2213 assert_eq!(pos.line, 0);
2214 assert_eq!(pos.character, 0);
2215
2216 let pos = server.get_end_position("Hello\n");
2218 assert_eq!(pos.line, 1);
2219 assert_eq!(pos.character, 0);
2220 }
2221
2222 #[tokio::test]
2223 async fn test_empty_document_handling() {
2224 let server = create_test_server();
2225
2226 let uri = Url::parse("file:///empty.md").unwrap();
2227 let text = "";
2228
2229 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2231 assert!(diagnostics.is_empty());
2232
2233 let range = Range {
2235 start: Position { line: 0, character: 0 },
2236 end: Position { line: 0, character: 0 },
2237 };
2238 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2239 assert!(actions.is_empty());
2240 }
2241
2242 #[tokio::test]
2243 async fn test_config_update() {
2244 let server = create_test_server();
2245
2246 {
2248 let mut config = server.config.write().await;
2249 config.enable_auto_fix = true;
2250 config.config_path = Some("/custom/path.toml".to_string());
2251 }
2252
2253 let config = server.config.read().await;
2255 assert!(config.enable_auto_fix);
2256 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2257 }
2258
2259 #[tokio::test]
2260 async fn test_document_formatting() {
2261 let server = create_test_server();
2262 let uri = Url::parse("file:///test.md").unwrap();
2263 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2264
2265 let entry = DocumentEntry {
2267 content: text.to_string(),
2268 version: Some(1),
2269 from_disk: false,
2270 };
2271 server.documents.write().await.insert(uri.clone(), entry);
2272
2273 let params = DocumentFormattingParams {
2275 text_document: TextDocumentIdentifier { uri: uri.clone() },
2276 options: FormattingOptions {
2277 tab_size: 4,
2278 insert_spaces: true,
2279 properties: HashMap::new(),
2280 trim_trailing_whitespace: Some(true),
2281 insert_final_newline: Some(true),
2282 trim_final_newlines: Some(true),
2283 },
2284 work_done_progress_params: WorkDoneProgressParams::default(),
2285 };
2286
2287 let result = server.formatting(params).await.unwrap();
2289
2290 assert!(result.is_some());
2292 let edits = result.unwrap();
2293 assert!(!edits.is_empty());
2294
2295 let edit = &edits[0];
2298 let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
2302 assert_eq!(edit.new_text, expected);
2303 }
2304
2305 #[tokio::test]
2308 async fn test_unfixable_rules_excluded_from_formatting() {
2309 let server = create_test_server();
2310 let uri = Url::parse("file:///test.md").unwrap();
2311
2312 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2314
2315 let entry = DocumentEntry {
2317 content: text.to_string(),
2318 version: Some(1),
2319 from_disk: false,
2320 };
2321 server.documents.write().await.insert(uri.clone(), entry);
2322
2323 let format_params = DocumentFormattingParams {
2325 text_document: TextDocumentIdentifier { uri: uri.clone() },
2326 options: FormattingOptions {
2327 tab_size: 4,
2328 insert_spaces: true,
2329 properties: HashMap::new(),
2330 trim_trailing_whitespace: Some(true),
2331 insert_final_newline: Some(true),
2332 trim_final_newlines: Some(true),
2333 },
2334 work_done_progress_params: WorkDoneProgressParams::default(),
2335 };
2336
2337 let format_result = server.formatting(format_params).await.unwrap();
2338 assert!(format_result.is_some(), "Should return formatting edits");
2339
2340 let edits = format_result.unwrap();
2341 assert!(!edits.is_empty(), "Should have formatting edits");
2342
2343 let formatted = &edits[0].new_text;
2344 assert!(
2345 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2346 "HTML should be preserved during formatting (Unfixable rule)"
2347 );
2348 assert!(
2349 !formatted.contains("spaces "),
2350 "Trailing spaces should be removed (fixable rule)"
2351 );
2352
2353 let range = Range {
2355 start: Position { line: 0, character: 0 },
2356 end: Position { line: 10, character: 0 },
2357 };
2358
2359 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2360
2361 let html_fix_actions: Vec<_> = code_actions
2363 .iter()
2364 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2365 .collect();
2366
2367 assert!(
2368 !html_fix_actions.is_empty(),
2369 "Quick Fix actions should be available for HTML (Unfixable rules)"
2370 );
2371
2372 let fix_all_actions: Vec<_> = code_actions
2374 .iter()
2375 .filter(|action| action.title.contains("Fix all"))
2376 .collect();
2377
2378 if let Some(fix_all_action) = fix_all_actions.first()
2379 && let Some(ref edit) = fix_all_action.edit
2380 && let Some(ref changes) = edit.changes
2381 && let Some(text_edits) = changes.get(&uri)
2382 && let Some(text_edit) = text_edits.first()
2383 {
2384 let fixed_all = &text_edit.new_text;
2385 assert!(
2386 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2387 "Fix All should preserve HTML (Unfixable rules)"
2388 );
2389 assert!(
2390 !fixed_all.contains("spaces "),
2391 "Fix All should remove trailing spaces (fixable rules)"
2392 );
2393 }
2394 }
2395
2396 #[tokio::test]
2398 async fn test_resolve_config_for_file_multi_root() {
2399 use std::fs;
2400 use tempfile::tempdir;
2401
2402 let temp_dir = tempdir().unwrap();
2403 let temp_path = temp_dir.path();
2404
2405 let project_a = temp_path.join("project_a");
2407 let project_a_docs = project_a.join("docs");
2408 fs::create_dir_all(&project_a_docs).unwrap();
2409
2410 let config_a = project_a.join(".rumdl.toml");
2411 fs::write(
2412 &config_a,
2413 r#"
2414[global]
2415
2416[MD013]
2417line_length = 60
2418"#,
2419 )
2420 .unwrap();
2421
2422 let project_b = temp_path.join("project_b");
2424 fs::create_dir(&project_b).unwrap();
2425
2426 let config_b = project_b.join(".rumdl.toml");
2427 fs::write(
2428 &config_b,
2429 r#"
2430[global]
2431
2432[MD013]
2433line_length = 120
2434"#,
2435 )
2436 .unwrap();
2437
2438 let server = create_test_server();
2440
2441 {
2443 let mut roots = server.workspace_roots.write().await;
2444 roots.push(project_a.clone());
2445 roots.push(project_b.clone());
2446 }
2447
2448 let file_a = project_a_docs.join("test.md");
2450 fs::write(&file_a, "# Test A\n").unwrap();
2451
2452 let config_for_a = server.resolve_config_for_file(&file_a).await;
2453 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2454 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2455
2456 let file_b = project_b.join("test.md");
2458 fs::write(&file_b, "# Test B\n").unwrap();
2459
2460 let config_for_b = server.resolve_config_for_file(&file_b).await;
2461 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2462 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2463 }
2464
2465 #[tokio::test]
2467 async fn test_config_resolution_respects_workspace_boundaries() {
2468 use std::fs;
2469 use tempfile::tempdir;
2470
2471 let temp_dir = tempdir().unwrap();
2472 let temp_path = temp_dir.path();
2473
2474 let parent_config = temp_path.join(".rumdl.toml");
2476 fs::write(
2477 &parent_config,
2478 r#"
2479[global]
2480
2481[MD013]
2482line_length = 80
2483"#,
2484 )
2485 .unwrap();
2486
2487 let workspace_root = temp_path.join("workspace");
2489 let workspace_subdir = workspace_root.join("subdir");
2490 fs::create_dir_all(&workspace_subdir).unwrap();
2491
2492 let workspace_config = workspace_root.join(".rumdl.toml");
2493 fs::write(
2494 &workspace_config,
2495 r#"
2496[global]
2497
2498[MD013]
2499line_length = 100
2500"#,
2501 )
2502 .unwrap();
2503
2504 let server = create_test_server();
2505
2506 {
2508 let mut roots = server.workspace_roots.write().await;
2509 roots.push(workspace_root.clone());
2510 }
2511
2512 let test_file = workspace_subdir.join("deep").join("test.md");
2514 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2515 fs::write(&test_file, "# Test\n").unwrap();
2516
2517 let config = server.resolve_config_for_file(&test_file).await;
2518 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2519
2520 assert_eq!(
2522 line_length,
2523 Some(100),
2524 "Should find workspace config, not parent config outside workspace"
2525 );
2526 }
2527
2528 #[tokio::test]
2530 async fn test_config_cache_hit() {
2531 use std::fs;
2532 use tempfile::tempdir;
2533
2534 let temp_dir = tempdir().unwrap();
2535 let temp_path = temp_dir.path();
2536
2537 let project = temp_path.join("project");
2538 fs::create_dir(&project).unwrap();
2539
2540 let config_file = project.join(".rumdl.toml");
2541 fs::write(
2542 &config_file,
2543 r#"
2544[global]
2545
2546[MD013]
2547line_length = 75
2548"#,
2549 )
2550 .unwrap();
2551
2552 let server = create_test_server();
2553 {
2554 let mut roots = server.workspace_roots.write().await;
2555 roots.push(project.clone());
2556 }
2557
2558 let test_file = project.join("test.md");
2559 fs::write(&test_file, "# Test\n").unwrap();
2560
2561 let config1 = server.resolve_config_for_file(&test_file).await;
2563 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2564 assert_eq!(line_length1, Some(75));
2565
2566 {
2568 let cache = server.config_cache.read().await;
2569 let search_dir = test_file.parent().unwrap();
2570 assert!(
2571 cache.contains_key(search_dir),
2572 "Cache should be populated after first call"
2573 );
2574 }
2575
2576 let config2 = server.resolve_config_for_file(&test_file).await;
2578 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2579 assert_eq!(line_length2, Some(75));
2580 }
2581
2582 #[tokio::test]
2584 async fn test_nested_directory_config_search() {
2585 use std::fs;
2586 use tempfile::tempdir;
2587
2588 let temp_dir = tempdir().unwrap();
2589 let temp_path = temp_dir.path();
2590
2591 let project = temp_path.join("project");
2592 fs::create_dir(&project).unwrap();
2593
2594 let config = project.join(".rumdl.toml");
2596 fs::write(
2597 &config,
2598 r#"
2599[global]
2600
2601[MD013]
2602line_length = 110
2603"#,
2604 )
2605 .unwrap();
2606
2607 let deep_dir = project.join("src").join("docs").join("guides");
2609 fs::create_dir_all(&deep_dir).unwrap();
2610 let deep_file = deep_dir.join("test.md");
2611 fs::write(&deep_file, "# Test\n").unwrap();
2612
2613 let server = create_test_server();
2614 {
2615 let mut roots = server.workspace_roots.write().await;
2616 roots.push(project.clone());
2617 }
2618
2619 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2620 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2621
2622 assert_eq!(
2623 line_length,
2624 Some(110),
2625 "Should find config by searching upward from deep directory"
2626 );
2627 }
2628
2629 #[tokio::test]
2631 async fn test_fallback_to_default_config() {
2632 use std::fs;
2633 use tempfile::tempdir;
2634
2635 let temp_dir = tempdir().unwrap();
2636 let temp_path = temp_dir.path();
2637
2638 let project = temp_path.join("project");
2639 fs::create_dir(&project).unwrap();
2640
2641 let test_file = project.join("test.md");
2644 fs::write(&test_file, "# Test\n").unwrap();
2645
2646 let server = create_test_server();
2647 {
2648 let mut roots = server.workspace_roots.write().await;
2649 roots.push(project.clone());
2650 }
2651
2652 let config = server.resolve_config_for_file(&test_file).await;
2653
2654 assert_eq!(
2656 config.global.line_length.get(),
2657 80,
2658 "Should fall back to default config when no config file found"
2659 );
2660 }
2661
2662 #[tokio::test]
2664 async fn test_config_priority_closer_wins() {
2665 use std::fs;
2666 use tempfile::tempdir;
2667
2668 let temp_dir = tempdir().unwrap();
2669 let temp_path = temp_dir.path();
2670
2671 let project = temp_path.join("project");
2672 fs::create_dir(&project).unwrap();
2673
2674 let parent_config = project.join(".rumdl.toml");
2676 fs::write(
2677 &parent_config,
2678 r#"
2679[global]
2680
2681[MD013]
2682line_length = 100
2683"#,
2684 )
2685 .unwrap();
2686
2687 let subdir = project.join("subdir");
2689 fs::create_dir(&subdir).unwrap();
2690
2691 let subdir_config = subdir.join(".rumdl.toml");
2692 fs::write(
2693 &subdir_config,
2694 r#"
2695[global]
2696
2697[MD013]
2698line_length = 50
2699"#,
2700 )
2701 .unwrap();
2702
2703 let server = create_test_server();
2704 {
2705 let mut roots = server.workspace_roots.write().await;
2706 roots.push(project.clone());
2707 }
2708
2709 let test_file = subdir.join("test.md");
2711 fs::write(&test_file, "# Test\n").unwrap();
2712
2713 let config = server.resolve_config_for_file(&test_file).await;
2714 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2715
2716 assert_eq!(
2717 line_length,
2718 Some(50),
2719 "Closer config (subdir) should override parent config"
2720 );
2721 }
2722
2723 #[tokio::test]
2729 async fn test_issue_131_pyproject_without_rumdl_section() {
2730 use std::fs;
2731 use tempfile::tempdir;
2732
2733 let parent_dir = tempdir().unwrap();
2735
2736 let project_dir = parent_dir.path().join("project");
2738 fs::create_dir(&project_dir).unwrap();
2739
2740 fs::write(
2742 project_dir.join("pyproject.toml"),
2743 r#"
2744[project]
2745name = "test-project"
2746version = "0.1.0"
2747"#,
2748 )
2749 .unwrap();
2750
2751 fs::write(
2754 parent_dir.path().join(".rumdl.toml"),
2755 r#"
2756[global]
2757disable = ["MD013"]
2758"#,
2759 )
2760 .unwrap();
2761
2762 let test_file = project_dir.join("test.md");
2763 fs::write(&test_file, "# Test\n").unwrap();
2764
2765 let server = create_test_server();
2766
2767 {
2769 let mut roots = server.workspace_roots.write().await;
2770 roots.push(parent_dir.path().to_path_buf());
2771 }
2772
2773 let config = server.resolve_config_for_file(&test_file).await;
2775
2776 assert!(
2779 config.global.disable.contains(&"MD013".to_string()),
2780 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2781 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2782 );
2783
2784 let cache = server.config_cache.read().await;
2787 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2788
2789 assert!(
2790 cache_entry.config_file.is_some(),
2791 "Should have found a config file (parent .rumdl.toml)"
2792 );
2793
2794 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2795 assert!(
2796 found_config_path.ends_with(".rumdl.toml"),
2797 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2798 );
2799 assert!(
2800 found_config_path.parent().unwrap() == parent_dir.path(),
2801 "Should have loaded config from parent directory, not project_dir"
2802 );
2803 }
2804
2805 #[tokio::test]
2810 async fn test_issue_131_pyproject_with_rumdl_section() {
2811 use std::fs;
2812 use tempfile::tempdir;
2813
2814 let parent_dir = tempdir().unwrap();
2816
2817 let project_dir = parent_dir.path().join("project");
2819 fs::create_dir(&project_dir).unwrap();
2820
2821 fs::write(
2823 project_dir.join("pyproject.toml"),
2824 r#"
2825[project]
2826name = "test-project"
2827
2828[tool.rumdl.global]
2829disable = ["MD033"]
2830"#,
2831 )
2832 .unwrap();
2833
2834 fs::write(
2836 parent_dir.path().join(".rumdl.toml"),
2837 r#"
2838[global]
2839disable = ["MD041"]
2840"#,
2841 )
2842 .unwrap();
2843
2844 let test_file = project_dir.join("test.md");
2845 fs::write(&test_file, "# Test\n").unwrap();
2846
2847 let server = create_test_server();
2848
2849 {
2851 let mut roots = server.workspace_roots.write().await;
2852 roots.push(parent_dir.path().to_path_buf());
2853 }
2854
2855 let config = server.resolve_config_for_file(&test_file).await;
2857
2858 assert!(
2860 config.global.disable.contains(&"MD033".to_string()),
2861 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2862 Expected MD033 from project_dir pyproject.toml to be disabled."
2863 );
2864
2865 assert!(
2867 !config.global.disable.contains(&"MD041".to_string()),
2868 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2869 );
2870
2871 let cache = server.config_cache.read().await;
2873 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2874
2875 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2876
2877 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2878 assert!(
2879 found_config_path.ends_with("pyproject.toml"),
2880 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2881 );
2882 assert!(
2883 found_config_path.parent().unwrap() == project_dir,
2884 "Should have loaded pyproject.toml from project_dir, not parent"
2885 );
2886 }
2887
2888 #[tokio::test]
2893 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2894 use std::fs;
2895 use tempfile::tempdir;
2896
2897 let temp_dir = tempdir().unwrap();
2898
2899 fs::write(
2901 temp_dir.path().join("pyproject.toml"),
2902 r#"
2903[project]
2904name = "test-project"
2905
2906[tool.rumdl.global]
2907disable = ["MD022"]
2908"#,
2909 )
2910 .unwrap();
2911
2912 let test_file = temp_dir.path().join("test.md");
2913 fs::write(&test_file, "# Test\n").unwrap();
2914
2915 let server = create_test_server();
2916
2917 {
2919 let mut roots = server.workspace_roots.write().await;
2920 roots.push(temp_dir.path().to_path_buf());
2921 }
2922
2923 let config = server.resolve_config_for_file(&test_file).await;
2925
2926 assert!(
2928 config.global.disable.contains(&"MD022".to_string()),
2929 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2930 );
2931
2932 let cache = server.config_cache.read().await;
2934 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2935 assert!(
2936 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2937 "Should have loaded pyproject.toml"
2938 );
2939 }
2940
2941 #[tokio::test]
2946 async fn test_issue_182_pull_diagnostics_capability_default() {
2947 let server = create_test_server();
2948
2949 assert!(
2951 !*server.client_supports_pull_diagnostics.read().await,
2952 "Default should be false - push diagnostics by default"
2953 );
2954 }
2955
2956 #[tokio::test]
2958 async fn test_issue_182_pull_diagnostics_flag_update() {
2959 let server = create_test_server();
2960
2961 *server.client_supports_pull_diagnostics.write().await = true;
2963
2964 assert!(
2965 *server.client_supports_pull_diagnostics.read().await,
2966 "Flag should be settable to true"
2967 );
2968 }
2969
2970 #[tokio::test]
2974 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2975 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2976
2977 let caps_with_diagnostic = ClientCapabilities {
2979 text_document: Some(TextDocumentClientCapabilities {
2980 diagnostic: Some(DiagnosticClientCapabilities {
2981 dynamic_registration: Some(true),
2982 related_document_support: Some(false),
2983 }),
2984 ..Default::default()
2985 }),
2986 ..Default::default()
2987 };
2988
2989 let supports_pull = caps_with_diagnostic
2991 .text_document
2992 .as_ref()
2993 .and_then(|td| td.diagnostic.as_ref())
2994 .is_some();
2995
2996 assert!(supports_pull, "Should detect pull diagnostic support");
2997 }
2998
2999 #[tokio::test]
3001 async fn test_issue_182_capability_detection_without_diagnostic_support() {
3002 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
3003
3004 let caps_without_diagnostic = ClientCapabilities {
3006 text_document: Some(TextDocumentClientCapabilities {
3007 diagnostic: None, ..Default::default()
3009 }),
3010 ..Default::default()
3011 };
3012
3013 let supports_pull = caps_without_diagnostic
3015 .text_document
3016 .as_ref()
3017 .and_then(|td| td.diagnostic.as_ref())
3018 .is_some();
3019
3020 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
3021 }
3022
3023 #[tokio::test]
3025 async fn test_issue_182_capability_detection_no_text_document() {
3026 use tower_lsp::lsp_types::ClientCapabilities;
3027
3028 let caps_no_text_doc = ClientCapabilities {
3030 text_document: None,
3031 ..Default::default()
3032 };
3033
3034 let supports_pull = caps_no_text_doc
3036 .text_document
3037 .as_ref()
3038 .and_then(|td| td.diagnostic.as_ref())
3039 .is_some();
3040
3041 assert!(
3042 !supports_pull,
3043 "Should NOT detect pull diagnostic support when text_document is None"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_resource_limit_constants() {
3049 assert_eq!(MAX_RULE_LIST_SIZE, 100);
3051 assert_eq!(MAX_LINE_LENGTH, 10_000);
3052 }
3053
3054 #[test]
3055 fn test_is_valid_rule_name_edge_cases() {
3056 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")); }
3069
3070 #[tokio::test]
3079 async fn test_lsp_toml_config_parity_generic() {
3080 use crate::config::RuleConfig;
3081 use crate::rule::Severity;
3082
3083 let server = create_test_server();
3084
3085 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3089 (
3091 "severity only - error",
3092 serde_json::json!({"severity": "error"}),
3093 RuleConfig {
3094 severity: Some(Severity::Error),
3095 values: std::collections::BTreeMap::new(),
3096 },
3097 ),
3098 (
3099 "severity only - warning",
3100 serde_json::json!({"severity": "warning"}),
3101 RuleConfig {
3102 severity: Some(Severity::Warning),
3103 values: std::collections::BTreeMap::new(),
3104 },
3105 ),
3106 (
3107 "severity only - info",
3108 serde_json::json!({"severity": "info"}),
3109 RuleConfig {
3110 severity: Some(Severity::Info),
3111 values: std::collections::BTreeMap::new(),
3112 },
3113 ),
3114 (
3116 "integer value",
3117 serde_json::json!({"lineLength": 120}),
3118 RuleConfig {
3119 severity: None,
3120 values: [("line_length".to_string(), toml::Value::Integer(120))]
3121 .into_iter()
3122 .collect(),
3123 },
3124 ),
3125 (
3127 "boolean value",
3128 serde_json::json!({"enabled": true}),
3129 RuleConfig {
3130 severity: None,
3131 values: [("enabled".to_string(), toml::Value::Boolean(true))]
3132 .into_iter()
3133 .collect(),
3134 },
3135 ),
3136 (
3138 "string value",
3139 serde_json::json!({"style": "consistent"}),
3140 RuleConfig {
3141 severity: None,
3142 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3143 .into_iter()
3144 .collect(),
3145 },
3146 ),
3147 (
3149 "array value",
3150 serde_json::json!({"allowedElements": ["div", "span"]}),
3151 RuleConfig {
3152 severity: None,
3153 values: [(
3154 "allowed_elements".to_string(),
3155 toml::Value::Array(vec![
3156 toml::Value::String("div".to_string()),
3157 toml::Value::String("span".to_string()),
3158 ]),
3159 )]
3160 .into_iter()
3161 .collect(),
3162 },
3163 ),
3164 (
3166 "severity + integer",
3167 serde_json::json!({"severity": "info", "lineLength": 80}),
3168 RuleConfig {
3169 severity: Some(Severity::Info),
3170 values: [("line_length".to_string(), toml::Value::Integer(80))]
3171 .into_iter()
3172 .collect(),
3173 },
3174 ),
3175 (
3176 "severity + multiple values",
3177 serde_json::json!({
3178 "severity": "warning",
3179 "lineLength": 100,
3180 "strict": false,
3181 "style": "atx"
3182 }),
3183 RuleConfig {
3184 severity: Some(Severity::Warning),
3185 values: [
3186 ("line_length".to_string(), toml::Value::Integer(100)),
3187 ("strict".to_string(), toml::Value::Boolean(false)),
3188 ("style".to_string(), toml::Value::String("atx".to_string())),
3189 ]
3190 .into_iter()
3191 .collect(),
3192 },
3193 ),
3194 (
3196 "camelCase conversion",
3197 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3198 RuleConfig {
3199 severity: None,
3200 values: [
3201 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3202 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3203 ]
3204 .into_iter()
3205 .collect(),
3206 },
3207 ),
3208 ];
3209
3210 for (description, lsp_json, expected_toml_config) in test_configs {
3211 let mut lsp_config = crate::config::Config::default();
3212 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3213
3214 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3215
3216 assert_eq!(
3218 lsp_rule.severity, expected_toml_config.severity,
3219 "Parity failure [{description}]: severity mismatch. \
3220 LSP={:?}, TOML={:?}",
3221 lsp_rule.severity, expected_toml_config.severity
3222 );
3223
3224 assert_eq!(
3226 lsp_rule.values, expected_toml_config.values,
3227 "Parity failure [{description}]: values mismatch. \
3228 LSP={:?}, TOML={:?}",
3229 lsp_rule.values, expected_toml_config.values
3230 );
3231 }
3232 }
3233
3234 #[tokio::test]
3236 async fn test_lsp_config_if_absent_preserves_existing() {
3237 use crate::config::RuleConfig;
3238 use crate::rule::Severity;
3239
3240 let server = create_test_server();
3241
3242 let mut config = crate::config::Config::default();
3244 config.rules.insert(
3245 "MD013".to_string(),
3246 RuleConfig {
3247 severity: Some(Severity::Error),
3248 values: [("line_length".to_string(), toml::Value::Integer(80))]
3249 .into_iter()
3250 .collect(),
3251 },
3252 );
3253
3254 let lsp_json = serde_json::json!({
3256 "severity": "info",
3257 "lineLength": 120
3258 });
3259 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3260
3261 let rule = config.rules.get("MD013").expect("Rule should exist");
3262
3263 assert_eq!(
3265 rule.severity,
3266 Some(Severity::Error),
3267 "Existing severity should not be overwritten"
3268 );
3269
3270 assert_eq!(
3272 rule.values.get("line_length"),
3273 Some(&toml::Value::Integer(80)),
3274 "Existing values should not be overwritten"
3275 );
3276 }
3277
3278 #[test]
3281 fn test_apply_formatting_options_insert_final_newline() {
3282 let options = FormattingOptions {
3283 tab_size: 4,
3284 insert_spaces: true,
3285 properties: HashMap::new(),
3286 trim_trailing_whitespace: None,
3287 insert_final_newline: Some(true),
3288 trim_final_newlines: None,
3289 };
3290
3291 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3293 assert_eq!(result, "hello\n");
3294
3295 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3297 assert_eq!(result, "hello\n");
3298 }
3299
3300 #[test]
3301 fn test_apply_formatting_options_trim_final_newlines() {
3302 let options = FormattingOptions {
3303 tab_size: 4,
3304 insert_spaces: true,
3305 properties: HashMap::new(),
3306 trim_trailing_whitespace: None,
3307 insert_final_newline: None,
3308 trim_final_newlines: Some(true),
3309 };
3310
3311 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3313 assert_eq!(result, "hello");
3314
3315 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3317 assert_eq!(result, "hello");
3318 }
3319
3320 #[test]
3321 fn test_apply_formatting_options_trim_and_insert_combined() {
3322 let options = FormattingOptions {
3324 tab_size: 4,
3325 insert_spaces: true,
3326 properties: HashMap::new(),
3327 trim_trailing_whitespace: None,
3328 insert_final_newline: Some(true),
3329 trim_final_newlines: Some(true),
3330 };
3331
3332 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3334 assert_eq!(result, "hello\n");
3335
3336 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3338 assert_eq!(result, "hello\n");
3339
3340 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3342 assert_eq!(result, "hello\n");
3343 }
3344
3345 #[test]
3346 fn test_apply_formatting_options_trim_trailing_whitespace() {
3347 let options = FormattingOptions {
3348 tab_size: 4,
3349 insert_spaces: true,
3350 properties: HashMap::new(),
3351 trim_trailing_whitespace: Some(true),
3352 insert_final_newline: Some(true),
3353 trim_final_newlines: None,
3354 };
3355
3356 let result = RumdlLanguageServer::apply_formatting_options("hello \nworld\t\n".to_string(), &options);
3358 assert_eq!(result, "hello\nworld\n");
3359 }
3360
3361 #[test]
3362 fn test_apply_formatting_options_issue_265_scenario() {
3363 let options = FormattingOptions {
3368 tab_size: 4,
3369 insert_spaces: true,
3370 properties: HashMap::new(),
3371 trim_trailing_whitespace: None,
3372 insert_final_newline: Some(true),
3373 trim_final_newlines: Some(true),
3374 };
3375
3376 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
3378 assert_eq!(
3379 result, "hello foobar hello.\n",
3380 "Should have exactly one trailing newline"
3381 );
3382
3383 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
3385 assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
3386
3387 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
3389 assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
3390 }
3391
3392 #[test]
3393 fn test_apply_formatting_options_no_options() {
3394 let options = FormattingOptions {
3396 tab_size: 4,
3397 insert_spaces: true,
3398 properties: HashMap::new(),
3399 trim_trailing_whitespace: None,
3400 insert_final_newline: None,
3401 trim_final_newlines: None,
3402 };
3403
3404 let content = "hello \nworld\n\n\n";
3405 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3406 assert_eq!(result, content, "Content should be unchanged when no options set");
3407 }
3408
3409 #[test]
3410 fn test_apply_formatting_options_empty_content() {
3411 let options = FormattingOptions {
3412 tab_size: 4,
3413 insert_spaces: true,
3414 properties: HashMap::new(),
3415 trim_trailing_whitespace: Some(true),
3416 insert_final_newline: Some(true),
3417 trim_final_newlines: Some(true),
3418 };
3419
3420 let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
3422 assert_eq!(result, "");
3423
3424 let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
3426 assert_eq!(result, "\n");
3427 }
3428
3429 #[test]
3430 fn test_apply_formatting_options_multiline_content() {
3431 let options = FormattingOptions {
3432 tab_size: 4,
3433 insert_spaces: true,
3434 properties: HashMap::new(),
3435 trim_trailing_whitespace: Some(true),
3436 insert_final_newline: Some(true),
3437 trim_final_newlines: Some(true),
3438 };
3439
3440 let content = "# Heading \n\nParagraph \n- List item \n\n\n";
3441 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3442 assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
3443 }
3444
3445 #[test]
3446 fn test_code_action_kind_filtering() {
3447 let matches = |action_kind: &str, requested: &str| -> bool { action_kind.starts_with(requested) };
3451
3452 assert!(matches("source.fixAll.rumdl", "source.fixAll"));
3454
3455 assert!(matches("source.fixAll.rumdl", "source.fixAll.rumdl"));
3457
3458 assert!(matches("source.fixAll.rumdl", "source"));
3460
3461 assert!(matches("quickfix", "quickfix"));
3463
3464 assert!(!matches("source.fixAll.rumdl", "quickfix"));
3466
3467 assert!(!matches("quickfix", "source.fixAll"));
3469
3470 assert!(!matches("source.fixAll", "source.fixAll.rumdl"));
3472 }
3473
3474 #[test]
3475 fn test_code_action_kind_filter_with_empty_array() {
3476 let filter_actions = |kinds: Option<Vec<&str>>| -> bool {
3480 if let Some(ref k) = kinds
3482 && !k.is_empty()
3483 {
3484 false
3486 } else {
3487 true
3489 }
3490 };
3491
3492 assert!(filter_actions(None));
3494
3495 assert!(filter_actions(Some(vec![])));
3497
3498 assert!(!filter_actions(Some(vec!["source.fixAll"])));
3500 }
3501
3502 #[test]
3503 fn test_code_action_kind_constants() {
3504 let fix_all_rumdl = CodeActionKind::new("source.fixAll.rumdl");
3506 assert_eq!(fix_all_rumdl.as_str(), "source.fixAll.rumdl");
3507
3508 assert!(
3510 fix_all_rumdl
3511 .as_str()
3512 .starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str())
3513 );
3514 }
3515}