1use super::position::{byte_range_to_lsp_range, char_column_to_utf16, utf16_len};
7use crate::rules::md013_line_length::MD013Config;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use tower_lsp::lsp_types::*;
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum IndexState {
15 Building {
17 progress: f32,
19 files_indexed: usize,
21 total_files: usize,
23 },
24 Ready,
26 Error(String),
28}
29
30impl Default for IndexState {
31 fn default() -> Self {
32 Self::Building {
33 progress: 0.0,
34 files_indexed: 0,
35 total_files: 0,
36 }
37 }
38}
39
40#[derive(Debug)]
42pub enum IndexUpdate {
43 FileChanged { path: PathBuf, content: String },
45 FileDeleted { path: PathBuf },
47 FullRescan,
49 Shutdown,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub enum ConfigurationPreference {
57 #[default]
59 EditorFirst,
60 FilesystemFirst,
62 EditorOnly,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71#[serde(default, rename_all = "camelCase")]
72pub struct LspRuleSettings {
73 pub line_length: Option<usize>,
75 pub disable: Option<Vec<String>>,
77 pub enable: Option<Vec<String>>,
79 #[serde(flatten)]
81 pub rules: std::collections::HashMap<String, serde_json::Value>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(default, rename_all = "camelCase")]
90pub struct RumdlLspConfig {
91 pub config_path: Option<String>,
93 pub enable_linting: bool,
95 pub enable_auto_fix: bool,
97 pub enable_rules: Option<Vec<String>>,
100 pub disable_rules: Option<Vec<String>>,
102 pub configuration_preference: ConfigurationPreference,
104 pub settings: Option<LspRuleSettings>,
107 pub enable_link_completions: bool,
110 pub enable_link_navigation: bool,
114 pub enable_symbols: bool,
121 pub link_completion_content_roots: Vec<String>,
125}
126
127impl Default for RumdlLspConfig {
128 fn default() -> Self {
129 Self {
130 config_path: None,
131 enable_linting: true,
132 enable_auto_fix: false,
133 enable_rules: None,
134 disable_rules: None,
135 configuration_preference: ConfigurationPreference::default(),
136 settings: None,
137 enable_link_completions: true,
138 enable_link_navigation: true,
139 enable_symbols: true,
140 link_completion_content_roots: Vec::new(),
141 }
142 }
143}
144
145pub fn warnings_to_diagnostics(warnings: &[crate::rule::LintWarning], document_text: &str) -> Vec<Diagnostic> {
150 let lines: Vec<&str> = document_text.lines().collect();
151 warnings.iter().map(|warning| diagnostic_in(warning, &lines)).collect()
152}
153
154pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning, document_text: &str) -> Diagnostic {
159 let lines: Vec<&str> = document_text.lines().collect();
160 diagnostic_in(warning, &lines)
161}
162
163fn diagnostic_in(warning: &crate::rule::LintWarning, lines: &[&str]) -> Diagnostic {
164 let start_line = warning.line.saturating_sub(1);
165 let end_line = warning.end_line.saturating_sub(1);
166
167 let start_position = Position {
168 line: start_line as u32,
169 character: char_column_to_utf16(lines.get(start_line).copied(), warning.column),
170 };
171
172 let end_position = Position {
174 line: end_line as u32,
175 character: char_column_to_utf16(lines.get(end_line).copied(), warning.end_column),
176 };
177
178 let severity = match warning.severity {
179 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
180 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
181 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
182 };
183
184 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
187 let is_rumdl_rule = rule_name.len() > 2
188 && rule_name[..2].eq_ignore_ascii_case("MD")
189 && rule_name[2..].chars().all(|c| c.is_ascii_digit());
190 if is_rumdl_rule {
191 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
192 .ok()
193 .map(|href| CodeDescription { href })
194 } else {
195 None
196 }
197 });
198
199 Diagnostic {
200 range: Range {
201 start: start_position,
202 end: end_position,
203 },
204 severity: Some(severity),
205 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
206 source: Some("rumdl".to_string()),
207 message: warning.message.clone(),
208 related_information: None,
209 tags: None,
210 code_description,
211 data: None,
212 }
213}
214
215pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
218 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
219}
220
221pub(crate) fn warning_to_code_actions_with_md013_config(
225 warning: &crate::rule::LintWarning,
226 uri: &Url,
227 document_text: &str,
228 md013_config: Option<&MD013Config>,
229) -> Vec<CodeAction> {
230 let mut actions = Vec::new();
231
232 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
234 actions.push(fix_action);
235 }
236
237 if warning.rule_name.as_deref() == Some("MD013")
240 && warning.fix.is_none()
241 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
242 {
243 actions.push(reflow_action);
244 }
245
246 if warning.rule_name.as_deref() == Some("MD034")
249 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
250 {
251 actions.push(convert_action);
252 }
253
254 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
256 actions.push(ignore_line_action);
257 }
258
259 actions
260}
261
262fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
264 if let Some(fix) = &warning.fix {
265 let primary = TextEdit {
270 range: byte_range_to_lsp_range(document_text, fix.range.clone())?,
271 new_text: fix.replacement.clone(),
272 };
273
274 let mut edits = Vec::with_capacity(1 + fix.additional_edits.len());
275 edits.push(primary);
276 for extra in &fix.additional_edits {
277 edits.push(TextEdit {
278 range: byte_range_to_lsp_range(document_text, extra.range.clone())?,
279 new_text: extra.replacement.clone(),
280 });
281 }
282
283 let mut changes = std::collections::HashMap::new();
284 changes.insert(uri.clone(), edits);
285
286 let workspace_edit = WorkspaceEdit {
287 changes: Some(changes),
288 document_changes: None,
289 change_annotations: None,
290 };
291
292 Some(CodeAction {
293 title: format!("Fix: {}", warning.message),
294 kind: Some(CodeActionKind::QUICKFIX),
295 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
296 edit: Some(workspace_edit),
297 command: None,
298 is_preferred: Some(true),
299 disabled: None,
300 data: None,
301 })
302 } else {
303 None
304 }
305}
306
307fn create_reflow_action(
310 warning: &crate::rule::LintWarning,
311 uri: &Url,
312 document_text: &str,
313 md013_config: Option<&MD013Config>,
314) -> Option<CodeAction> {
315 let options = if let Some(config) = md013_config {
318 config.to_reflow_options()
319 } else {
320 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
321 crate::utils::text_reflow::ReflowOptions {
322 line_length,
323 ..Default::default()
324 }
325 };
326
327 let reflow_result =
329 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
330
331 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
333
334 let edit = TextEdit {
335 range,
336 new_text: reflow_result.reflowed_text,
337 };
338
339 let mut changes = std::collections::HashMap::new();
340 changes.insert(uri.clone(), vec![edit]);
341
342 let workspace_edit = WorkspaceEdit {
343 changes: Some(changes),
344 document_changes: None,
345 change_annotations: None,
346 };
347
348 Some(CodeAction {
349 title: "Reflow paragraph".to_string(),
350 kind: Some(CodeActionKind::QUICKFIX),
351 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
352 edit: Some(workspace_edit),
353 command: None,
354 is_preferred: Some(false), disabled: None,
356 data: None,
357 })
358}
359
360fn extract_line_length_from_message(message: &str) -> Option<usize> {
363 let exceeds_idx = message.find("exceeds")?;
365 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
369
370 num_str.parse::<usize>().ok()
371}
372
373fn create_convert_to_link_action(
377 warning: &crate::rule::LintWarning,
378 uri: &Url,
379 document_text: &str,
380) -> Option<CodeAction> {
381 let fix = warning.fix.as_ref()?;
383
384 let url = extract_url_from_fix_replacement(&fix.replacement)?;
387
388 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
390
391 let link_text = extract_domain_for_placeholder(url);
396 let new_text = format!("[{link_text}]({url})");
397
398 let edit = TextEdit { range, new_text };
399
400 let mut changes = std::collections::HashMap::new();
401 changes.insert(uri.clone(), vec![edit]);
402
403 let workspace_edit = WorkspaceEdit {
404 changes: Some(changes),
405 document_changes: None,
406 change_annotations: None,
407 };
408
409 Some(CodeAction {
410 title: "Convert to markdown link".to_string(),
411 kind: Some(CodeActionKind::QUICKFIX),
412 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
413 edit: Some(workspace_edit),
414 command: None,
415 is_preferred: Some(false), disabled: None,
417 data: None,
418 })
419}
420
421fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
424 let trimmed = replacement.trim();
426 if trimmed.starts_with('<') && trimmed.ends_with('>') {
427 Some(&trimmed[1..trimmed.len() - 1])
428 } else {
429 None
430 }
431}
432
433fn extract_domain_for_placeholder(url: &str) -> &str {
437 if url.contains('@') && !url.contains("://") {
439 return url;
440 }
441
442 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
444}
445
446fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
448 let rule_id = warning.rule_name.as_ref()?;
449 let warning_line = warning.line.saturating_sub(1);
450
451 let lines: Vec<&str> = document_text.lines().collect();
453 let line_content = lines.get(warning_line)?;
454
455 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
457 return None;
459 }
460
461 let line_end = Position {
463 line: warning_line as u32,
464 character: utf16_len(line_content),
465 };
466
467 let rule_label = crate::config::primary_alias(rule_id).unwrap_or(rule_id.as_str());
471 let comment = format!(" <!-- rumdl-disable-line {rule_label} -->");
472
473 let edit = TextEdit {
474 range: Range {
475 start: line_end,
476 end: line_end,
477 },
478 new_text: comment,
479 };
480
481 let mut changes = std::collections::HashMap::new();
482 changes.insert(uri.clone(), vec![edit]);
483
484 let title = if rule_label == rule_id {
485 format!("Ignore {rule_id} for this line")
486 } else {
487 format!("Ignore {rule_label} ({rule_id}) for this line")
488 };
489
490 Some(CodeAction {
491 title,
492 kind: Some(CodeActionKind::QUICKFIX),
493 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
494 edit: Some(WorkspaceEdit {
495 changes: Some(changes),
496 document_changes: None,
497 change_annotations: None,
498 }),
499 command: None,
500 is_preferred: Some(false), disabled: None,
502 data: None,
503 })
504}
505
506#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
509pub fn warning_to_code_action(
510 warning: &crate::rule::LintWarning,
511 uri: &Url,
512 document_text: &str,
513) -> Option<CodeAction> {
514 warning_to_code_actions(warning, uri, document_text)
515 .into_iter()
516 .find(|action| action.is_preferred == Some(true))
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use crate::rule::{Fix, LintWarning, Severity};
523
524 #[test]
525 fn test_rumdl_lsp_config_default() {
526 let config = RumdlLspConfig::default();
527 assert_eq!(config.config_path, None);
528 assert!(config.enable_linting);
529 assert!(!config.enable_auto_fix);
530 }
531
532 #[test]
533 fn test_rumdl_lsp_config_serialization() {
534 let config = RumdlLspConfig {
535 config_path: Some("/path/to/config.toml".to_string()),
536 enable_linting: false,
537 enable_auto_fix: true,
538 enable_rules: None,
539 disable_rules: None,
540 configuration_preference: ConfigurationPreference::EditorFirst,
541 settings: None,
542 enable_link_completions: true,
543 enable_link_navigation: true,
544 enable_symbols: true,
545 link_completion_content_roots: Vec::new(),
546 };
547
548 let json = serde_json::to_string(&config).unwrap();
550 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
551 assert!(json.contains("\"enableLinting\":false"));
552 assert!(json.contains("\"enableAutoFix\":true"));
553
554 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
556 assert_eq!(deserialized.config_path, config.config_path);
557 assert_eq!(deserialized.enable_linting, config.enable_linting);
558 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
559 }
560
561 #[test]
562 fn test_warning_to_diagnostic_basic() {
563 let warning = LintWarning {
564 line: 5,
565 column: 10,
566 end_line: 5,
567 end_column: 15,
568 rule_name: Some("MD001".to_string()),
569 message: "Test warning message".to_string(),
570 severity: Severity::Warning,
571 fix: None,
572 };
573
574 let diagnostic = warning_to_diagnostic(&warning, "one\ntwo\nthree\nfour\nfive: a longer line\n");
575
576 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
579 assert_eq!(diagnostic.range.end.character, 14);
580 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
581 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
582 assert_eq!(diagnostic.message, "Test warning message");
583 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
584 }
585
586 #[test]
587 fn test_warning_to_diagnostic_error_severity() {
588 let warning = LintWarning {
589 line: 1,
590 column: 1,
591 end_line: 1,
592 end_column: 5,
593 rule_name: Some("MD002".to_string()),
594 message: "Error message".to_string(),
595 severity: Severity::Error,
596 fix: None,
597 };
598
599 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
600 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
601 }
602
603 #[test]
604 fn test_warning_to_diagnostic_no_rule_name() {
605 let warning = LintWarning {
606 line: 1,
607 column: 1,
608 end_line: 1,
609 end_column: 5,
610 rule_name: None,
611 message: "Generic warning".to_string(),
612 severity: Severity::Warning,
613 fix: None,
614 };
615
616 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
617 assert_eq!(diagnostic.code, None);
618 assert!(diagnostic.code_description.is_none());
619 }
620
621 #[test]
622 fn test_warning_to_diagnostic_edge_cases() {
623 let warning = LintWarning {
625 line: 0,
626 column: 0,
627 end_line: 0,
628 end_column: 0,
629 rule_name: Some("MD001".to_string()),
630 message: "Edge case".to_string(),
631 severity: Severity::Warning,
632 fix: None,
633 };
634
635 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
636 assert_eq!(diagnostic.range.start.line, 0);
637 assert_eq!(diagnostic.range.start.character, 0);
638 }
639
640 #[test]
641 fn a_diagnostic_column_after_a_non_bmp_codepoint_counts_both_code_units() {
642 let text = "🎉 badword here\n";
646 let warning = LintWarning {
647 line: 1,
648 column: 3,
649 end_line: 1,
650 end_column: 10,
651 rule_name: Some("MD001".to_string()),
652 message: "Test".to_string(),
653 severity: Severity::Warning,
654 fix: None,
655 };
656
657 let diagnostic = warning_to_diagnostic(&warning, text);
658 assert_eq!(diagnostic.range.start.character, 3);
659 assert_eq!(diagnostic.range.end.character, 10);
660 }
661
662 #[test]
663 fn a_batch_of_diagnostics_places_each_column_on_its_own_line() {
664 let text = "🎉 first\nplain second\n";
665 let warning_of = |line: usize, column: usize| LintWarning {
666 line,
667 column,
668 end_line: line,
669 end_column: column + 1,
670 rule_name: Some("MD001".to_string()),
671 message: "Test".to_string(),
672 severity: Severity::Warning,
673 fix: None,
674 };
675
676 let diagnostics = warnings_to_diagnostics(&[warning_of(1, 3), warning_of(2, 3)], text);
677 assert_eq!(diagnostics[0].range.start.character, 3);
678 assert_eq!(diagnostics[1].range.start.character, 2);
679 }
680
681 #[test]
682 fn an_ignore_line_action_appends_after_the_last_code_unit_of_the_line() {
683 let text = "🎉 needs an ignore\n";
684 let warning = LintWarning {
685 line: 1,
686 column: 1,
687 end_line: 1,
688 end_column: 2,
689 rule_name: Some("MD001".to_string()),
690 message: "Test".to_string(),
691 severity: Severity::Warning,
692 fix: None,
693 };
694
695 let uri = Url::parse("file:///test.md").unwrap();
696 let action = warning_to_code_actions(&warning, &uri, text)
697 .into_iter()
698 .find(|action| action.title.contains("Ignore"))
699 .expect("an ignore-line action");
700 let edits = action.edit.unwrap().changes.unwrap().remove(&uri).unwrap();
701 assert_eq!(edits[0].range.start, Position { line: 0, character: 18 });
704 }
705
706 #[test]
707 fn test_warning_to_code_action_with_fix() {
708 let warning = LintWarning {
709 line: 1,
710 column: 1,
711 end_line: 1,
712 end_column: 5,
713 rule_name: Some("MD001".to_string()),
714 message: "Missing space".to_string(),
715 severity: Severity::Warning,
716 fix: Some(Fix::new(0..5, "Fixed".to_string())),
717 };
718
719 let uri = Url::parse("file:///test.md").unwrap();
720 let document_text = "Hello World";
721
722 let actions = warning_to_code_actions(&warning, &uri, document_text);
723 assert!(!actions.is_empty());
724 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
727 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
728 assert_eq!(action.is_preferred, Some(true));
729
730 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
731 let edits = &changes[&uri];
732 assert_eq!(edits.len(), 1);
733 assert_eq!(edits[0].new_text, "Fixed");
734 }
735
736 #[test]
737 fn test_warning_to_code_action_no_fix() {
738 let warning = LintWarning {
739 line: 1,
740 column: 1,
741 end_line: 1,
742 end_column: 5,
743 rule_name: Some("MD001".to_string()),
744 message: "No fix available".to_string(),
745 severity: Severity::Warning,
746 fix: None,
747 };
748
749 let uri = Url::parse("file:///test.md").unwrap();
750 let document_text = "Hello World";
751
752 let actions = warning_to_code_actions(&warning, &uri, document_text);
753 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
755 }
756
757 #[test]
758 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
759 let warning = LintWarning {
760 line: 2,
761 column: 1,
762 end_line: 2,
763 end_column: 100,
764 rule_name: Some("MD013".to_string()),
765 message: "Line length 95 exceeds 40 characters".to_string(),
766 severity: Severity::Warning,
767 fix: None,
768 };
769
770 let uri = Url::parse("file:///test.md").unwrap();
771 let document_text = "> This quoted paragraph starts explicitly and is intentionally long enough for reflow.\nlazy continuation line should also be included when reflow is triggered from this warning.\n";
772
773 let actions = warning_to_code_actions(&warning, &uri, document_text);
774 let reflow_action = actions
775 .iter()
776 .find(|action| action.title == "Reflow paragraph")
777 .expect("Expected manual reflow action for MD013");
778
779 let changes = reflow_action
780 .edit
781 .as_ref()
782 .and_then(|edit| edit.changes.as_ref())
783 .expect("Expected edits for reflow action");
784 let file_edits = changes.get(&uri).expect("Expected edits for URI");
785 assert_eq!(file_edits.len(), 1);
786 assert!(
787 file_edits[0]
788 .new_text
789 .lines()
790 .next()
791 .is_some_and(|line| line.starts_with("> ")),
792 "Expected blockquote prefix in reflow output"
793 );
794 }
795
796 #[test]
797 fn test_warning_to_code_action_multiline_fix() {
798 let warning = LintWarning {
799 line: 2,
800 column: 1,
801 end_line: 3,
802 end_column: 5,
803 rule_name: Some("MD001".to_string()),
804 message: "Multiline fix".to_string(),
805 severity: Severity::Warning,
806 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
807 };
808
809 let uri = Url::parse("file:///test.md").unwrap();
810 let document_text = "Hello\nWorld\nTest Line";
811
812 let actions = warning_to_code_actions(&warning, &uri, document_text);
813 assert!(!actions.is_empty());
814 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
817 let edits = &changes[&uri];
818 assert_eq!(edits[0].new_text, "Fixed\nContent");
819 assert_eq!(edits[0].range.start.line, 1);
820 assert_eq!(edits[0].range.start.character, 0);
821 }
822
823 #[test]
824 fn test_warning_to_code_action_atomic_with_additional_edits() {
825 let document_text = "See [docs](https://example.com) for details.\n";
831 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
832 let primary_end = document_text.find(" for details").unwrap();
833 let appended = "\n[docs]: https://example.com\n".to_string();
834
835 let warning = LintWarning {
836 line: 1,
837 column: primary_start + 1,
838 end_line: 1,
839 end_column: primary_end + 1,
840 rule_name: Some("MD054".to_string()),
841 message: "Inconsistent link style".to_string(),
842 severity: Severity::Warning,
843 fix: Some(Fix::with_additional_edits(
844 primary_start..primary_end,
845 "[docs]".to_string(),
846 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
847 )),
848 };
849
850 let uri = Url::parse("file:///test.md").unwrap();
851 let actions = warning_to_code_actions(&warning, &uri, document_text);
852
853 let fix_action = actions
854 .iter()
855 .find(|a| a.is_preferred == Some(true))
856 .expect("expected a preferred fix code action for MD054 ref-emit warning");
857 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
858
859 let edits = fix_action
860 .edit
861 .as_ref()
862 .and_then(|w| w.changes.as_ref())
863 .and_then(|c| c.get(&uri))
864 .expect("WorkspaceEdit should carry edits keyed by the document URI");
865
866 assert_eq!(
867 edits.len(),
868 2,
869 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
870 );
871 assert_eq!(edits[0].new_text, "[docs]");
872 assert_eq!(edits[1].new_text, appended);
873
874 assert_eq!(edits[1].range.start, edits[1].range.end);
876 }
877
878 #[test]
879 fn test_code_description_url_generation() {
880 let warning = LintWarning {
881 line: 1,
882 column: 1,
883 end_line: 1,
884 end_column: 5,
885 rule_name: Some("MD013".to_string()),
886 message: "Line too long".to_string(),
887 severity: Severity::Warning,
888 fix: None,
889 };
890
891 let diagnostic = warning_to_diagnostic(&warning, "Line too long\n");
892 assert!(diagnostic.code_description.is_some());
893
894 let url = diagnostic.code_description.unwrap().href;
895 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
896 }
897
898 #[test]
899 fn test_no_url_for_code_block_tool_warnings() {
900 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
903 let warning = LintWarning {
904 line: 1,
905 column: 1,
906 end_line: 1,
907 end_column: 10,
908 rule_name: Some(tool_name.to_string()),
909 message: "some tool warning".to_string(),
910 severity: Severity::Warning,
911 fix: None,
912 };
913
914 let diagnostic = warning_to_diagnostic(&warning, "some tool output\n");
915 assert!(
916 diagnostic.code_description.is_none(),
917 "Expected no URL for tool name '{tool_name}', but got one",
918 );
919 }
920 }
921
922 #[test]
923 fn test_lsp_config_partial_deserialization() {
924 let json = r#"{"enableLinting": false}"#;
926 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
927
928 assert!(!config.enable_linting);
929 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
932
933 #[test]
934 fn test_configuration_preference_serialization() {
935 let pref = ConfigurationPreference::EditorFirst;
937 let json = serde_json::to_string(&pref).unwrap();
938 assert_eq!(json, "\"editorFirst\"");
939
940 let pref = ConfigurationPreference::FilesystemFirst;
942 let json = serde_json::to_string(&pref).unwrap();
943 assert_eq!(json, "\"filesystemFirst\"");
944
945 let pref = ConfigurationPreference::EditorOnly;
947 let json = serde_json::to_string(&pref).unwrap();
948 assert_eq!(json, "\"editorOnly\"");
949
950 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
952 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
953 }
954
955 #[test]
956 fn test_lsp_rule_settings_deserialization() {
957 let json = r#"{
959 "lineLength": 120,
960 "disable": ["MD001", "MD002"],
961 "enable": ["MD013"]
962 }"#;
963 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
964
965 assert_eq!(settings.line_length, Some(120));
966 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
967 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
968 }
969
970 #[test]
971 fn test_lsp_rule_settings_with_per_rule_config() {
972 let json = r#"{
974 "lineLength": 80,
975 "MD013": {
976 "lineLength": 120,
977 "codeBlocks": false
978 },
979 "MD024": {
980 "siblingsOnly": true
981 }
982 }"#;
983 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
984
985 assert_eq!(settings.line_length, Some(80));
986
987 let md013 = settings.rules.get("MD013").unwrap();
989 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
990 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
991
992 let md024 = settings.rules.get("MD024").unwrap();
994 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
995 }
996
997 #[test]
998 fn test_full_lsp_config_with_settings() {
999 let json = r#"{
1001 "configPath": "/path/to/config",
1002 "enableLinting": true,
1003 "enableAutoFix": false,
1004 "configurationPreference": "editorFirst",
1005 "settings": {
1006 "lineLength": 100,
1007 "disable": ["MD033"],
1008 "MD013": {
1009 "lineLength": 120,
1010 "tables": false
1011 }
1012 }
1013 }"#;
1014 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1015
1016 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1017 assert!(config.enable_linting);
1018 assert!(!config.enable_auto_fix);
1019 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1020
1021 let settings = config.settings.unwrap();
1022 assert_eq!(settings.line_length, Some(100));
1023 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1024
1025 let md013 = settings.rules.get("MD013").unwrap();
1026 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1027 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1028 }
1029
1030 #[test]
1031 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1032 let warning = LintWarning {
1033 line: 5,
1034 column: 1,
1035 end_line: 5,
1036 end_column: 50,
1037 rule_name: Some("MD013".to_string()),
1038 message: "Line too long".to_string(),
1039 severity: Severity::Warning,
1040 fix: None,
1041 };
1042
1043 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1044 let uri = Url::parse("file:///test.md").unwrap();
1045
1046 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1047
1048 assert_eq!(action.title, "Ignore line-length (MD013) for this line");
1049 assert_eq!(action.is_preferred, Some(false));
1050 assert!(action.edit.is_some());
1051
1052 let edit = action.edit.unwrap();
1054 let changes = edit.changes.unwrap();
1055 let file_edits = changes.get(&uri).unwrap();
1056
1057 assert_eq!(file_edits.len(), 1);
1058 assert_eq!(file_edits[0].new_text, " <!-- rumdl-disable-line line-length -->");
1059 assert!(!file_edits[0].new_text.contains("markdownlint"));
1060
1061 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1065
1066 fn apply_ignore_line_edit(action: &CodeAction, uri: &Url, document: &str) -> String {
1068 let edits = action
1069 .edit
1070 .as_ref()
1071 .unwrap()
1072 .changes
1073 .as_ref()
1074 .unwrap()
1075 .get(uri)
1076 .unwrap();
1077 assert_eq!(edits.len(), 1);
1078 let edit = &edits[0];
1079 let mut lines: Vec<String> = document.lines().map(str::to_string).collect();
1080 let line = &mut lines[edit.range.start.line as usize];
1081 line.push_str(&edit.new_text);
1082 lines.join("\n")
1083 }
1084
1085 #[test]
1086 fn an_ignore_line_comment_names_the_rule_in_a_form_the_linter_accepts() {
1087 let long_line = "word ".repeat(40);
1088 let document = format!("# Title\n\n{long_line}text\n");
1089 let uri = Url::parse("file:///test.md").unwrap();
1090 let rules = crate::rules::all_rules(&crate::config::Config::default());
1091
1092 let before = crate::lint(
1093 &document,
1094 &rules,
1095 false,
1096 crate::config::MarkdownFlavor::Standard,
1097 None,
1098 None,
1099 )
1100 .unwrap();
1101 let warning = before
1102 .iter()
1103 .find(|w| w.rule_name.as_deref() == Some("MD013"))
1104 .expect("control: the long line must be reported before the comment is added");
1105
1106 let action = create_ignore_line_action(warning, &uri, &document).unwrap();
1107 let disabled = apply_ignore_line_edit(&action, &uri, &document);
1108 assert!(
1109 disabled.contains("<!-- rumdl-disable-line line-length -->"),
1110 "the comment names the rule readably, got: {disabled}"
1111 );
1112
1113 let after = crate::lint(
1114 &disabled,
1115 &rules,
1116 false,
1117 crate::config::MarkdownFlavor::Standard,
1118 None,
1119 None,
1120 )
1121 .unwrap();
1122 assert!(
1123 !after.iter().any(|w| w.rule_name.as_deref() == Some("MD013")),
1124 "the readable name must suppress the rule it names, got: {after:?}"
1125 );
1126 }
1127
1128 #[test]
1129 fn an_ignore_line_comment_falls_back_to_the_id_for_a_rule_with_no_readable_name() {
1130 let warning = LintWarning {
1131 line: 1,
1132 column: 1,
1133 end_line: 1,
1134 end_column: 2,
1135 rule_name: Some("MD999".to_string()),
1136 message: "From a rule the registry does not know".to_string(),
1137 severity: Severity::Warning,
1138 fix: None,
1139 };
1140 let uri = Url::parse("file:///test.md").unwrap();
1141
1142 let action = create_ignore_line_action(&warning, &uri, "text").unwrap();
1143 assert_eq!(action.title, "Ignore MD999 for this line");
1144 let edit = action.edit.unwrap();
1145 let file_edits = edit.changes.unwrap();
1146 assert_eq!(
1147 file_edits.get(&uri).unwrap()[0].new_text,
1148 " <!-- rumdl-disable-line MD999 -->"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_create_ignore_line_action_no_duplicate() {
1154 let warning = LintWarning {
1155 line: 1,
1156 column: 1,
1157 end_line: 1,
1158 end_column: 50,
1159 rule_name: Some("MD013".to_string()),
1160 message: "Line too long".to_string(),
1161 severity: Severity::Warning,
1162 fix: None,
1163 };
1164
1165 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1167 let uri = Url::parse("file:///test.md").unwrap();
1168
1169 let action = create_ignore_line_action(&warning, &uri, document);
1170
1171 assert!(action.is_none());
1173 }
1174
1175 #[test]
1176 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1177 let warning = LintWarning {
1178 line: 1,
1179 column: 1,
1180 end_line: 1,
1181 end_column: 50,
1182 rule_name: Some("MD013".to_string()),
1183 message: "Line too long".to_string(),
1184 severity: Severity::Warning,
1185 fix: None,
1186 };
1187
1188 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1190 let uri = Url::parse("file:///test.md").unwrap();
1191
1192 let action = create_ignore_line_action(&warning, &uri, document);
1193
1194 assert!(action.is_none());
1196 }
1197
1198 #[test]
1199 fn test_warning_to_code_actions_with_fix() {
1200 let warning = LintWarning {
1201 line: 1,
1202 column: 1,
1203 end_line: 1,
1204 end_column: 5,
1205 rule_name: Some("MD009".to_string()),
1206 message: "Trailing spaces".to_string(),
1207 severity: Severity::Warning,
1208 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1209 };
1210
1211 let uri = Url::parse("file:///test.md").unwrap();
1212 let document_text = "Hello \nWorld";
1213
1214 let actions = warning_to_code_actions(&warning, &uri, document_text);
1215
1216 assert_eq!(actions.len(), 2);
1218
1219 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1221 assert_eq!(actions[0].is_preferred, Some(true));
1222
1223 assert_eq!(actions[1].title, "Ignore no-trailing-spaces (MD009) for this line");
1225 assert_eq!(actions[1].is_preferred, Some(false));
1226 }
1227
1228 #[test]
1229 fn test_warning_to_code_actions_no_fix() {
1230 let warning = LintWarning {
1231 line: 1,
1232 column: 1,
1233 end_line: 1,
1234 end_column: 10,
1235 rule_name: Some("MD033".to_string()),
1236 message: "Inline HTML".to_string(),
1237 severity: Severity::Warning,
1238 fix: None,
1239 };
1240
1241 let uri = Url::parse("file:///test.md").unwrap();
1242 let document_text = "<div>HTML</div>";
1243
1244 let actions = warning_to_code_actions(&warning, &uri, document_text);
1245
1246 assert_eq!(actions.len(), 1);
1248 assert_eq!(actions[0].title, "Ignore no-inline-html (MD033) for this line");
1249 assert_eq!(actions[0].is_preferred, Some(false));
1250 }
1251
1252 #[test]
1253 fn test_warning_to_code_actions_no_rule_name() {
1254 let warning = LintWarning {
1255 line: 1,
1256 column: 1,
1257 end_line: 1,
1258 end_column: 5,
1259 rule_name: None,
1260 message: "Generic warning".to_string(),
1261 severity: Severity::Warning,
1262 fix: None,
1263 };
1264
1265 let uri = Url::parse("file:///test.md").unwrap();
1266 let document_text = "Hello World";
1267
1268 let actions = warning_to_code_actions(&warning, &uri, document_text);
1269
1270 assert_eq!(actions.len(), 0);
1272 }
1273
1274 #[test]
1275 fn test_legacy_warning_to_code_action_compatibility() {
1276 let warning = LintWarning {
1277 line: 1,
1278 column: 1,
1279 end_line: 1,
1280 end_column: 5,
1281 rule_name: Some("MD001".to_string()),
1282 message: "Test".to_string(),
1283 severity: Severity::Warning,
1284 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1285 };
1286
1287 let uri = Url::parse("file:///test.md").unwrap();
1288 let document_text = "Hello World";
1289
1290 #[allow(deprecated)]
1291 let action = warning_to_code_action(&warning, &uri, document_text);
1292
1293 assert!(action.is_some());
1295 let action = action.unwrap();
1296 assert_eq!(action.title, "Fix: Test");
1297 assert_eq!(action.is_preferred, Some(true));
1298 }
1299
1300 #[test]
1301 fn test_md034_convert_to_link_action() {
1302 let warning = LintWarning {
1304 line: 1,
1305 column: 1,
1306 end_line: 1,
1307 end_column: 25,
1308 rule_name: Some("MD034".to_string()),
1309 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1310 severity: Severity::Warning,
1311 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1312 };
1313
1314 let uri = Url::parse("file:///test.md").unwrap();
1315 let document_text = "https://example.com is a test URL";
1316
1317 let actions = warning_to_code_actions(&warning, &uri, document_text);
1318
1319 assert_eq!(actions.len(), 3);
1321
1322 assert_eq!(
1324 actions[0].title,
1325 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1326 );
1327 assert_eq!(actions[0].is_preferred, Some(true));
1328
1329 assert_eq!(actions[1].title, "Convert to markdown link");
1331 assert_eq!(actions[1].is_preferred, Some(false));
1332
1333 let edit = actions[1].edit.as_ref().unwrap();
1335 let changes = edit.changes.as_ref().unwrap();
1336 let file_edits = changes.get(&uri).unwrap();
1337 assert_eq!(file_edits.len(), 1);
1338
1339 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1341
1342 assert_eq!(actions[2].title, "Ignore no-bare-urls (MD034) for this line");
1344 }
1345
1346 #[test]
1347 fn test_md034_convert_to_link_action_email() {
1348 let warning = LintWarning {
1350 line: 1,
1351 column: 1,
1352 end_line: 1,
1353 end_column: 20,
1354 rule_name: Some("MD034".to_string()),
1355 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1356 severity: Severity::Warning,
1357 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1358 };
1359
1360 let uri = Url::parse("file:///test.md").unwrap();
1361 let document_text = "user@example.com is my email";
1362
1363 let actions = warning_to_code_actions(&warning, &uri, document_text);
1364
1365 assert_eq!(actions.len(), 3);
1367
1368 assert_eq!(actions[1].title, "Convert to markdown link");
1370
1371 let edit = actions[1].edit.as_ref().unwrap();
1372 let changes = edit.changes.as_ref().unwrap();
1373 let file_edits = changes.get(&uri).unwrap();
1374
1375 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1377 }
1378
1379 #[test]
1380 fn test_extract_url_from_fix_replacement() {
1381 assert_eq!(
1382 extract_url_from_fix_replacement("<https://example.com>"),
1383 Some("https://example.com")
1384 );
1385 assert_eq!(
1386 extract_url_from_fix_replacement("<user@example.com>"),
1387 Some("user@example.com")
1388 );
1389 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1390 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1391 }
1392
1393 #[test]
1394 fn test_extract_domain_for_placeholder() {
1395 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1396 assert_eq!(
1397 extract_domain_for_placeholder("https://example.com/path/to/page"),
1398 "example.com"
1399 );
1400 assert_eq!(
1401 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1402 "sub.example.com:8080"
1403 );
1404 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1405 assert_eq!(
1406 extract_domain_for_placeholder("ftp://files.example.com"),
1407 "files.example.com"
1408 );
1409 }
1410}