1use super::position::{analysis_range_to_lsp_range, 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 FileRemoved { path: PathBuf },
53 FullRescan,
55 Shutdown,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum RelintRequest {
68 File(PathBuf),
70 AllOpen,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub enum ConfigurationPreference {
79 #[default]
81 EditorFirst,
82 FilesystemFirst,
84 EditorOnly,
86}
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93#[serde(default, rename_all = "camelCase")]
94pub struct LspRuleSettings {
95 pub line_length: Option<usize>,
97 pub disable: Option<Vec<String>>,
99 pub enable: Option<Vec<String>>,
101 #[serde(flatten)]
103 pub rules: std::collections::HashMap<String, serde_json::Value>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default, rename_all = "camelCase")]
112pub struct RumdlLspConfig {
113 pub config_path: Option<String>,
115 pub enable_linting: bool,
117 pub enable_auto_fix: bool,
119 pub enable_rules: Option<Vec<String>>,
122 pub disable_rules: Option<Vec<String>>,
124 pub configuration_preference: ConfigurationPreference,
126 pub settings: Option<LspRuleSettings>,
129 pub enable_link_completions: bool,
132 pub enable_link_navigation: bool,
136 pub enable_symbols: bool,
143 pub link_completion_content_roots: Vec<String>,
147}
148
149impl Default for RumdlLspConfig {
150 fn default() -> Self {
151 Self {
152 config_path: None,
153 enable_linting: true,
154 enable_auto_fix: false,
155 enable_rules: None,
156 disable_rules: None,
157 configuration_preference: ConfigurationPreference::default(),
158 settings: None,
159 enable_link_completions: true,
160 enable_link_navigation: true,
161 enable_symbols: true,
162 link_completion_content_roots: Vec::new(),
163 }
164 }
165}
166
167pub fn warnings_to_diagnostics(warnings: &[crate::rule::LintWarning], document_text: &str) -> Vec<Diagnostic> {
172 let (lines, mark_units) = analysis_lines(document_text);
173 warnings
174 .iter()
175 .map(|warning| diagnostic_in(warning, &lines, mark_units))
176 .collect()
177}
178
179pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning, document_text: &str) -> Diagnostic {
184 let (lines, mark_units) = analysis_lines(document_text);
185 diagnostic_in(warning, &lines, mark_units)
186}
187
188fn analysis_lines(document_text: &str) -> (Vec<&str>, u32) {
192 let analysis = crate::utils::bom::strip_bom(document_text);
193 let mark_units = crate::utils::bom::utf16_len(document_text);
194 (analysis.lines().collect(), mark_units)
195}
196
197fn diagnostic_in(warning: &crate::rule::LintWarning, lines: &[&str], mark_units: u32) -> Diagnostic {
198 let on_first_line = |line: usize| if line == 0 { mark_units } else { 0 };
199 let start_line = warning.line.saturating_sub(1);
200 let end_line = warning.end_line.saturating_sub(1);
201
202 let start_position = Position {
203 line: start_line as u32,
204 character: char_column_to_utf16(lines.get(start_line).copied(), warning.column) + on_first_line(start_line),
205 };
206
207 let end_position = Position {
209 line: end_line as u32,
210 character: char_column_to_utf16(lines.get(end_line).copied(), warning.end_column) + on_first_line(end_line),
211 };
212
213 let severity = match warning.severity {
214 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
215 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
216 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
217 };
218
219 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
222 let is_rumdl_rule = rule_name.len() > 2
223 && rule_name[..2].eq_ignore_ascii_case("MD")
224 && rule_name[2..].chars().all(|c| c.is_ascii_digit());
225 if is_rumdl_rule {
226 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
227 .ok()
228 .map(|href| CodeDescription { href })
229 } else {
230 None
231 }
232 });
233
234 Diagnostic {
235 range: Range {
236 start: start_position,
237 end: end_position,
238 },
239 severity: Some(severity),
240 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
241 source: Some("rumdl".to_string()),
242 message: warning.message.clone(),
243 related_information: None,
244 tags: None,
245 code_description,
246 data: None,
247 }
248}
249
250pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
253 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
254}
255
256pub(crate) fn warning_to_code_actions_with_md013_config(
260 warning: &crate::rule::LintWarning,
261 uri: &Url,
262 document_text: &str,
263 md013_config: Option<&MD013Config>,
264) -> Vec<CodeAction> {
265 let mut actions = Vec::new();
266
267 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
269 actions.push(fix_action);
270 }
271
272 if warning.rule_name.as_deref() == Some("MD013")
275 && warning.fix.is_none()
276 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
277 {
278 actions.push(reflow_action);
279 }
280
281 if warning.rule_name.as_deref() == Some("MD034")
284 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
285 {
286 actions.push(convert_action);
287 }
288
289 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
291 actions.push(ignore_line_action);
292 }
293
294 actions
295}
296
297fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
299 if let Some(fix) = &warning.fix {
300 let primary = TextEdit {
305 range: byte_range_to_lsp_range(document_text, fix.range.clone())?,
306 new_text: fix.replacement.clone(),
307 };
308
309 let mut edits = Vec::with_capacity(1 + fix.additional_edits.len());
310 edits.push(primary);
311 for extra in &fix.additional_edits {
312 edits.push(TextEdit {
313 range: byte_range_to_lsp_range(document_text, extra.range.clone())?,
314 new_text: extra.replacement.clone(),
315 });
316 }
317
318 let mut changes = std::collections::HashMap::new();
319 changes.insert(uri.clone(), edits);
320
321 let workspace_edit = WorkspaceEdit {
322 changes: Some(changes),
323 document_changes: None,
324 change_annotations: None,
325 };
326
327 Some(CodeAction {
328 title: format!("Fix: {}", warning.message),
329 kind: Some(CodeActionKind::QUICKFIX),
330 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
331 edit: Some(workspace_edit),
332 command: None,
333 is_preferred: Some(true),
334 disabled: None,
335 data: None,
336 })
337 } else {
338 None
339 }
340}
341
342fn create_reflow_action(
345 warning: &crate::rule::LintWarning,
346 uri: &Url,
347 document_text: &str,
348 md013_config: Option<&MD013Config>,
349) -> Option<CodeAction> {
350 let options = if let Some(config) = md013_config {
353 config.to_reflow_options()
354 } else {
355 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
356 crate::utils::text_reflow::ReflowOptions {
357 line_length,
358 ..Default::default()
359 }
360 };
361
362 let line_ending_map = crate::utils::NormalizedLineEndingMap::new(document_text);
368 let normalized = crate::utils::normalize_line_ending(document_text, crate::utils::LineEnding::Lf);
369 let reflow_result = crate::utils::text_reflow::reflow_paragraph_at_line_with_options(
372 crate::utils::bom::strip_bom(&normalized),
373 warning.line,
374 &options,
375 )?;
376
377 let range = analysis_range_to_lsp_range(&normalized, reflow_result.start_byte..reflow_result.end_byte)?;
379
380 let edit = TextEdit {
381 range,
382 new_text: line_ending_map
383 .original_text_at(
384 &reflow_result.reflowed_text,
385 reflow_result.start_byte + crate::utils::bom::bom_len(&normalized),
386 )
387 .into_owned(),
388 };
389
390 let mut changes = std::collections::HashMap::new();
391 changes.insert(uri.clone(), vec![edit]);
392
393 let workspace_edit = WorkspaceEdit {
394 changes: Some(changes),
395 document_changes: None,
396 change_annotations: None,
397 };
398
399 Some(CodeAction {
400 title: "Reflow paragraph".to_string(),
401 kind: Some(CodeActionKind::QUICKFIX),
402 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
403 edit: Some(workspace_edit),
404 command: None,
405 is_preferred: Some(false), disabled: None,
407 data: None,
408 })
409}
410
411fn extract_line_length_from_message(message: &str) -> Option<usize> {
414 let exceeds_idx = message.find("exceeds")?;
416 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
420
421 num_str.parse::<usize>().ok()
422}
423
424fn create_convert_to_link_action(
428 warning: &crate::rule::LintWarning,
429 uri: &Url,
430 document_text: &str,
431) -> Option<CodeAction> {
432 let fix = warning.fix.as_ref()?;
434
435 let url = extract_url_from_fix_replacement(&fix.replacement)?;
438
439 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
441
442 let link_text = extract_domain_for_placeholder(url);
447 let destination = link_destination(url);
448 let new_text = format!("[{link_text}]({destination})");
449
450 let edit = TextEdit { range, new_text };
451
452 let mut changes = std::collections::HashMap::new();
453 changes.insert(uri.clone(), vec![edit]);
454
455 let workspace_edit = WorkspaceEdit {
456 changes: Some(changes),
457 document_changes: None,
458 change_annotations: None,
459 };
460
461 Some(CodeAction {
462 title: "Convert to markdown link".to_string(),
463 kind: Some(CodeActionKind::QUICKFIX),
464 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
465 edit: Some(workspace_edit),
466 command: None,
467 is_preferred: Some(false), disabled: None,
469 data: None,
470 })
471}
472
473fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
482 let trimmed = replacement.trim();
484 if trimmed.starts_with('<') && trimmed.ends_with('>') {
485 Some(&trimmed[1..trimmed.len() - 1])
486 } else {
487 None
488 }
489}
490
491fn is_bare_email(url: &str) -> bool {
497 url.contains('@') && !url.contains(':')
498}
499
500fn link_destination(url: &str) -> std::borrow::Cow<'_, str> {
506 if is_bare_email(url) {
507 std::borrow::Cow::Owned(format!("mailto:{url}"))
508 } else {
509 std::borrow::Cow::Borrowed(url)
510 }
511}
512
513fn extract_domain_for_placeholder(url: &str) -> &str {
517 if is_bare_email(url) {
519 return url;
520 }
521
522 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
524}
525
526fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
528 let rule_id = warning.rule_name.as_ref()?;
529 let warning_line = warning.line.saturating_sub(1);
530
531 let lines: Vec<&str> = document_text.lines().collect();
533 let line_content = lines.get(warning_line)?;
534
535 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
537 return None;
539 }
540
541 let line_end = Position {
543 line: warning_line as u32,
544 character: utf16_len(line_content),
545 };
546
547 let rule_label = crate::config::primary_alias(rule_id).unwrap_or(rule_id.as_str());
551 let comment = format!(" <!-- rumdl-disable-line {rule_label} -->");
552
553 let edit = TextEdit {
554 range: Range {
555 start: line_end,
556 end: line_end,
557 },
558 new_text: comment,
559 };
560
561 let mut changes = std::collections::HashMap::new();
562 changes.insert(uri.clone(), vec![edit]);
563
564 let title = if rule_label == rule_id {
565 format!("Ignore {rule_id} for this line")
566 } else {
567 format!("Ignore {rule_label} ({rule_id}) for this line")
568 };
569
570 Some(CodeAction {
571 title,
572 kind: Some(CodeActionKind::QUICKFIX),
573 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
574 edit: Some(WorkspaceEdit {
575 changes: Some(changes),
576 document_changes: None,
577 change_annotations: None,
578 }),
579 command: None,
580 is_preferred: Some(false), disabled: None,
582 data: None,
583 })
584}
585
586#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
589pub fn warning_to_code_action(
590 warning: &crate::rule::LintWarning,
591 uri: &Url,
592 document_text: &str,
593) -> Option<CodeAction> {
594 warning_to_code_actions(warning, uri, document_text)
595 .into_iter()
596 .find(|action| action.is_preferred == Some(true))
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use crate::rule::{Fix, LintWarning, Severity};
603
604 #[test]
605 fn test_rumdl_lsp_config_default() {
606 let config = RumdlLspConfig::default();
607 assert_eq!(config.config_path, None);
608 assert!(config.enable_linting);
609 assert!(!config.enable_auto_fix);
610 }
611
612 #[test]
613 fn test_rumdl_lsp_config_serialization() {
614 let config = RumdlLspConfig {
615 config_path: Some("/path/to/config.toml".to_string()),
616 enable_linting: false,
617 enable_auto_fix: true,
618 enable_rules: None,
619 disable_rules: None,
620 configuration_preference: ConfigurationPreference::EditorFirst,
621 settings: None,
622 enable_link_completions: true,
623 enable_link_navigation: true,
624 enable_symbols: true,
625 link_completion_content_roots: Vec::new(),
626 };
627
628 let json = serde_json::to_string(&config).unwrap();
630 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
631 assert!(json.contains("\"enableLinting\":false"));
632 assert!(json.contains("\"enableAutoFix\":true"));
633
634 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
636 assert_eq!(deserialized.config_path, config.config_path);
637 assert_eq!(deserialized.enable_linting, config.enable_linting);
638 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
639 }
640
641 #[test]
642 fn test_warning_to_diagnostic_basic() {
643 let warning = LintWarning {
644 line: 5,
645 column: 10,
646 end_line: 5,
647 end_column: 15,
648 rule_name: Some("MD001".to_string()),
649 message: "Test warning message".to_string(),
650 severity: Severity::Warning,
651 fix: None,
652 };
653
654 let diagnostic = warning_to_diagnostic(&warning, "one\ntwo\nthree\nfour\nfive: a longer line\n");
655
656 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
659 assert_eq!(diagnostic.range.end.character, 14);
660 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
661 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
662 assert_eq!(diagnostic.message, "Test warning message");
663 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
664 }
665
666 #[test]
667 fn test_warning_to_diagnostic_error_severity() {
668 let warning = LintWarning {
669 line: 1,
670 column: 1,
671 end_line: 1,
672 end_column: 5,
673 rule_name: Some("MD002".to_string()),
674 message: "Error message".to_string(),
675 severity: Severity::Error,
676 fix: None,
677 };
678
679 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
680 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
681 }
682
683 #[test]
684 fn test_warning_to_diagnostic_no_rule_name() {
685 let warning = LintWarning {
686 line: 1,
687 column: 1,
688 end_line: 1,
689 end_column: 5,
690 rule_name: None,
691 message: "Generic warning".to_string(),
692 severity: Severity::Warning,
693 fix: None,
694 };
695
696 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
697 assert_eq!(diagnostic.code, None);
698 assert!(diagnostic.code_description.is_none());
699 }
700
701 #[test]
702 fn test_warning_to_diagnostic_edge_cases() {
703 let warning = LintWarning {
705 line: 0,
706 column: 0,
707 end_line: 0,
708 end_column: 0,
709 rule_name: Some("MD001".to_string()),
710 message: "Edge case".to_string(),
711 severity: Severity::Warning,
712 fix: None,
713 };
714
715 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
716 assert_eq!(diagnostic.range.start.line, 0);
717 assert_eq!(diagnostic.range.start.character, 0);
718 }
719
720 #[test]
721 fn a_diagnostic_column_after_a_non_bmp_codepoint_counts_both_code_units() {
722 let text = "🎉 badword here\n";
726 let warning = LintWarning {
727 line: 1,
728 column: 3,
729 end_line: 1,
730 end_column: 10,
731 rule_name: Some("MD001".to_string()),
732 message: "Test".to_string(),
733 severity: Severity::Warning,
734 fix: None,
735 };
736
737 let diagnostic = warning_to_diagnostic(&warning, text);
738 assert_eq!(diagnostic.range.start.character, 3);
739 assert_eq!(diagnostic.range.end.character, 10);
740 }
741
742 #[test]
743 fn a_batch_of_diagnostics_places_each_column_on_its_own_line() {
744 let text = "🎉 first\nplain second\n";
745 let warning_of = |line: usize, column: usize| LintWarning {
746 line,
747 column,
748 end_line: line,
749 end_column: column + 1,
750 rule_name: Some("MD001".to_string()),
751 message: "Test".to_string(),
752 severity: Severity::Warning,
753 fix: None,
754 };
755
756 let diagnostics = warnings_to_diagnostics(&[warning_of(1, 3), warning_of(2, 3)], text);
757 assert_eq!(diagnostics[0].range.start.character, 3);
758 assert_eq!(diagnostics[1].range.start.character, 2);
759 }
760
761 #[test]
762 fn first_line_diagnostics_count_the_byte_order_mark_the_client_holds() {
763 let text = "\u{feff}# Title\nsecond\n";
764 let warning_of = |line: usize, column: usize| LintWarning {
765 line,
766 column,
767 end_line: line,
768 end_column: column + 2,
769 rule_name: Some("MD001".to_string()),
770 message: "Test".to_string(),
771 severity: Severity::Warning,
772 fix: None,
773 };
774 let single = warning_to_diagnostic(&warning_of(1, 3), text);
777 assert_eq!((single.range.start.character, single.range.end.character), (3, 5));
778 let batch = warnings_to_diagnostics(&[warning_of(1, 3), warning_of(2, 3)], text);
779 assert_eq!((batch[0].range.start.character, batch[0].range.end.character), (3, 5));
780 assert_eq!((batch[1].range.start.character, batch[1].range.end.character), (2, 4));
781 let plain = warning_to_diagnostic(&warning_of(1, 3), "# Title\nsecond\n");
783 assert_eq!(plain.range.start.character, 2);
784 }
785
786 #[test]
787 fn a_fix_edit_behind_a_byte_order_mark_addresses_the_clients_text() {
788 let text = "\u{feff}#Title\n\nbody\n";
789 let warning = LintWarning {
791 line: 1,
792 column: 2,
793 end_line: 1,
794 end_column: 2,
795 rule_name: Some("MD018".to_string()),
796 message: "No space after # in heading".to_string(),
797 severity: Severity::Warning,
798 fix: Some(crate::rule::Fix::new(4..4, " ".to_string())),
799 };
800 let uri = Url::parse("file:///t.md").unwrap();
801 let actions = warning_to_code_actions(&warning, &uri, text);
802 let edit = actions
803 .iter()
804 .find_map(|action| action.edit.as_ref())
805 .and_then(|edit| edit.changes.as_ref())
806 .and_then(|changes| changes.get(&uri))
807 .map(|edits| edits[0].clone())
808 .expect("a fix edit");
809 assert_eq!((edit.range.start.line, edit.range.start.character), (0, 2));
811 assert_eq!((edit.range.end.line, edit.range.end.character), (0, 2));
812 }
813
814 #[test]
815 fn an_ignore_line_action_appends_after_the_last_code_unit_of_the_line() {
816 let text = "🎉 needs an ignore\n";
817 let warning = LintWarning {
818 line: 1,
819 column: 1,
820 end_line: 1,
821 end_column: 2,
822 rule_name: Some("MD001".to_string()),
823 message: "Test".to_string(),
824 severity: Severity::Warning,
825 fix: None,
826 };
827
828 let uri = Url::parse("file:///test.md").unwrap();
829 let action = warning_to_code_actions(&warning, &uri, text)
830 .into_iter()
831 .find(|action| action.title.contains("Ignore"))
832 .expect("an ignore-line action");
833 let edits = action.edit.unwrap().changes.unwrap().remove(&uri).unwrap();
834 assert_eq!(edits[0].range.start, Position { line: 0, character: 18 });
837 }
838
839 #[test]
840 fn test_warning_to_code_action_with_fix() {
841 let warning = LintWarning {
842 line: 1,
843 column: 1,
844 end_line: 1,
845 end_column: 5,
846 rule_name: Some("MD001".to_string()),
847 message: "Missing space".to_string(),
848 severity: Severity::Warning,
849 fix: Some(Fix::new(0..5, "Fixed".to_string())),
850 };
851
852 let uri = Url::parse("file:///test.md").unwrap();
853 let document_text = "Hello World";
854
855 let actions = warning_to_code_actions(&warning, &uri, document_text);
856 assert!(!actions.is_empty());
857 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
860 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
861 assert_eq!(action.is_preferred, Some(true));
862
863 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
864 let edits = &changes[&uri];
865 assert_eq!(edits.len(), 1);
866 assert_eq!(edits[0].new_text, "Fixed");
867 }
868
869 #[test]
870 fn test_warning_to_code_action_no_fix() {
871 let warning = LintWarning {
872 line: 1,
873 column: 1,
874 end_line: 1,
875 end_column: 5,
876 rule_name: Some("MD001".to_string()),
877 message: "No fix available".to_string(),
878 severity: Severity::Warning,
879 fix: None,
880 };
881
882 let uri = Url::parse("file:///test.md").unwrap();
883 let document_text = "Hello World";
884
885 let actions = warning_to_code_actions(&warning, &uri, document_text);
886 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
888 }
889
890 #[test]
891 fn bom_reflow_edits_preserve_first_line_positions_and_line_endings() {
892 let warning = LintWarning {
893 line: 1,
894 column: 1,
895 end_line: 1,
896 end_column: 100,
897 rule_name: Some("MD013".into()),
898 message: "Line length 95 exceeds 40 characters".into(),
899 severity: Severity::Warning,
900 fix: None,
901 };
902 let uri = Url::parse("file:///test.md").unwrap();
903 for ending in ["\n", "\r\n"] {
904 let plain = format!(
905 "This paragraph is long enough that it needs to wrap onto several short lines without losing any of its words.{ending}"
906 );
907 let marked = format!("\u{feff}{plain}");
908 let edit = |text: &str| {
909 let action = create_reflow_action(&warning, &uri, text, None).expect("reflow action");
910 action.edit.unwrap().changes.unwrap().remove(&uri).unwrap().remove(0)
911 };
912 let (plain_edit, marked_edit) = (edit(&plain), edit(&marked));
913 assert_eq!(marked_edit.range.start.character, plain_edit.range.start.character + 1);
914 assert_eq!(marked_edit.range.start.line, 0);
915 assert_eq!(marked_edit.range.end.line, plain_edit.range.end.line);
916 let shift = u32::from(plain_edit.range.end.line == 0);
917 assert_eq!(marked_edit.range.end.character, plain_edit.range.end.character + shift);
918 assert_eq!(marked_edit.new_text, plain_edit.new_text);
919 assert!(marked_edit.new_text.contains(ending));
920 if ending == "\r\n" {
921 assert!(!marked_edit.new_text.replace("\r\n", "").contains('\n'));
922 }
923 }
924 }
925
926 #[test]
927 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
928 let warning = LintWarning {
929 line: 2,
930 column: 1,
931 end_line: 2,
932 end_column: 100,
933 rule_name: Some("MD013".to_string()),
934 message: "Line length 95 exceeds 40 characters".to_string(),
935 severity: Severity::Warning,
936 fix: None,
937 };
938
939 let uri = Url::parse("file:///test.md").unwrap();
940 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";
941
942 let actions = warning_to_code_actions(&warning, &uri, document_text);
943 let reflow_action = actions
944 .iter()
945 .find(|action| action.title == "Reflow paragraph")
946 .expect("Expected manual reflow action for MD013");
947
948 let changes = reflow_action
949 .edit
950 .as_ref()
951 .and_then(|edit| edit.changes.as_ref())
952 .expect("Expected edits for reflow action");
953 let file_edits = changes.get(&uri).expect("Expected edits for URI");
954 assert_eq!(file_edits.len(), 1);
955 assert!(
956 file_edits[0]
957 .new_text
958 .lines()
959 .next()
960 .is_some_and(|line| line.starts_with("> ")),
961 "Expected blockquote prefix in reflow output"
962 );
963 }
964
965 #[test]
966 fn test_warning_to_code_action_multiline_fix() {
967 let warning = LintWarning {
968 line: 2,
969 column: 1,
970 end_line: 3,
971 end_column: 5,
972 rule_name: Some("MD001".to_string()),
973 message: "Multiline fix".to_string(),
974 severity: Severity::Warning,
975 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
976 };
977
978 let uri = Url::parse("file:///test.md").unwrap();
979 let document_text = "Hello\nWorld\nTest Line";
980
981 let actions = warning_to_code_actions(&warning, &uri, document_text);
982 assert!(!actions.is_empty());
983 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
986 let edits = &changes[&uri];
987 assert_eq!(edits[0].new_text, "Fixed\nContent");
988 assert_eq!(edits[0].range.start.line, 1);
989 assert_eq!(edits[0].range.start.character, 0);
990 }
991
992 #[test]
993 fn test_warning_to_code_action_atomic_with_additional_edits() {
994 let document_text = "See [docs](https://example.com) for details.\n";
1000 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
1001 let primary_end = document_text.find(" for details").unwrap();
1002 let appended = "\n[docs]: https://example.com\n".to_string();
1003
1004 let warning = LintWarning {
1005 line: 1,
1006 column: primary_start + 1,
1007 end_line: 1,
1008 end_column: primary_end + 1,
1009 rule_name: Some("MD054".to_string()),
1010 message: "Inconsistent link style".to_string(),
1011 severity: Severity::Warning,
1012 fix: Some(Fix::with_additional_edits(
1013 primary_start..primary_end,
1014 "[docs]".to_string(),
1015 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
1016 )),
1017 };
1018
1019 let uri = Url::parse("file:///test.md").unwrap();
1020 let actions = warning_to_code_actions(&warning, &uri, document_text);
1021
1022 let fix_action = actions
1023 .iter()
1024 .find(|a| a.is_preferred == Some(true))
1025 .expect("expected a preferred fix code action for MD054 ref-emit warning");
1026 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
1027
1028 let edits = fix_action
1029 .edit
1030 .as_ref()
1031 .and_then(|w| w.changes.as_ref())
1032 .and_then(|c| c.get(&uri))
1033 .expect("WorkspaceEdit should carry edits keyed by the document URI");
1034
1035 assert_eq!(
1036 edits.len(),
1037 2,
1038 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
1039 );
1040 assert_eq!(edits[0].new_text, "[docs]");
1041 assert_eq!(edits[1].new_text, appended);
1042
1043 assert_eq!(edits[1].range.start, edits[1].range.end);
1045 }
1046
1047 #[test]
1048 fn test_code_description_url_generation() {
1049 let warning = LintWarning {
1050 line: 1,
1051 column: 1,
1052 end_line: 1,
1053 end_column: 5,
1054 rule_name: Some("MD013".to_string()),
1055 message: "Line too long".to_string(),
1056 severity: Severity::Warning,
1057 fix: None,
1058 };
1059
1060 let diagnostic = warning_to_diagnostic(&warning, "Line too long\n");
1061 assert!(diagnostic.code_description.is_some());
1062
1063 let url = diagnostic.code_description.unwrap().href;
1064 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
1065 }
1066
1067 #[test]
1068 fn test_no_url_for_code_block_tool_warnings() {
1069 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
1072 let warning = LintWarning {
1073 line: 1,
1074 column: 1,
1075 end_line: 1,
1076 end_column: 10,
1077 rule_name: Some(tool_name.to_string()),
1078 message: "some tool warning".to_string(),
1079 severity: Severity::Warning,
1080 fix: None,
1081 };
1082
1083 let diagnostic = warning_to_diagnostic(&warning, "some tool output\n");
1084 assert!(
1085 diagnostic.code_description.is_none(),
1086 "Expected no URL for tool name '{tool_name}', but got one",
1087 );
1088 }
1089 }
1090
1091 #[test]
1092 fn test_lsp_config_partial_deserialization() {
1093 let json = r#"{"enableLinting": false}"#;
1095 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1096
1097 assert!(!config.enable_linting);
1098 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
1101
1102 #[test]
1103 fn test_configuration_preference_serialization() {
1104 let pref = ConfigurationPreference::EditorFirst;
1106 let json = serde_json::to_string(&pref).unwrap();
1107 assert_eq!(json, "\"editorFirst\"");
1108
1109 let pref = ConfigurationPreference::FilesystemFirst;
1111 let json = serde_json::to_string(&pref).unwrap();
1112 assert_eq!(json, "\"filesystemFirst\"");
1113
1114 let pref = ConfigurationPreference::EditorOnly;
1116 let json = serde_json::to_string(&pref).unwrap();
1117 assert_eq!(json, "\"editorOnly\"");
1118
1119 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
1121 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
1122 }
1123
1124 #[test]
1125 fn test_lsp_rule_settings_deserialization() {
1126 let json = r#"{
1128 "lineLength": 120,
1129 "disable": ["MD001", "MD002"],
1130 "enable": ["MD013"]
1131 }"#;
1132 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1133
1134 assert_eq!(settings.line_length, Some(120));
1135 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
1136 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
1137 }
1138
1139 #[test]
1140 fn test_lsp_rule_settings_with_per_rule_config() {
1141 let json = r#"{
1143 "lineLength": 80,
1144 "MD013": {
1145 "lineLength": 120,
1146 "codeBlocks": false
1147 },
1148 "MD024": {
1149 "siblingsOnly": true
1150 }
1151 }"#;
1152 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1153
1154 assert_eq!(settings.line_length, Some(80));
1155
1156 let md013 = settings.rules.get("MD013").unwrap();
1158 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1159 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1160
1161 let md024 = settings.rules.get("MD024").unwrap();
1163 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1164 }
1165
1166 #[test]
1167 fn test_full_lsp_config_with_settings() {
1168 let json = r#"{
1170 "configPath": "/path/to/config",
1171 "enableLinting": true,
1172 "enableAutoFix": false,
1173 "configurationPreference": "editorFirst",
1174 "settings": {
1175 "lineLength": 100,
1176 "disable": ["MD033"],
1177 "MD013": {
1178 "lineLength": 120,
1179 "tables": false
1180 }
1181 }
1182 }"#;
1183 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1184
1185 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1186 assert!(config.enable_linting);
1187 assert!(!config.enable_auto_fix);
1188 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1189
1190 let settings = config.settings.unwrap();
1191 assert_eq!(settings.line_length, Some(100));
1192 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1193
1194 let md013 = settings.rules.get("MD013").unwrap();
1195 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1196 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1197 }
1198
1199 #[test]
1200 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1201 let warning = LintWarning {
1202 line: 5,
1203 column: 1,
1204 end_line: 5,
1205 end_column: 50,
1206 rule_name: Some("MD013".to_string()),
1207 message: "Line too long".to_string(),
1208 severity: Severity::Warning,
1209 fix: None,
1210 };
1211
1212 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1213 let uri = Url::parse("file:///test.md").unwrap();
1214
1215 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1216
1217 assert_eq!(action.title, "Ignore line-length (MD013) for this line");
1218 assert_eq!(action.is_preferred, Some(false));
1219 assert!(action.edit.is_some());
1220
1221 let edit = action.edit.unwrap();
1223 let changes = edit.changes.unwrap();
1224 let file_edits = changes.get(&uri).unwrap();
1225
1226 assert_eq!(file_edits.len(), 1);
1227 assert_eq!(file_edits[0].new_text, " <!-- rumdl-disable-line line-length -->");
1228 assert!(!file_edits[0].new_text.contains("markdownlint"));
1229
1230 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1234
1235 fn apply_ignore_line_edit(action: &CodeAction, uri: &Url, document: &str) -> String {
1237 let edits = action
1238 .edit
1239 .as_ref()
1240 .unwrap()
1241 .changes
1242 .as_ref()
1243 .unwrap()
1244 .get(uri)
1245 .unwrap();
1246 assert_eq!(edits.len(), 1);
1247 let edit = &edits[0];
1248 let mut lines: Vec<String> = document.lines().map(str::to_string).collect();
1249 let line = &mut lines[edit.range.start.line as usize];
1250 line.push_str(&edit.new_text);
1251 lines.join("\n")
1252 }
1253
1254 #[test]
1255 fn an_ignore_line_comment_names_the_rule_in_a_form_the_linter_accepts() {
1256 let long_line = "word ".repeat(40);
1257 let document = format!("# Title\n\n{long_line}text\n");
1258 let uri = Url::parse("file:///test.md").unwrap();
1259 let rules = crate::rules::all_rules(&crate::config::Config::default());
1260
1261 let before = crate::lint(
1262 &document,
1263 &rules,
1264 false,
1265 crate::config::MarkdownFlavor::Standard,
1266 None,
1267 None,
1268 )
1269 .unwrap();
1270 let warning = before
1271 .iter()
1272 .find(|w| w.rule_name.as_deref() == Some("MD013"))
1273 .expect("control: the long line must be reported before the comment is added");
1274
1275 let action = create_ignore_line_action(warning, &uri, &document).unwrap();
1276 let disabled = apply_ignore_line_edit(&action, &uri, &document);
1277 assert!(
1278 disabled.contains("<!-- rumdl-disable-line line-length -->"),
1279 "the comment names the rule readably, got: {disabled}"
1280 );
1281
1282 let after = crate::lint(
1283 &disabled,
1284 &rules,
1285 false,
1286 crate::config::MarkdownFlavor::Standard,
1287 None,
1288 None,
1289 )
1290 .unwrap();
1291 assert!(
1292 !after.iter().any(|w| w.rule_name.as_deref() == Some("MD013")),
1293 "the readable name must suppress the rule it names, got: {after:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn an_ignore_line_comment_falls_back_to_the_id_for_a_rule_with_no_readable_name() {
1299 let warning = LintWarning {
1300 line: 1,
1301 column: 1,
1302 end_line: 1,
1303 end_column: 2,
1304 rule_name: Some("MD999".to_string()),
1305 message: "From a rule the registry does not know".to_string(),
1306 severity: Severity::Warning,
1307 fix: None,
1308 };
1309 let uri = Url::parse("file:///test.md").unwrap();
1310
1311 let action = create_ignore_line_action(&warning, &uri, "text").unwrap();
1312 assert_eq!(action.title, "Ignore MD999 for this line");
1313 let edit = action.edit.unwrap();
1314 let file_edits = edit.changes.unwrap();
1315 assert_eq!(
1316 file_edits.get(&uri).unwrap()[0].new_text,
1317 " <!-- rumdl-disable-line MD999 -->"
1318 );
1319 }
1320
1321 #[test]
1322 fn test_create_ignore_line_action_no_duplicate() {
1323 let warning = LintWarning {
1324 line: 1,
1325 column: 1,
1326 end_line: 1,
1327 end_column: 50,
1328 rule_name: Some("MD013".to_string()),
1329 message: "Line too long".to_string(),
1330 severity: Severity::Warning,
1331 fix: None,
1332 };
1333
1334 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1336 let uri = Url::parse("file:///test.md").unwrap();
1337
1338 let action = create_ignore_line_action(&warning, &uri, document);
1339
1340 assert!(action.is_none());
1342 }
1343
1344 #[test]
1345 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1346 let warning = LintWarning {
1347 line: 1,
1348 column: 1,
1349 end_line: 1,
1350 end_column: 50,
1351 rule_name: Some("MD013".to_string()),
1352 message: "Line too long".to_string(),
1353 severity: Severity::Warning,
1354 fix: None,
1355 };
1356
1357 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1359 let uri = Url::parse("file:///test.md").unwrap();
1360
1361 let action = create_ignore_line_action(&warning, &uri, document);
1362
1363 assert!(action.is_none());
1365 }
1366
1367 #[test]
1368 fn test_warning_to_code_actions_with_fix() {
1369 let warning = LintWarning {
1370 line: 1,
1371 column: 1,
1372 end_line: 1,
1373 end_column: 5,
1374 rule_name: Some("MD009".to_string()),
1375 message: "Trailing spaces".to_string(),
1376 severity: Severity::Warning,
1377 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1378 };
1379
1380 let uri = Url::parse("file:///test.md").unwrap();
1381 let document_text = "Hello \nWorld";
1382
1383 let actions = warning_to_code_actions(&warning, &uri, document_text);
1384
1385 assert_eq!(actions.len(), 2);
1387
1388 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1390 assert_eq!(actions[0].is_preferred, Some(true));
1391
1392 assert_eq!(actions[1].title, "Ignore no-trailing-spaces (MD009) for this line");
1394 assert_eq!(actions[1].is_preferred, Some(false));
1395 }
1396
1397 #[test]
1398 fn test_warning_to_code_actions_no_fix() {
1399 let warning = LintWarning {
1400 line: 1,
1401 column: 1,
1402 end_line: 1,
1403 end_column: 10,
1404 rule_name: Some("MD033".to_string()),
1405 message: "Inline HTML".to_string(),
1406 severity: Severity::Warning,
1407 fix: None,
1408 };
1409
1410 let uri = Url::parse("file:///test.md").unwrap();
1411 let document_text = "<div>HTML</div>";
1412
1413 let actions = warning_to_code_actions(&warning, &uri, document_text);
1414
1415 assert_eq!(actions.len(), 1);
1417 assert_eq!(actions[0].title, "Ignore no-inline-html (MD033) for this line");
1418 assert_eq!(actions[0].is_preferred, Some(false));
1419 }
1420
1421 #[test]
1422 fn test_warning_to_code_actions_no_rule_name() {
1423 let warning = LintWarning {
1424 line: 1,
1425 column: 1,
1426 end_line: 1,
1427 end_column: 5,
1428 rule_name: None,
1429 message: "Generic warning".to_string(),
1430 severity: Severity::Warning,
1431 fix: None,
1432 };
1433
1434 let uri = Url::parse("file:///test.md").unwrap();
1435 let document_text = "Hello World";
1436
1437 let actions = warning_to_code_actions(&warning, &uri, document_text);
1438
1439 assert_eq!(actions.len(), 0);
1441 }
1442
1443 #[test]
1444 fn test_legacy_warning_to_code_action_compatibility() {
1445 let warning = LintWarning {
1446 line: 1,
1447 column: 1,
1448 end_line: 1,
1449 end_column: 5,
1450 rule_name: Some("MD001".to_string()),
1451 message: "Test".to_string(),
1452 severity: Severity::Warning,
1453 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1454 };
1455
1456 let uri = Url::parse("file:///test.md").unwrap();
1457 let document_text = "Hello World";
1458
1459 #[allow(deprecated)]
1460 let action = warning_to_code_action(&warning, &uri, document_text);
1461
1462 assert!(action.is_some());
1464 let action = action.unwrap();
1465 assert_eq!(action.title, "Fix: Test");
1466 assert_eq!(action.is_preferred, Some(true));
1467 }
1468
1469 #[test]
1470 fn test_md034_convert_to_link_action() {
1471 let warning = LintWarning {
1473 line: 1,
1474 column: 1,
1475 end_line: 1,
1476 end_column: 25,
1477 rule_name: Some("MD034".to_string()),
1478 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1479 severity: Severity::Warning,
1480 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1481 };
1482
1483 let uri = Url::parse("file:///test.md").unwrap();
1484 let document_text = "https://example.com is a test URL";
1485
1486 let actions = warning_to_code_actions(&warning, &uri, document_text);
1487
1488 assert_eq!(actions.len(), 3);
1490
1491 assert_eq!(
1493 actions[0].title,
1494 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1495 );
1496 assert_eq!(actions[0].is_preferred, Some(true));
1497
1498 assert_eq!(actions[1].title, "Convert to markdown link");
1500 assert_eq!(actions[1].is_preferred, Some(false));
1501
1502 let edit = actions[1].edit.as_ref().unwrap();
1504 let changes = edit.changes.as_ref().unwrap();
1505 let file_edits = changes.get(&uri).unwrap();
1506 assert_eq!(file_edits.len(), 1);
1507
1508 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1510
1511 assert_eq!(actions[2].title, "Ignore no-bare-urls (MD034) for this line");
1513 }
1514
1515 #[test]
1516 fn test_md034_convert_to_link_action_email() {
1517 let warning = LintWarning {
1519 line: 1,
1520 column: 1,
1521 end_line: 1,
1522 end_column: 20,
1523 rule_name: Some("MD034".to_string()),
1524 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1525 severity: Severity::Warning,
1526 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1527 };
1528
1529 let uri = Url::parse("file:///test.md").unwrap();
1530 let document_text = "user@example.com is my email";
1531
1532 let actions = warning_to_code_actions(&warning, &uri, document_text);
1533
1534 assert_eq!(actions.len(), 3);
1536
1537 assert_eq!(actions[1].title, "Convert to markdown link");
1539
1540 let edit = actions[1].edit.as_ref().unwrap();
1541 let changes = edit.changes.as_ref().unwrap();
1542 let file_edits = changes.get(&uri).unwrap();
1543
1544 assert_eq!(file_edits[0].new_text, "[user@example.com](mailto:user@example.com)");
1547 }
1548
1549 #[test]
1550 fn test_md034_convert_to_link_action_leaves_an_existing_scheme_alone() {
1551 let warning = LintWarning {
1554 line: 1,
1555 column: 1,
1556 end_line: 1,
1557 end_column: 26,
1558 rule_name: Some("MD034".to_string()),
1559 message: "URL without angle brackets or link formatting: 'xmpp:user@example.com'".to_string(),
1560 severity: Severity::Warning,
1561 fix: Some(Fix::new(0..21, "<xmpp:user@example.com>".to_string())),
1562 };
1563
1564 let uri = Url::parse("file:///test.md").unwrap();
1565 let document_text = "xmpp:user@example.com is a chat address";
1566
1567 let actions = warning_to_code_actions(&warning, &uri, document_text);
1568 let edit = actions[1].edit.as_ref().unwrap();
1569 let file_edits = edit.changes.as_ref().unwrap().get(&uri).unwrap();
1570
1571 assert_eq!(file_edits[0].new_text, "[xmpp:user@example.com](xmpp:user@example.com)");
1572 }
1573
1574 #[test]
1575 fn test_link_destination_writes_the_scheme_only_for_a_bare_email() {
1576 assert_eq!(link_destination("user@example.com"), "mailto:user@example.com");
1578 assert_eq!(
1579 link_destination("first.last+tag@sub.example.co.uk"),
1580 "mailto:first.last+tag@sub.example.co.uk"
1581 );
1582 assert_eq!(link_destination("xmpp:user@example.com"), "xmpp:user@example.com");
1583 assert_eq!(
1584 link_destination("https://user@example.com/path"),
1585 "https://user@example.com/path"
1586 );
1587 assert_eq!(link_destination("https://example.com"), "https://example.com");
1588 }
1589
1590 #[test]
1591 fn test_converted_email_link_round_trips_through_md034() {
1592 use crate::rule::Rule;
1594 let rule = crate::rules::MD034NoBareUrls;
1595 let ctx = crate::lint_context::LintContext::new(
1596 "Mail [user@example.com](mailto:user@example.com) now\n",
1597 crate::config::MarkdownFlavor::Standard,
1598 None,
1599 );
1600 assert!(rule.check(&ctx).unwrap().is_empty());
1601 }
1602
1603 #[test]
1604 fn test_extract_url_from_fix_replacement() {
1605 assert_eq!(
1606 extract_url_from_fix_replacement("<https://example.com>"),
1607 Some("https://example.com")
1608 );
1609 assert_eq!(
1610 extract_url_from_fix_replacement("<user@example.com>"),
1611 Some("user@example.com")
1612 );
1613 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1614 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1615
1616 assert_eq!(
1618 extract_url_from_fix_replacement("[https://example.com](https://example.com)"),
1619 None
1620 );
1621 }
1622
1623 #[test]
1628 fn test_md034_convert_to_link_action_absent_for_mdx_link_fix() {
1629 let warning = LintWarning {
1630 line: 1,
1631 column: 1,
1632 end_line: 1,
1633 end_column: 20,
1634 rule_name: Some("MD034".to_string()),
1635 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1636 fix: Some(Fix::new(
1637 0..19,
1638 "[https://example.com](https://example.com)".to_string(),
1639 )),
1640 severity: Severity::Warning,
1641 };
1642
1643 let uri = Url::parse("file:///test.mdx").unwrap();
1644 let actions = warning_to_code_actions(&warning, &uri, "https://example.com is the site");
1645
1646 assert!(
1647 !actions.iter().any(|a| a.title == "Convert to markdown link"),
1648 "the MDX fix is already a link, so no conversion action should be offered"
1649 );
1650 assert!(
1651 actions.iter().any(|a| a.title.starts_with("Fix")),
1652 "the primary fix action must still be offered"
1653 );
1654 }
1655
1656 #[test]
1657 fn test_extract_domain_for_placeholder() {
1658 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1659 assert_eq!(
1660 extract_domain_for_placeholder("https://example.com/path/to/page"),
1661 "example.com"
1662 );
1663 assert_eq!(
1664 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1665 "sub.example.com:8080"
1666 );
1667 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1668 assert_eq!(
1669 extract_domain_for_placeholder("ftp://files.example.com"),
1670 "files.example.com"
1671 );
1672 }
1673}