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::QUICKFIX),
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::Simple(true)),
1138 document_formatting_provider: Some(OneOf::Left(true)),
1139 document_range_formatting_provider: Some(OneOf::Left(true)),
1140 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1141 identifier: Some("rumdl".to_string()),
1142 inter_file_dependencies: true,
1143 workspace_diagnostics: false,
1144 work_done_progress_options: WorkDoneProgressOptions::default(),
1145 })),
1146 workspace: Some(WorkspaceServerCapabilities {
1147 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1148 supported: Some(true),
1149 change_notifications: Some(OneOf::Left(true)),
1150 }),
1151 file_operations: None,
1152 }),
1153 ..Default::default()
1154 },
1155 server_info: Some(ServerInfo {
1156 name: "rumdl".to_string(),
1157 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1158 }),
1159 })
1160 }
1161
1162 async fn initialized(&self, _: InitializedParams) {
1163 let version = env!("CARGO_PKG_VERSION");
1164
1165 let (binary_path, build_time) = std::env::current_exe()
1167 .ok()
1168 .map(|path| {
1169 let path_str = path.to_str().unwrap_or("unknown").to_string();
1170 let build_time = std::fs::metadata(&path)
1171 .ok()
1172 .and_then(|metadata| metadata.modified().ok())
1173 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1174 .and_then(|duration| {
1175 let secs = duration.as_secs();
1176 chrono::DateTime::from_timestamp(secs as i64, 0)
1177 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1178 })
1179 .unwrap_or_else(|| "unknown".to_string());
1180 (path_str, build_time)
1181 })
1182 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1183
1184 let working_dir = std::env::current_dir()
1185 .ok()
1186 .and_then(|p| p.to_str().map(|s| s.to_string()))
1187 .unwrap_or_else(|| "unknown".to_string());
1188
1189 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1190 log::info!("Working directory: {working_dir}");
1191
1192 self.client
1193 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1194 .await;
1195
1196 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1198 log::warn!("Failed to trigger initial workspace indexing");
1199 } else {
1200 log::info!("Triggered initial workspace indexing for cross-file analysis");
1201 }
1202
1203 let markdown_patterns = [
1206 "**/*.md",
1207 "**/*.markdown",
1208 "**/*.mdx",
1209 "**/*.mkd",
1210 "**/*.mkdn",
1211 "**/*.mdown",
1212 "**/*.mdwn",
1213 "**/*.qmd",
1214 "**/*.rmd",
1215 ];
1216 let watchers: Vec<_> = markdown_patterns
1217 .iter()
1218 .map(|pattern| FileSystemWatcher {
1219 glob_pattern: GlobPattern::String((*pattern).to_string()),
1220 kind: Some(WatchKind::all()),
1221 })
1222 .collect();
1223
1224 let registration = Registration {
1225 id: "markdown-watcher".to_string(),
1226 method: "workspace/didChangeWatchedFiles".to_string(),
1227 register_options: Some(
1228 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1229 ),
1230 };
1231
1232 if self.client.register_capability(vec![registration]).await.is_err() {
1233 log::debug!("Client does not support file watching capability");
1234 }
1235 }
1236
1237 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1238 let mut roots = self.workspace_roots.write().await;
1240
1241 for removed in ¶ms.event.removed {
1243 if let Ok(path) = removed.uri.to_file_path() {
1244 roots.retain(|r| r != &path);
1245 log::info!("Removed workspace root: {}", path.display());
1246 }
1247 }
1248
1249 for added in ¶ms.event.added {
1251 if let Ok(path) = added.uri.to_file_path()
1252 && !roots.contains(&path)
1253 {
1254 log::info!("Added workspace root: {}", path.display());
1255 roots.push(path);
1256 }
1257 }
1258 drop(roots);
1259
1260 self.config_cache.write().await.clear();
1262
1263 self.reload_configuration().await;
1265
1266 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1268 log::warn!("Failed to trigger workspace rescan after folder change");
1269 }
1270 }
1271
1272 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1273 log::debug!("Configuration changed: {:?}", params.settings);
1274
1275 let settings_value = params.settings;
1279
1280 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1282 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1283 } else {
1284 settings_value
1285 };
1286
1287 let mut config_applied = false;
1289 let mut warnings: Vec<String> = Vec::new();
1290
1291 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1295 && (rule_settings.disable.is_some()
1296 || rule_settings.enable.is_some()
1297 || rule_settings.line_length.is_some()
1298 || !rule_settings.rules.is_empty())
1299 {
1300 if let Some(ref disable) = rule_settings.disable {
1302 for rule in disable {
1303 if !is_valid_rule_name(rule) {
1304 warnings.push(format!("Unknown rule in disable list: {rule}"));
1305 }
1306 }
1307 }
1308 if let Some(ref enable) = rule_settings.enable {
1309 for rule in enable {
1310 if !is_valid_rule_name(rule) {
1311 warnings.push(format!("Unknown rule in enable list: {rule}"));
1312 }
1313 }
1314 }
1315 for rule_name in rule_settings.rules.keys() {
1317 if !is_valid_rule_name(rule_name) {
1318 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1319 }
1320 }
1321
1322 log::info!("Applied rule settings from configuration (Neovim style)");
1323 let mut config = self.config.write().await;
1324 config.settings = Some(rule_settings);
1325 drop(config);
1326 config_applied = true;
1327 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1328 && (full_config.config_path.is_some()
1329 || full_config.enable_rules.is_some()
1330 || full_config.disable_rules.is_some()
1331 || full_config.settings.is_some()
1332 || !full_config.enable_linting
1333 || full_config.enable_auto_fix)
1334 {
1335 if let Some(ref rules) = full_config.enable_rules {
1337 for rule in rules {
1338 if !is_valid_rule_name(rule) {
1339 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1340 }
1341 }
1342 }
1343 if let Some(ref rules) = full_config.disable_rules {
1344 for rule in rules {
1345 if !is_valid_rule_name(rule) {
1346 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1347 }
1348 }
1349 }
1350
1351 log::info!("Applied full LSP configuration from settings");
1352 *self.config.write().await = full_config;
1353 config_applied = true;
1354 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1355 let mut config = self.config.write().await;
1358
1359 let mut rules = std::collections::HashMap::new();
1361 let mut disable = Vec::new();
1362 let mut enable = Vec::new();
1363 let mut line_length = None;
1364
1365 for (key, value) in obj {
1366 match key.as_str() {
1367 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1368 Ok(d) => {
1369 if d.len() > MAX_RULE_LIST_SIZE {
1370 warnings.push(format!(
1371 "Too many rules in 'disable' ({} > {}), truncating",
1372 d.len(),
1373 MAX_RULE_LIST_SIZE
1374 ));
1375 }
1376 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1377 if !is_valid_rule_name(rule) {
1378 warnings.push(format!("Unknown rule in disable: {rule}"));
1379 }
1380 }
1381 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1382 }
1383 Err(_) => {
1384 warnings.push(format!(
1385 "Invalid 'disable' value: expected array of strings, got {value}"
1386 ));
1387 }
1388 },
1389 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1390 Ok(e) => {
1391 if e.len() > MAX_RULE_LIST_SIZE {
1392 warnings.push(format!(
1393 "Too many rules in 'enable' ({} > {}), truncating",
1394 e.len(),
1395 MAX_RULE_LIST_SIZE
1396 ));
1397 }
1398 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1399 if !is_valid_rule_name(rule) {
1400 warnings.push(format!("Unknown rule in enable: {rule}"));
1401 }
1402 }
1403 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1404 }
1405 Err(_) => {
1406 warnings.push(format!(
1407 "Invalid 'enable' value: expected array of strings, got {value}"
1408 ));
1409 }
1410 },
1411 "lineLength" | "line_length" | "line-length" => {
1412 if let Some(l) = value.as_u64() {
1413 match usize::try_from(l) {
1414 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1415 Ok(len) => warnings.push(format!(
1416 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1417 )),
1418 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1419 }
1420 } else {
1421 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1422 }
1423 }
1424 _ if key.starts_with("MD") || key.starts_with("md") => {
1426 let normalized = key.to_uppercase();
1427 if !is_valid_rule_name(&normalized) {
1428 warnings.push(format!("Unknown rule: {key}"));
1429 }
1430 rules.insert(normalized, value);
1431 }
1432 _ => {
1433 warnings.push(format!("Unknown configuration key: {key}"));
1435 }
1436 }
1437 }
1438
1439 let settings = LspRuleSettings {
1440 line_length,
1441 disable: if disable.is_empty() { None } else { Some(disable) },
1442 enable: if enable.is_empty() { None } else { Some(enable) },
1443 rules,
1444 };
1445
1446 log::info!("Applied Neovim-style rule settings (manual parse)");
1447 config.settings = Some(settings);
1448 drop(config);
1449 config_applied = true;
1450 } else {
1451 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1452 }
1453
1454 for warning in &warnings {
1456 log::warn!("{warning}");
1457 }
1458
1459 if !warnings.is_empty() {
1461 let message = if warnings.len() == 1 {
1462 format!("rumdl: {}", warnings[0])
1463 } else {
1464 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1465 };
1466 self.client.log_message(MessageType::WARNING, message).await;
1467 }
1468
1469 if !config_applied {
1470 log::debug!("No configuration changes applied");
1471 }
1472
1473 self.config_cache.write().await.clear();
1475
1476 let doc_list: Vec<_> = {
1478 let documents = self.documents.read().await;
1479 documents
1480 .iter()
1481 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1482 .collect()
1483 };
1484
1485 let tasks = doc_list.into_iter().map(|(uri, text)| {
1487 let server = self.clone();
1488 tokio::spawn(async move {
1489 server.update_diagnostics(uri, text).await;
1490 })
1491 });
1492
1493 let _ = join_all(tasks).await;
1495 }
1496
1497 async fn shutdown(&self) -> JsonRpcResult<()> {
1498 log::info!("Shutting down rumdl Language Server");
1499
1500 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1502
1503 Ok(())
1504 }
1505
1506 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1507 let uri = params.text_document.uri;
1508 let text = params.text_document.text;
1509 let version = params.text_document.version;
1510
1511 let entry = DocumentEntry {
1512 content: text.clone(),
1513 version: Some(version),
1514 from_disk: false,
1515 };
1516 self.documents.write().await.insert(uri.clone(), entry);
1517
1518 if let Ok(path) = uri.to_file_path() {
1520 let _ = self
1521 .update_tx
1522 .send(IndexUpdate::FileChanged {
1523 path,
1524 content: text.clone(),
1525 })
1526 .await;
1527 }
1528
1529 self.update_diagnostics(uri, text).await;
1530 }
1531
1532 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1533 let uri = params.text_document.uri;
1534 let version = params.text_document.version;
1535
1536 if let Some(change) = params.content_changes.into_iter().next() {
1537 let text = change.text;
1538
1539 let entry = DocumentEntry {
1540 content: text.clone(),
1541 version: Some(version),
1542 from_disk: false,
1543 };
1544 self.documents.write().await.insert(uri.clone(), entry);
1545
1546 if let Ok(path) = uri.to_file_path() {
1548 let _ = self
1549 .update_tx
1550 .send(IndexUpdate::FileChanged {
1551 path,
1552 content: text.clone(),
1553 })
1554 .await;
1555 }
1556
1557 self.update_diagnostics(uri, text).await;
1558 }
1559 }
1560
1561 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1562 let config_guard = self.config.read().await;
1563 let enable_auto_fix = config_guard.enable_auto_fix;
1564 drop(config_guard);
1565
1566 if !enable_auto_fix {
1567 return Ok(None);
1568 }
1569
1570 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1572 return Ok(None);
1573 };
1574
1575 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1577 Ok(Some(fixed_text)) => {
1578 Ok(Some(vec![TextEdit {
1580 range: Range {
1581 start: Position { line: 0, character: 0 },
1582 end: self.get_end_position(&text),
1583 },
1584 new_text: fixed_text,
1585 }]))
1586 }
1587 Ok(None) => Ok(None),
1588 Err(e) => {
1589 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1590 Ok(None)
1591 }
1592 }
1593 }
1594
1595 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1596 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1599 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1600 .await;
1601 }
1602 }
1603
1604 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1605 self.documents.write().await.remove(¶ms.text_document.uri);
1607
1608 self.client
1611 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1612 .await;
1613 }
1614
1615 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1616 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1618
1619 let mut config_changed = false;
1620
1621 for change in ¶ms.changes {
1622 if let Ok(path) = change.uri.to_file_path() {
1623 let file_name = path.file_name().and_then(|f| f.to_str());
1624 let extension = path.extension().and_then(|e| e.to_str());
1625
1626 if let Some(name) = file_name
1628 && CONFIG_FILES.contains(&name)
1629 && !config_changed
1630 {
1631 log::info!("Config file changed: {}, invalidating config cache", path.display());
1632
1633 let mut cache = self.config_cache.write().await;
1635 cache.retain(|_, entry| {
1636 if let Some(config_file) = &entry.config_file {
1637 config_file != &path
1638 } else {
1639 true
1640 }
1641 });
1642
1643 drop(cache);
1645 self.reload_configuration().await;
1646 config_changed = true;
1647 }
1648
1649 if let Some(ext) = extension
1651 && is_markdown_extension(ext)
1652 {
1653 match change.typ {
1654 FileChangeType::CREATED | FileChangeType::CHANGED => {
1655 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1657 let _ = self
1658 .update_tx
1659 .send(IndexUpdate::FileChanged {
1660 path: path.clone(),
1661 content,
1662 })
1663 .await;
1664 }
1665 }
1666 FileChangeType::DELETED => {
1667 let _ = self
1668 .update_tx
1669 .send(IndexUpdate::FileDeleted { path: path.clone() })
1670 .await;
1671 }
1672 _ => {}
1673 }
1674 }
1675 }
1676 }
1677
1678 if config_changed {
1680 let docs_to_update: Vec<(Url, String)> = {
1681 let docs = self.documents.read().await;
1682 docs.iter()
1683 .filter(|(_, entry)| !entry.from_disk)
1684 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1685 .collect()
1686 };
1687
1688 for (uri, text) in docs_to_update {
1689 self.update_diagnostics(uri, text).await;
1690 }
1691 }
1692 }
1693
1694 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1695 let uri = params.text_document.uri;
1696 let range = params.range;
1697
1698 if let Some(text) = self.get_document_content(&uri).await {
1699 match self.get_code_actions(&uri, &text, range).await {
1700 Ok(actions) => {
1701 let response: Vec<CodeActionOrCommand> =
1702 actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
1703 Ok(Some(response))
1704 }
1705 Err(e) => {
1706 log::error!("Failed to get code actions: {e}");
1707 Ok(None)
1708 }
1709 }
1710 } else {
1711 Ok(None)
1712 }
1713 }
1714
1715 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1716 log::debug!(
1721 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1722 params.range
1723 );
1724
1725 let formatting_params = DocumentFormattingParams {
1726 text_document: params.text_document,
1727 options: params.options,
1728 work_done_progress_params: params.work_done_progress_params,
1729 };
1730
1731 self.formatting(formatting_params).await
1732 }
1733
1734 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1735 let uri = params.text_document.uri;
1736 let options = params.options;
1737
1738 log::debug!("Formatting request for: {uri}");
1739 log::debug!(
1740 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1741 options.insert_final_newline,
1742 options.trim_final_newlines,
1743 options.trim_trailing_whitespace
1744 );
1745
1746 if let Some(text) = self.get_document_content(&uri).await {
1747 let config_guard = self.config.read().await;
1749 let lsp_config = config_guard.clone();
1750 drop(config_guard);
1751
1752 let file_path = uri.to_file_path().ok();
1754 let file_config = if let Some(ref path) = file_path {
1755 self.resolve_config_for_file(path).await
1756 } else {
1757 self.rumdl_config.read().await.clone()
1759 };
1760
1761 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1763
1764 let all_rules = rules::all_rules(&rumdl_config);
1765 let flavor = if let Some(ref path) = file_path {
1766 rumdl_config.get_flavor_for_file(path)
1767 } else {
1768 rumdl_config.markdown_flavor()
1769 };
1770
1771 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1773
1774 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1776
1777 let mut result = text.clone();
1779 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1780 Ok(warnings) => {
1781 log::debug!(
1782 "Found {} warnings, {} with fixes",
1783 warnings.len(),
1784 warnings.iter().filter(|w| w.fix.is_some()).count()
1785 );
1786
1787 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1788 if has_fixes {
1789 let fixable_warnings: Vec<_> = warnings
1791 .iter()
1792 .filter(|w| {
1793 if let Some(rule_name) = &w.rule_name {
1794 filtered_rules
1795 .iter()
1796 .find(|r| r.name() == rule_name)
1797 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1798 .unwrap_or(false)
1799 } else {
1800 false
1801 }
1802 })
1803 .cloned()
1804 .collect();
1805
1806 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1807 Ok(fixed_content) => {
1808 result = fixed_content;
1809 }
1810 Err(e) => {
1811 log::error!("Failed to apply fixes: {e}");
1812 }
1813 }
1814 }
1815 }
1816 Err(e) => {
1817 log::error!("Failed to lint document: {e}");
1818 }
1819 }
1820
1821 result = Self::apply_formatting_options(result, &options);
1824
1825 if result != text {
1827 log::debug!("Returning formatting edits");
1828 let end_position = self.get_end_position(&text);
1829 let edit = TextEdit {
1830 range: Range {
1831 start: Position { line: 0, character: 0 },
1832 end: end_position,
1833 },
1834 new_text: result,
1835 };
1836 return Ok(Some(vec![edit]));
1837 }
1838
1839 Ok(Some(Vec::new()))
1840 } else {
1841 log::warn!("Document not found: {uri}");
1842 Ok(None)
1843 }
1844 }
1845
1846 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1847 let uri = params.text_document.uri;
1848
1849 if let Some(text) = self.get_open_document_content(&uri).await {
1850 match self.lint_document(&uri, &text).await {
1851 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1852 RelatedFullDocumentDiagnosticReport {
1853 related_documents: None,
1854 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1855 result_id: None,
1856 items: diagnostics,
1857 },
1858 },
1859 ))),
1860 Err(e) => {
1861 log::error!("Failed to get diagnostics: {e}");
1862 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1863 RelatedFullDocumentDiagnosticReport {
1864 related_documents: None,
1865 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1866 result_id: None,
1867 items: Vec::new(),
1868 },
1869 },
1870 )))
1871 }
1872 }
1873 } else {
1874 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1875 RelatedFullDocumentDiagnosticReport {
1876 related_documents: None,
1877 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1878 result_id: None,
1879 items: Vec::new(),
1880 },
1881 },
1882 )))
1883 }
1884 }
1885}
1886
1887#[cfg(test)]
1888mod tests {
1889 use super::*;
1890 use crate::rule::LintWarning;
1891 use tower_lsp::LspService;
1892
1893 fn create_test_server() -> RumdlLanguageServer {
1894 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
1895 service.inner().clone()
1896 }
1897
1898 #[test]
1899 fn test_is_valid_rule_name() {
1900 assert!(is_valid_rule_name("MD001"));
1902 assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
1906 assert!(is_valid_rule_name("MD005"));
1907 assert!(is_valid_rule_name("MD007"));
1908 assert!(is_valid_rule_name("MD009"));
1909 assert!(is_valid_rule_name("MD041"));
1910 assert!(is_valid_rule_name("MD060"));
1911 assert!(is_valid_rule_name("MD061"));
1912
1913 assert!(is_valid_rule_name("all"));
1915 assert!(is_valid_rule_name("ALL"));
1916 assert!(is_valid_rule_name("All"));
1917
1918 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"));
1931 assert!(!is_valid_rule_name("not-a-rule"));
1932 assert!(!is_valid_rule_name(""));
1933 assert!(!is_valid_rule_name("random-text"));
1934 }
1935
1936 #[tokio::test]
1937 async fn test_server_creation() {
1938 let server = create_test_server();
1939
1940 let config = server.config.read().await;
1942 assert!(config.enable_linting);
1943 assert!(!config.enable_auto_fix);
1944 }
1945
1946 #[tokio::test]
1947 async fn test_lint_document() {
1948 let server = create_test_server();
1949
1950 let uri = Url::parse("file:///test.md").unwrap();
1952 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1953
1954 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1955
1956 assert!(!diagnostics.is_empty());
1958 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
1959 }
1960
1961 #[tokio::test]
1962 async fn test_lint_document_disabled() {
1963 let server = create_test_server();
1964
1965 server.config.write().await.enable_linting = false;
1967
1968 let uri = Url::parse("file:///test.md").unwrap();
1969 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1970
1971 let diagnostics = server.lint_document(&uri, text).await.unwrap();
1972
1973 assert!(diagnostics.is_empty());
1975 }
1976
1977 #[tokio::test]
1978 async fn test_get_code_actions() {
1979 let server = create_test_server();
1980
1981 let uri = Url::parse("file:///test.md").unwrap();
1982 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
1983
1984 let range = Range {
1986 start: Position { line: 0, character: 0 },
1987 end: Position { line: 3, character: 21 },
1988 };
1989
1990 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
1991
1992 assert!(!actions.is_empty());
1994 assert!(actions.iter().any(|a| a.title.contains("trailing")));
1995 }
1996
1997 #[tokio::test]
1998 async fn test_get_code_actions_outside_range() {
1999 let server = create_test_server();
2000
2001 let uri = Url::parse("file:///test.md").unwrap();
2002 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2003
2004 let range = Range {
2006 start: Position { line: 0, character: 0 },
2007 end: Position { line: 0, character: 6 },
2008 };
2009
2010 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2011
2012 assert!(actions.is_empty());
2014 }
2015
2016 #[tokio::test]
2017 async fn test_document_storage() {
2018 let server = create_test_server();
2019
2020 let uri = Url::parse("file:///test.md").unwrap();
2021 let text = "# Test Document";
2022
2023 let entry = DocumentEntry {
2025 content: text.to_string(),
2026 version: Some(1),
2027 from_disk: false,
2028 };
2029 server.documents.write().await.insert(uri.clone(), entry);
2030
2031 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
2033 assert_eq!(stored, Some(text.to_string()));
2034
2035 server.documents.write().await.remove(&uri);
2037
2038 let stored = server.documents.read().await.get(&uri).cloned();
2040 assert_eq!(stored, None);
2041 }
2042
2043 #[tokio::test]
2044 async fn test_configuration_loading() {
2045 let server = create_test_server();
2046
2047 server.load_configuration(false).await;
2049
2050 let rumdl_config = server.rumdl_config.read().await;
2053 drop(rumdl_config); }
2056
2057 #[tokio::test]
2058 async fn test_load_config_for_lsp() {
2059 let result = RumdlLanguageServer::load_config_for_lsp(None);
2061 assert!(result.is_ok());
2062
2063 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2065 assert!(result.is_err());
2066 }
2067
2068 #[tokio::test]
2069 async fn test_warning_conversion() {
2070 let warning = LintWarning {
2071 message: "Test warning".to_string(),
2072 line: 1,
2073 column: 1,
2074 end_line: 1,
2075 end_column: 10,
2076 severity: crate::rule::Severity::Warning,
2077 fix: None,
2078 rule_name: Some("MD001".to_string()),
2079 };
2080
2081 let diagnostic = warning_to_diagnostic(&warning);
2083 assert_eq!(diagnostic.message, "Test warning");
2084 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2085 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2086
2087 let uri = Url::parse("file:///test.md").unwrap();
2089 let actions = warning_to_code_actions(&warning, &uri, "Test content");
2090 assert_eq!(actions.len(), 1);
2092 assert_eq!(actions[0].title, "Ignore MD001 for this line");
2093 }
2094
2095 #[tokio::test]
2096 async fn test_multiple_documents() {
2097 let server = create_test_server();
2098
2099 let uri1 = Url::parse("file:///test1.md").unwrap();
2100 let uri2 = Url::parse("file:///test2.md").unwrap();
2101 let text1 = "# Document 1";
2102 let text2 = "# Document 2";
2103
2104 {
2106 let mut docs = server.documents.write().await;
2107 let entry1 = DocumentEntry {
2108 content: text1.to_string(),
2109 version: Some(1),
2110 from_disk: false,
2111 };
2112 let entry2 = DocumentEntry {
2113 content: text2.to_string(),
2114 version: Some(1),
2115 from_disk: false,
2116 };
2117 docs.insert(uri1.clone(), entry1);
2118 docs.insert(uri2.clone(), entry2);
2119 }
2120
2121 let docs = server.documents.read().await;
2123 assert_eq!(docs.len(), 2);
2124 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2125 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2126 }
2127
2128 #[tokio::test]
2129 async fn test_auto_fix_on_save() {
2130 let server = create_test_server();
2131
2132 {
2134 let mut config = server.config.write().await;
2135 config.enable_auto_fix = true;
2136 }
2137
2138 let uri = Url::parse("file:///test.md").unwrap();
2139 let text = "#Heading without space"; let entry = DocumentEntry {
2143 content: text.to_string(),
2144 version: Some(1),
2145 from_disk: false,
2146 };
2147 server.documents.write().await.insert(uri.clone(), entry);
2148
2149 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2151 assert!(fixed.is_some());
2152 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2154 }
2155
2156 #[tokio::test]
2157 async fn test_get_end_position() {
2158 let server = create_test_server();
2159
2160 let pos = server.get_end_position("Hello");
2162 assert_eq!(pos.line, 0);
2163 assert_eq!(pos.character, 5);
2164
2165 let pos = server.get_end_position("Hello\nWorld\nTest");
2167 assert_eq!(pos.line, 2);
2168 assert_eq!(pos.character, 4);
2169
2170 let pos = server.get_end_position("");
2172 assert_eq!(pos.line, 0);
2173 assert_eq!(pos.character, 0);
2174
2175 let pos = server.get_end_position("Hello\n");
2177 assert_eq!(pos.line, 1);
2178 assert_eq!(pos.character, 0);
2179 }
2180
2181 #[tokio::test]
2182 async fn test_empty_document_handling() {
2183 let server = create_test_server();
2184
2185 let uri = Url::parse("file:///empty.md").unwrap();
2186 let text = "";
2187
2188 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2190 assert!(diagnostics.is_empty());
2191
2192 let range = Range {
2194 start: Position { line: 0, character: 0 },
2195 end: Position { line: 0, character: 0 },
2196 };
2197 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2198 assert!(actions.is_empty());
2199 }
2200
2201 #[tokio::test]
2202 async fn test_config_update() {
2203 let server = create_test_server();
2204
2205 {
2207 let mut config = server.config.write().await;
2208 config.enable_auto_fix = true;
2209 config.config_path = Some("/custom/path.toml".to_string());
2210 }
2211
2212 let config = server.config.read().await;
2214 assert!(config.enable_auto_fix);
2215 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2216 }
2217
2218 #[tokio::test]
2219 async fn test_document_formatting() {
2220 let server = create_test_server();
2221 let uri = Url::parse("file:///test.md").unwrap();
2222 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2223
2224 let entry = DocumentEntry {
2226 content: text.to_string(),
2227 version: Some(1),
2228 from_disk: false,
2229 };
2230 server.documents.write().await.insert(uri.clone(), entry);
2231
2232 let params = DocumentFormattingParams {
2234 text_document: TextDocumentIdentifier { uri: uri.clone() },
2235 options: FormattingOptions {
2236 tab_size: 4,
2237 insert_spaces: true,
2238 properties: HashMap::new(),
2239 trim_trailing_whitespace: Some(true),
2240 insert_final_newline: Some(true),
2241 trim_final_newlines: Some(true),
2242 },
2243 work_done_progress_params: WorkDoneProgressParams::default(),
2244 };
2245
2246 let result = server.formatting(params).await.unwrap();
2248
2249 assert!(result.is_some());
2251 let edits = result.unwrap();
2252 assert!(!edits.is_empty());
2253
2254 let edit = &edits[0];
2257 let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
2261 assert_eq!(edit.new_text, expected);
2262 }
2263
2264 #[tokio::test]
2267 async fn test_unfixable_rules_excluded_from_formatting() {
2268 let server = create_test_server();
2269 let uri = Url::parse("file:///test.md").unwrap();
2270
2271 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2273
2274 let entry = DocumentEntry {
2276 content: text.to_string(),
2277 version: Some(1),
2278 from_disk: false,
2279 };
2280 server.documents.write().await.insert(uri.clone(), entry);
2281
2282 let format_params = DocumentFormattingParams {
2284 text_document: TextDocumentIdentifier { uri: uri.clone() },
2285 options: FormattingOptions {
2286 tab_size: 4,
2287 insert_spaces: true,
2288 properties: HashMap::new(),
2289 trim_trailing_whitespace: Some(true),
2290 insert_final_newline: Some(true),
2291 trim_final_newlines: Some(true),
2292 },
2293 work_done_progress_params: WorkDoneProgressParams::default(),
2294 };
2295
2296 let format_result = server.formatting(format_params).await.unwrap();
2297 assert!(format_result.is_some(), "Should return formatting edits");
2298
2299 let edits = format_result.unwrap();
2300 assert!(!edits.is_empty(), "Should have formatting edits");
2301
2302 let formatted = &edits[0].new_text;
2303 assert!(
2304 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2305 "HTML should be preserved during formatting (Unfixable rule)"
2306 );
2307 assert!(
2308 !formatted.contains("spaces "),
2309 "Trailing spaces should be removed (fixable rule)"
2310 );
2311
2312 let range = Range {
2314 start: Position { line: 0, character: 0 },
2315 end: Position { line: 10, character: 0 },
2316 };
2317
2318 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2319
2320 let html_fix_actions: Vec<_> = code_actions
2322 .iter()
2323 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2324 .collect();
2325
2326 assert!(
2327 !html_fix_actions.is_empty(),
2328 "Quick Fix actions should be available for HTML (Unfixable rules)"
2329 );
2330
2331 let fix_all_actions: Vec<_> = code_actions
2333 .iter()
2334 .filter(|action| action.title.contains("Fix all"))
2335 .collect();
2336
2337 if let Some(fix_all_action) = fix_all_actions.first()
2338 && let Some(ref edit) = fix_all_action.edit
2339 && let Some(ref changes) = edit.changes
2340 && let Some(text_edits) = changes.get(&uri)
2341 && let Some(text_edit) = text_edits.first()
2342 {
2343 let fixed_all = &text_edit.new_text;
2344 assert!(
2345 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2346 "Fix All should preserve HTML (Unfixable rules)"
2347 );
2348 assert!(
2349 !fixed_all.contains("spaces "),
2350 "Fix All should remove trailing spaces (fixable rules)"
2351 );
2352 }
2353 }
2354
2355 #[tokio::test]
2357 async fn test_resolve_config_for_file_multi_root() {
2358 use std::fs;
2359 use tempfile::tempdir;
2360
2361 let temp_dir = tempdir().unwrap();
2362 let temp_path = temp_dir.path();
2363
2364 let project_a = temp_path.join("project_a");
2366 let project_a_docs = project_a.join("docs");
2367 fs::create_dir_all(&project_a_docs).unwrap();
2368
2369 let config_a = project_a.join(".rumdl.toml");
2370 fs::write(
2371 &config_a,
2372 r#"
2373[global]
2374
2375[MD013]
2376line_length = 60
2377"#,
2378 )
2379 .unwrap();
2380
2381 let project_b = temp_path.join("project_b");
2383 fs::create_dir(&project_b).unwrap();
2384
2385 let config_b = project_b.join(".rumdl.toml");
2386 fs::write(
2387 &config_b,
2388 r#"
2389[global]
2390
2391[MD013]
2392line_length = 120
2393"#,
2394 )
2395 .unwrap();
2396
2397 let server = create_test_server();
2399
2400 {
2402 let mut roots = server.workspace_roots.write().await;
2403 roots.push(project_a.clone());
2404 roots.push(project_b.clone());
2405 }
2406
2407 let file_a = project_a_docs.join("test.md");
2409 fs::write(&file_a, "# Test A\n").unwrap();
2410
2411 let config_for_a = server.resolve_config_for_file(&file_a).await;
2412 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2413 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2414
2415 let file_b = project_b.join("test.md");
2417 fs::write(&file_b, "# Test B\n").unwrap();
2418
2419 let config_for_b = server.resolve_config_for_file(&file_b).await;
2420 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2421 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2422 }
2423
2424 #[tokio::test]
2426 async fn test_config_resolution_respects_workspace_boundaries() {
2427 use std::fs;
2428 use tempfile::tempdir;
2429
2430 let temp_dir = tempdir().unwrap();
2431 let temp_path = temp_dir.path();
2432
2433 let parent_config = temp_path.join(".rumdl.toml");
2435 fs::write(
2436 &parent_config,
2437 r#"
2438[global]
2439
2440[MD013]
2441line_length = 80
2442"#,
2443 )
2444 .unwrap();
2445
2446 let workspace_root = temp_path.join("workspace");
2448 let workspace_subdir = workspace_root.join("subdir");
2449 fs::create_dir_all(&workspace_subdir).unwrap();
2450
2451 let workspace_config = workspace_root.join(".rumdl.toml");
2452 fs::write(
2453 &workspace_config,
2454 r#"
2455[global]
2456
2457[MD013]
2458line_length = 100
2459"#,
2460 )
2461 .unwrap();
2462
2463 let server = create_test_server();
2464
2465 {
2467 let mut roots = server.workspace_roots.write().await;
2468 roots.push(workspace_root.clone());
2469 }
2470
2471 let test_file = workspace_subdir.join("deep").join("test.md");
2473 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2474 fs::write(&test_file, "# Test\n").unwrap();
2475
2476 let config = server.resolve_config_for_file(&test_file).await;
2477 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2478
2479 assert_eq!(
2481 line_length,
2482 Some(100),
2483 "Should find workspace config, not parent config outside workspace"
2484 );
2485 }
2486
2487 #[tokio::test]
2489 async fn test_config_cache_hit() {
2490 use std::fs;
2491 use tempfile::tempdir;
2492
2493 let temp_dir = tempdir().unwrap();
2494 let temp_path = temp_dir.path();
2495
2496 let project = temp_path.join("project");
2497 fs::create_dir(&project).unwrap();
2498
2499 let config_file = project.join(".rumdl.toml");
2500 fs::write(
2501 &config_file,
2502 r#"
2503[global]
2504
2505[MD013]
2506line_length = 75
2507"#,
2508 )
2509 .unwrap();
2510
2511 let server = create_test_server();
2512 {
2513 let mut roots = server.workspace_roots.write().await;
2514 roots.push(project.clone());
2515 }
2516
2517 let test_file = project.join("test.md");
2518 fs::write(&test_file, "# Test\n").unwrap();
2519
2520 let config1 = server.resolve_config_for_file(&test_file).await;
2522 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2523 assert_eq!(line_length1, Some(75));
2524
2525 {
2527 let cache = server.config_cache.read().await;
2528 let search_dir = test_file.parent().unwrap();
2529 assert!(
2530 cache.contains_key(search_dir),
2531 "Cache should be populated after first call"
2532 );
2533 }
2534
2535 let config2 = server.resolve_config_for_file(&test_file).await;
2537 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2538 assert_eq!(line_length2, Some(75));
2539 }
2540
2541 #[tokio::test]
2543 async fn test_nested_directory_config_search() {
2544 use std::fs;
2545 use tempfile::tempdir;
2546
2547 let temp_dir = tempdir().unwrap();
2548 let temp_path = temp_dir.path();
2549
2550 let project = temp_path.join("project");
2551 fs::create_dir(&project).unwrap();
2552
2553 let config = project.join(".rumdl.toml");
2555 fs::write(
2556 &config,
2557 r#"
2558[global]
2559
2560[MD013]
2561line_length = 110
2562"#,
2563 )
2564 .unwrap();
2565
2566 let deep_dir = project.join("src").join("docs").join("guides");
2568 fs::create_dir_all(&deep_dir).unwrap();
2569 let deep_file = deep_dir.join("test.md");
2570 fs::write(&deep_file, "# Test\n").unwrap();
2571
2572 let server = create_test_server();
2573 {
2574 let mut roots = server.workspace_roots.write().await;
2575 roots.push(project.clone());
2576 }
2577
2578 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2579 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2580
2581 assert_eq!(
2582 line_length,
2583 Some(110),
2584 "Should find config by searching upward from deep directory"
2585 );
2586 }
2587
2588 #[tokio::test]
2590 async fn test_fallback_to_default_config() {
2591 use std::fs;
2592 use tempfile::tempdir;
2593
2594 let temp_dir = tempdir().unwrap();
2595 let temp_path = temp_dir.path();
2596
2597 let project = temp_path.join("project");
2598 fs::create_dir(&project).unwrap();
2599
2600 let test_file = project.join("test.md");
2603 fs::write(&test_file, "# Test\n").unwrap();
2604
2605 let server = create_test_server();
2606 {
2607 let mut roots = server.workspace_roots.write().await;
2608 roots.push(project.clone());
2609 }
2610
2611 let config = server.resolve_config_for_file(&test_file).await;
2612
2613 assert_eq!(
2615 config.global.line_length.get(),
2616 80,
2617 "Should fall back to default config when no config file found"
2618 );
2619 }
2620
2621 #[tokio::test]
2623 async fn test_config_priority_closer_wins() {
2624 use std::fs;
2625 use tempfile::tempdir;
2626
2627 let temp_dir = tempdir().unwrap();
2628 let temp_path = temp_dir.path();
2629
2630 let project = temp_path.join("project");
2631 fs::create_dir(&project).unwrap();
2632
2633 let parent_config = project.join(".rumdl.toml");
2635 fs::write(
2636 &parent_config,
2637 r#"
2638[global]
2639
2640[MD013]
2641line_length = 100
2642"#,
2643 )
2644 .unwrap();
2645
2646 let subdir = project.join("subdir");
2648 fs::create_dir(&subdir).unwrap();
2649
2650 let subdir_config = subdir.join(".rumdl.toml");
2651 fs::write(
2652 &subdir_config,
2653 r#"
2654[global]
2655
2656[MD013]
2657line_length = 50
2658"#,
2659 )
2660 .unwrap();
2661
2662 let server = create_test_server();
2663 {
2664 let mut roots = server.workspace_roots.write().await;
2665 roots.push(project.clone());
2666 }
2667
2668 let test_file = subdir.join("test.md");
2670 fs::write(&test_file, "# Test\n").unwrap();
2671
2672 let config = server.resolve_config_for_file(&test_file).await;
2673 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2674
2675 assert_eq!(
2676 line_length,
2677 Some(50),
2678 "Closer config (subdir) should override parent config"
2679 );
2680 }
2681
2682 #[tokio::test]
2688 async fn test_issue_131_pyproject_without_rumdl_section() {
2689 use std::fs;
2690 use tempfile::tempdir;
2691
2692 let parent_dir = tempdir().unwrap();
2694
2695 let project_dir = parent_dir.path().join("project");
2697 fs::create_dir(&project_dir).unwrap();
2698
2699 fs::write(
2701 project_dir.join("pyproject.toml"),
2702 r#"
2703[project]
2704name = "test-project"
2705version = "0.1.0"
2706"#,
2707 )
2708 .unwrap();
2709
2710 fs::write(
2713 parent_dir.path().join(".rumdl.toml"),
2714 r#"
2715[global]
2716disable = ["MD013"]
2717"#,
2718 )
2719 .unwrap();
2720
2721 let test_file = project_dir.join("test.md");
2722 fs::write(&test_file, "# Test\n").unwrap();
2723
2724 let server = create_test_server();
2725
2726 {
2728 let mut roots = server.workspace_roots.write().await;
2729 roots.push(parent_dir.path().to_path_buf());
2730 }
2731
2732 let config = server.resolve_config_for_file(&test_file).await;
2734
2735 assert!(
2738 config.global.disable.contains(&"MD013".to_string()),
2739 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
2740 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
2741 );
2742
2743 let cache = server.config_cache.read().await;
2746 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2747
2748 assert!(
2749 cache_entry.config_file.is_some(),
2750 "Should have found a config file (parent .rumdl.toml)"
2751 );
2752
2753 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2754 assert!(
2755 found_config_path.ends_with(".rumdl.toml"),
2756 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
2757 );
2758 assert!(
2759 found_config_path.parent().unwrap() == parent_dir.path(),
2760 "Should have loaded config from parent directory, not project_dir"
2761 );
2762 }
2763
2764 #[tokio::test]
2769 async fn test_issue_131_pyproject_with_rumdl_section() {
2770 use std::fs;
2771 use tempfile::tempdir;
2772
2773 let parent_dir = tempdir().unwrap();
2775
2776 let project_dir = parent_dir.path().join("project");
2778 fs::create_dir(&project_dir).unwrap();
2779
2780 fs::write(
2782 project_dir.join("pyproject.toml"),
2783 r#"
2784[project]
2785name = "test-project"
2786
2787[tool.rumdl.global]
2788disable = ["MD033"]
2789"#,
2790 )
2791 .unwrap();
2792
2793 fs::write(
2795 parent_dir.path().join(".rumdl.toml"),
2796 r#"
2797[global]
2798disable = ["MD041"]
2799"#,
2800 )
2801 .unwrap();
2802
2803 let test_file = project_dir.join("test.md");
2804 fs::write(&test_file, "# Test\n").unwrap();
2805
2806 let server = create_test_server();
2807
2808 {
2810 let mut roots = server.workspace_roots.write().await;
2811 roots.push(parent_dir.path().to_path_buf());
2812 }
2813
2814 let config = server.resolve_config_for_file(&test_file).await;
2816
2817 assert!(
2819 config.global.disable.contains(&"MD033".to_string()),
2820 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
2821 Expected MD033 from project_dir pyproject.toml to be disabled."
2822 );
2823
2824 assert!(
2826 !config.global.disable.contains(&"MD041".to_string()),
2827 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
2828 );
2829
2830 let cache = server.config_cache.read().await;
2832 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
2833
2834 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
2835
2836 let found_config_path = cache_entry.config_file.as_ref().unwrap();
2837 assert!(
2838 found_config_path.ends_with("pyproject.toml"),
2839 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
2840 );
2841 assert!(
2842 found_config_path.parent().unwrap() == project_dir,
2843 "Should have loaded pyproject.toml from project_dir, not parent"
2844 );
2845 }
2846
2847 #[tokio::test]
2852 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
2853 use std::fs;
2854 use tempfile::tempdir;
2855
2856 let temp_dir = tempdir().unwrap();
2857
2858 fs::write(
2860 temp_dir.path().join("pyproject.toml"),
2861 r#"
2862[project]
2863name = "test-project"
2864
2865[tool.rumdl.global]
2866disable = ["MD022"]
2867"#,
2868 )
2869 .unwrap();
2870
2871 let test_file = temp_dir.path().join("test.md");
2872 fs::write(&test_file, "# Test\n").unwrap();
2873
2874 let server = create_test_server();
2875
2876 {
2878 let mut roots = server.workspace_roots.write().await;
2879 roots.push(temp_dir.path().to_path_buf());
2880 }
2881
2882 let config = server.resolve_config_for_file(&test_file).await;
2884
2885 assert!(
2887 config.global.disable.contains(&"MD022".to_string()),
2888 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
2889 );
2890
2891 let cache = server.config_cache.read().await;
2893 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
2894 assert!(
2895 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
2896 "Should have loaded pyproject.toml"
2897 );
2898 }
2899
2900 #[tokio::test]
2905 async fn test_issue_182_pull_diagnostics_capability_default() {
2906 let server = create_test_server();
2907
2908 assert!(
2910 !*server.client_supports_pull_diagnostics.read().await,
2911 "Default should be false - push diagnostics by default"
2912 );
2913 }
2914
2915 #[tokio::test]
2917 async fn test_issue_182_pull_diagnostics_flag_update() {
2918 let server = create_test_server();
2919
2920 *server.client_supports_pull_diagnostics.write().await = true;
2922
2923 assert!(
2924 *server.client_supports_pull_diagnostics.read().await,
2925 "Flag should be settable to true"
2926 );
2927 }
2928
2929 #[tokio::test]
2933 async fn test_issue_182_capability_detection_with_diagnostic_support() {
2934 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
2935
2936 let caps_with_diagnostic = ClientCapabilities {
2938 text_document: Some(TextDocumentClientCapabilities {
2939 diagnostic: Some(DiagnosticClientCapabilities {
2940 dynamic_registration: Some(true),
2941 related_document_support: Some(false),
2942 }),
2943 ..Default::default()
2944 }),
2945 ..Default::default()
2946 };
2947
2948 let supports_pull = caps_with_diagnostic
2950 .text_document
2951 .as_ref()
2952 .and_then(|td| td.diagnostic.as_ref())
2953 .is_some();
2954
2955 assert!(supports_pull, "Should detect pull diagnostic support");
2956 }
2957
2958 #[tokio::test]
2960 async fn test_issue_182_capability_detection_without_diagnostic_support() {
2961 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
2962
2963 let caps_without_diagnostic = ClientCapabilities {
2965 text_document: Some(TextDocumentClientCapabilities {
2966 diagnostic: None, ..Default::default()
2968 }),
2969 ..Default::default()
2970 };
2971
2972 let supports_pull = caps_without_diagnostic
2974 .text_document
2975 .as_ref()
2976 .and_then(|td| td.diagnostic.as_ref())
2977 .is_some();
2978
2979 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
2980 }
2981
2982 #[tokio::test]
2984 async fn test_issue_182_capability_detection_no_text_document() {
2985 use tower_lsp::lsp_types::ClientCapabilities;
2986
2987 let caps_no_text_doc = ClientCapabilities {
2989 text_document: None,
2990 ..Default::default()
2991 };
2992
2993 let supports_pull = caps_no_text_doc
2995 .text_document
2996 .as_ref()
2997 .and_then(|td| td.diagnostic.as_ref())
2998 .is_some();
2999
3000 assert!(
3001 !supports_pull,
3002 "Should NOT detect pull diagnostic support when text_document is None"
3003 );
3004 }
3005
3006 #[test]
3007 fn test_resource_limit_constants() {
3008 assert_eq!(MAX_RULE_LIST_SIZE, 100);
3010 assert_eq!(MAX_LINE_LENGTH, 10_000);
3011 }
3012
3013 #[test]
3014 fn test_is_valid_rule_name_edge_cases() {
3015 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")); }
3028
3029 #[tokio::test]
3038 async fn test_lsp_toml_config_parity_generic() {
3039 use crate::config::RuleConfig;
3040 use crate::rule::Severity;
3041
3042 let server = create_test_server();
3043
3044 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3048 (
3050 "severity only - error",
3051 serde_json::json!({"severity": "error"}),
3052 RuleConfig {
3053 severity: Some(Severity::Error),
3054 values: std::collections::BTreeMap::new(),
3055 },
3056 ),
3057 (
3058 "severity only - warning",
3059 serde_json::json!({"severity": "warning"}),
3060 RuleConfig {
3061 severity: Some(Severity::Warning),
3062 values: std::collections::BTreeMap::new(),
3063 },
3064 ),
3065 (
3066 "severity only - info",
3067 serde_json::json!({"severity": "info"}),
3068 RuleConfig {
3069 severity: Some(Severity::Info),
3070 values: std::collections::BTreeMap::new(),
3071 },
3072 ),
3073 (
3075 "integer value",
3076 serde_json::json!({"lineLength": 120}),
3077 RuleConfig {
3078 severity: None,
3079 values: [("line_length".to_string(), toml::Value::Integer(120))]
3080 .into_iter()
3081 .collect(),
3082 },
3083 ),
3084 (
3086 "boolean value",
3087 serde_json::json!({"enabled": true}),
3088 RuleConfig {
3089 severity: None,
3090 values: [("enabled".to_string(), toml::Value::Boolean(true))]
3091 .into_iter()
3092 .collect(),
3093 },
3094 ),
3095 (
3097 "string value",
3098 serde_json::json!({"style": "consistent"}),
3099 RuleConfig {
3100 severity: None,
3101 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3102 .into_iter()
3103 .collect(),
3104 },
3105 ),
3106 (
3108 "array value",
3109 serde_json::json!({"allowedElements": ["div", "span"]}),
3110 RuleConfig {
3111 severity: None,
3112 values: [(
3113 "allowed_elements".to_string(),
3114 toml::Value::Array(vec![
3115 toml::Value::String("div".to_string()),
3116 toml::Value::String("span".to_string()),
3117 ]),
3118 )]
3119 .into_iter()
3120 .collect(),
3121 },
3122 ),
3123 (
3125 "severity + integer",
3126 serde_json::json!({"severity": "info", "lineLength": 80}),
3127 RuleConfig {
3128 severity: Some(Severity::Info),
3129 values: [("line_length".to_string(), toml::Value::Integer(80))]
3130 .into_iter()
3131 .collect(),
3132 },
3133 ),
3134 (
3135 "severity + multiple values",
3136 serde_json::json!({
3137 "severity": "warning",
3138 "lineLength": 100,
3139 "strict": false,
3140 "style": "atx"
3141 }),
3142 RuleConfig {
3143 severity: Some(Severity::Warning),
3144 values: [
3145 ("line_length".to_string(), toml::Value::Integer(100)),
3146 ("strict".to_string(), toml::Value::Boolean(false)),
3147 ("style".to_string(), toml::Value::String("atx".to_string())),
3148 ]
3149 .into_iter()
3150 .collect(),
3151 },
3152 ),
3153 (
3155 "camelCase conversion",
3156 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3157 RuleConfig {
3158 severity: None,
3159 values: [
3160 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3161 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3162 ]
3163 .into_iter()
3164 .collect(),
3165 },
3166 ),
3167 ];
3168
3169 for (description, lsp_json, expected_toml_config) in test_configs {
3170 let mut lsp_config = crate::config::Config::default();
3171 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3172
3173 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3174
3175 assert_eq!(
3177 lsp_rule.severity, expected_toml_config.severity,
3178 "Parity failure [{description}]: severity mismatch. \
3179 LSP={:?}, TOML={:?}",
3180 lsp_rule.severity, expected_toml_config.severity
3181 );
3182
3183 assert_eq!(
3185 lsp_rule.values, expected_toml_config.values,
3186 "Parity failure [{description}]: values mismatch. \
3187 LSP={:?}, TOML={:?}",
3188 lsp_rule.values, expected_toml_config.values
3189 );
3190 }
3191 }
3192
3193 #[tokio::test]
3195 async fn test_lsp_config_if_absent_preserves_existing() {
3196 use crate::config::RuleConfig;
3197 use crate::rule::Severity;
3198
3199 let server = create_test_server();
3200
3201 let mut config = crate::config::Config::default();
3203 config.rules.insert(
3204 "MD013".to_string(),
3205 RuleConfig {
3206 severity: Some(Severity::Error),
3207 values: [("line_length".to_string(), toml::Value::Integer(80))]
3208 .into_iter()
3209 .collect(),
3210 },
3211 );
3212
3213 let lsp_json = serde_json::json!({
3215 "severity": "info",
3216 "lineLength": 120
3217 });
3218 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3219
3220 let rule = config.rules.get("MD013").expect("Rule should exist");
3221
3222 assert_eq!(
3224 rule.severity,
3225 Some(Severity::Error),
3226 "Existing severity should not be overwritten"
3227 );
3228
3229 assert_eq!(
3231 rule.values.get("line_length"),
3232 Some(&toml::Value::Integer(80)),
3233 "Existing values should not be overwritten"
3234 );
3235 }
3236
3237 #[test]
3240 fn test_apply_formatting_options_insert_final_newline() {
3241 let options = FormattingOptions {
3242 tab_size: 4,
3243 insert_spaces: true,
3244 properties: HashMap::new(),
3245 trim_trailing_whitespace: None,
3246 insert_final_newline: Some(true),
3247 trim_final_newlines: None,
3248 };
3249
3250 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3252 assert_eq!(result, "hello\n");
3253
3254 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3256 assert_eq!(result, "hello\n");
3257 }
3258
3259 #[test]
3260 fn test_apply_formatting_options_trim_final_newlines() {
3261 let options = FormattingOptions {
3262 tab_size: 4,
3263 insert_spaces: true,
3264 properties: HashMap::new(),
3265 trim_trailing_whitespace: None,
3266 insert_final_newline: None,
3267 trim_final_newlines: Some(true),
3268 };
3269
3270 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3272 assert_eq!(result, "hello");
3273
3274 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3276 assert_eq!(result, "hello");
3277 }
3278
3279 #[test]
3280 fn test_apply_formatting_options_trim_and_insert_combined() {
3281 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: Some(true),
3289 };
3290
3291 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3293 assert_eq!(result, "hello\n");
3294
3295 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3297 assert_eq!(result, "hello\n");
3298
3299 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3301 assert_eq!(result, "hello\n");
3302 }
3303
3304 #[test]
3305 fn test_apply_formatting_options_trim_trailing_whitespace() {
3306 let options = FormattingOptions {
3307 tab_size: 4,
3308 insert_spaces: true,
3309 properties: HashMap::new(),
3310 trim_trailing_whitespace: Some(true),
3311 insert_final_newline: Some(true),
3312 trim_final_newlines: None,
3313 };
3314
3315 let result = RumdlLanguageServer::apply_formatting_options("hello \nworld\t\n".to_string(), &options);
3317 assert_eq!(result, "hello\nworld\n");
3318 }
3319
3320 #[test]
3321 fn test_apply_formatting_options_issue_265_scenario() {
3322 let options = FormattingOptions {
3327 tab_size: 4,
3328 insert_spaces: true,
3329 properties: HashMap::new(),
3330 trim_trailing_whitespace: None,
3331 insert_final_newline: Some(true),
3332 trim_final_newlines: Some(true),
3333 };
3334
3335 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
3337 assert_eq!(
3338 result, "hello foobar hello.\n",
3339 "Should have exactly one trailing newline"
3340 );
3341
3342 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
3344 assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
3345
3346 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
3348 assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
3349 }
3350
3351 #[test]
3352 fn test_apply_formatting_options_no_options() {
3353 let options = FormattingOptions {
3355 tab_size: 4,
3356 insert_spaces: true,
3357 properties: HashMap::new(),
3358 trim_trailing_whitespace: None,
3359 insert_final_newline: None,
3360 trim_final_newlines: None,
3361 };
3362
3363 let content = "hello \nworld\n\n\n";
3364 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3365 assert_eq!(result, content, "Content should be unchanged when no options set");
3366 }
3367
3368 #[test]
3369 fn test_apply_formatting_options_empty_content() {
3370 let options = FormattingOptions {
3371 tab_size: 4,
3372 insert_spaces: true,
3373 properties: HashMap::new(),
3374 trim_trailing_whitespace: Some(true),
3375 insert_final_newline: Some(true),
3376 trim_final_newlines: Some(true),
3377 };
3378
3379 let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
3381 assert_eq!(result, "");
3382
3383 let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
3385 assert_eq!(result, "\n");
3386 }
3387
3388 #[test]
3389 fn test_apply_formatting_options_multiline_content() {
3390 let options = FormattingOptions {
3391 tab_size: 4,
3392 insert_spaces: true,
3393 properties: HashMap::new(),
3394 trim_trailing_whitespace: Some(true),
3395 insert_final_newline: Some(true),
3396 trim_final_newlines: Some(true),
3397 };
3398
3399 let content = "# Heading \n\nParagraph \n- List item \n\n\n";
3400 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3401 assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
3402 }
3403}