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 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: Vec<&str> = document_text.lines().collect();
173 warnings.iter().map(|warning| diagnostic_in(warning, &lines)).collect()
174}
175
176pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning, document_text: &str) -> Diagnostic {
181 let lines: Vec<&str> = document_text.lines().collect();
182 diagnostic_in(warning, &lines)
183}
184
185fn diagnostic_in(warning: &crate::rule::LintWarning, lines: &[&str]) -> Diagnostic {
186 let start_line = warning.line.saturating_sub(1);
187 let end_line = warning.end_line.saturating_sub(1);
188
189 let start_position = Position {
190 line: start_line as u32,
191 character: char_column_to_utf16(lines.get(start_line).copied(), warning.column),
192 };
193
194 let end_position = Position {
196 line: end_line as u32,
197 character: char_column_to_utf16(lines.get(end_line).copied(), warning.end_column),
198 };
199
200 let severity = match warning.severity {
201 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
202 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
203 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
204 };
205
206 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
209 let is_rumdl_rule = rule_name.len() > 2
210 && rule_name[..2].eq_ignore_ascii_case("MD")
211 && rule_name[2..].chars().all(|c| c.is_ascii_digit());
212 if is_rumdl_rule {
213 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
214 .ok()
215 .map(|href| CodeDescription { href })
216 } else {
217 None
218 }
219 });
220
221 Diagnostic {
222 range: Range {
223 start: start_position,
224 end: end_position,
225 },
226 severity: Some(severity),
227 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
228 source: Some("rumdl".to_string()),
229 message: warning.message.clone(),
230 related_information: None,
231 tags: None,
232 code_description,
233 data: None,
234 }
235}
236
237pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
240 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
241}
242
243pub(crate) fn warning_to_code_actions_with_md013_config(
247 warning: &crate::rule::LintWarning,
248 uri: &Url,
249 document_text: &str,
250 md013_config: Option<&MD013Config>,
251) -> Vec<CodeAction> {
252 let mut actions = Vec::new();
253
254 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
256 actions.push(fix_action);
257 }
258
259 if warning.rule_name.as_deref() == Some("MD013")
262 && warning.fix.is_none()
263 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
264 {
265 actions.push(reflow_action);
266 }
267
268 if warning.rule_name.as_deref() == Some("MD034")
271 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
272 {
273 actions.push(convert_action);
274 }
275
276 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
278 actions.push(ignore_line_action);
279 }
280
281 actions
282}
283
284fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
286 if let Some(fix) = &warning.fix {
287 let primary = TextEdit {
292 range: byte_range_to_lsp_range(document_text, fix.range.clone())?,
293 new_text: fix.replacement.clone(),
294 };
295
296 let mut edits = Vec::with_capacity(1 + fix.additional_edits.len());
297 edits.push(primary);
298 for extra in &fix.additional_edits {
299 edits.push(TextEdit {
300 range: byte_range_to_lsp_range(document_text, extra.range.clone())?,
301 new_text: extra.replacement.clone(),
302 });
303 }
304
305 let mut changes = std::collections::HashMap::new();
306 changes.insert(uri.clone(), edits);
307
308 let workspace_edit = WorkspaceEdit {
309 changes: Some(changes),
310 document_changes: None,
311 change_annotations: None,
312 };
313
314 Some(CodeAction {
315 title: format!("Fix: {}", warning.message),
316 kind: Some(CodeActionKind::QUICKFIX),
317 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
318 edit: Some(workspace_edit),
319 command: None,
320 is_preferred: Some(true),
321 disabled: None,
322 data: None,
323 })
324 } else {
325 None
326 }
327}
328
329fn create_reflow_action(
332 warning: &crate::rule::LintWarning,
333 uri: &Url,
334 document_text: &str,
335 md013_config: Option<&MD013Config>,
336) -> Option<CodeAction> {
337 let options = if let Some(config) = md013_config {
340 config.to_reflow_options()
341 } else {
342 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
343 crate::utils::text_reflow::ReflowOptions {
344 line_length,
345 ..Default::default()
346 }
347 };
348
349 let line_ending = crate::utils::detect_line_ending_enum(document_text);
355 let normalized = crate::utils::normalize_line_ending(document_text, crate::utils::LineEnding::Lf);
356 let reflow_result =
357 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(&normalized, warning.line, &options)?;
358
359 let range = byte_range_to_lsp_range(&normalized, reflow_result.start_byte..reflow_result.end_byte)?;
361
362 let edit = TextEdit {
363 range,
364 new_text: crate::utils::normalize_line_ending(&reflow_result.reflowed_text, line_ending).into_owned(),
365 };
366
367 let mut changes = std::collections::HashMap::new();
368 changes.insert(uri.clone(), vec![edit]);
369
370 let workspace_edit = WorkspaceEdit {
371 changes: Some(changes),
372 document_changes: None,
373 change_annotations: None,
374 };
375
376 Some(CodeAction {
377 title: "Reflow paragraph".to_string(),
378 kind: Some(CodeActionKind::QUICKFIX),
379 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
380 edit: Some(workspace_edit),
381 command: None,
382 is_preferred: Some(false), disabled: None,
384 data: None,
385 })
386}
387
388fn extract_line_length_from_message(message: &str) -> Option<usize> {
391 let exceeds_idx = message.find("exceeds")?;
393 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
397
398 num_str.parse::<usize>().ok()
399}
400
401fn create_convert_to_link_action(
405 warning: &crate::rule::LintWarning,
406 uri: &Url,
407 document_text: &str,
408) -> Option<CodeAction> {
409 let fix = warning.fix.as_ref()?;
411
412 let url = extract_url_from_fix_replacement(&fix.replacement)?;
415
416 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
418
419 let link_text = extract_domain_for_placeholder(url);
424 let destination = link_destination(url);
425 let new_text = format!("[{link_text}]({destination})");
426
427 let edit = TextEdit { range, new_text };
428
429 let mut changes = std::collections::HashMap::new();
430 changes.insert(uri.clone(), vec![edit]);
431
432 let workspace_edit = WorkspaceEdit {
433 changes: Some(changes),
434 document_changes: None,
435 change_annotations: None,
436 };
437
438 Some(CodeAction {
439 title: "Convert to markdown link".to_string(),
440 kind: Some(CodeActionKind::QUICKFIX),
441 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
442 edit: Some(workspace_edit),
443 command: None,
444 is_preferred: Some(false), disabled: None,
446 data: None,
447 })
448}
449
450fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
459 let trimmed = replacement.trim();
461 if trimmed.starts_with('<') && trimmed.ends_with('>') {
462 Some(&trimmed[1..trimmed.len() - 1])
463 } else {
464 None
465 }
466}
467
468fn is_bare_email(url: &str) -> bool {
474 url.contains('@') && !url.contains(':')
475}
476
477fn link_destination(url: &str) -> std::borrow::Cow<'_, str> {
483 if is_bare_email(url) {
484 std::borrow::Cow::Owned(format!("mailto:{url}"))
485 } else {
486 std::borrow::Cow::Borrowed(url)
487 }
488}
489
490fn extract_domain_for_placeholder(url: &str) -> &str {
494 if is_bare_email(url) {
496 return url;
497 }
498
499 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
501}
502
503fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
505 let rule_id = warning.rule_name.as_ref()?;
506 let warning_line = warning.line.saturating_sub(1);
507
508 let lines: Vec<&str> = document_text.lines().collect();
510 let line_content = lines.get(warning_line)?;
511
512 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
514 return None;
516 }
517
518 let line_end = Position {
520 line: warning_line as u32,
521 character: utf16_len(line_content),
522 };
523
524 let rule_label = crate::config::primary_alias(rule_id).unwrap_or(rule_id.as_str());
528 let comment = format!(" <!-- rumdl-disable-line {rule_label} -->");
529
530 let edit = TextEdit {
531 range: Range {
532 start: line_end,
533 end: line_end,
534 },
535 new_text: comment,
536 };
537
538 let mut changes = std::collections::HashMap::new();
539 changes.insert(uri.clone(), vec![edit]);
540
541 let title = if rule_label == rule_id {
542 format!("Ignore {rule_id} for this line")
543 } else {
544 format!("Ignore {rule_label} ({rule_id}) for this line")
545 };
546
547 Some(CodeAction {
548 title,
549 kind: Some(CodeActionKind::QUICKFIX),
550 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
551 edit: Some(WorkspaceEdit {
552 changes: Some(changes),
553 document_changes: None,
554 change_annotations: None,
555 }),
556 command: None,
557 is_preferred: Some(false), disabled: None,
559 data: None,
560 })
561}
562
563#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
566pub fn warning_to_code_action(
567 warning: &crate::rule::LintWarning,
568 uri: &Url,
569 document_text: &str,
570) -> Option<CodeAction> {
571 warning_to_code_actions(warning, uri, document_text)
572 .into_iter()
573 .find(|action| action.is_preferred == Some(true))
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::rule::{Fix, LintWarning, Severity};
580
581 #[test]
582 fn test_rumdl_lsp_config_default() {
583 let config = RumdlLspConfig::default();
584 assert_eq!(config.config_path, None);
585 assert!(config.enable_linting);
586 assert!(!config.enable_auto_fix);
587 }
588
589 #[test]
590 fn test_rumdl_lsp_config_serialization() {
591 let config = RumdlLspConfig {
592 config_path: Some("/path/to/config.toml".to_string()),
593 enable_linting: false,
594 enable_auto_fix: true,
595 enable_rules: None,
596 disable_rules: None,
597 configuration_preference: ConfigurationPreference::EditorFirst,
598 settings: None,
599 enable_link_completions: true,
600 enable_link_navigation: true,
601 enable_symbols: true,
602 link_completion_content_roots: Vec::new(),
603 };
604
605 let json = serde_json::to_string(&config).unwrap();
607 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
608 assert!(json.contains("\"enableLinting\":false"));
609 assert!(json.contains("\"enableAutoFix\":true"));
610
611 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
613 assert_eq!(deserialized.config_path, config.config_path);
614 assert_eq!(deserialized.enable_linting, config.enable_linting);
615 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
616 }
617
618 #[test]
619 fn test_warning_to_diagnostic_basic() {
620 let warning = LintWarning {
621 line: 5,
622 column: 10,
623 end_line: 5,
624 end_column: 15,
625 rule_name: Some("MD001".to_string()),
626 message: "Test warning message".to_string(),
627 severity: Severity::Warning,
628 fix: None,
629 };
630
631 let diagnostic = warning_to_diagnostic(&warning, "one\ntwo\nthree\nfour\nfive: a longer line\n");
632
633 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
636 assert_eq!(diagnostic.range.end.character, 14);
637 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
638 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
639 assert_eq!(diagnostic.message, "Test warning message");
640 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
641 }
642
643 #[test]
644 fn test_warning_to_diagnostic_error_severity() {
645 let warning = LintWarning {
646 line: 1,
647 column: 1,
648 end_line: 1,
649 end_column: 5,
650 rule_name: Some("MD002".to_string()),
651 message: "Error message".to_string(),
652 severity: Severity::Error,
653 fix: None,
654 };
655
656 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
657 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
658 }
659
660 #[test]
661 fn test_warning_to_diagnostic_no_rule_name() {
662 let warning = LintWarning {
663 line: 1,
664 column: 1,
665 end_line: 1,
666 end_column: 5,
667 rule_name: None,
668 message: "Generic warning".to_string(),
669 severity: Severity::Warning,
670 fix: None,
671 };
672
673 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
674 assert_eq!(diagnostic.code, None);
675 assert!(diagnostic.code_description.is_none());
676 }
677
678 #[test]
679 fn test_warning_to_diagnostic_edge_cases() {
680 let warning = LintWarning {
682 line: 0,
683 column: 0,
684 end_line: 0,
685 end_column: 0,
686 rule_name: Some("MD001".to_string()),
687 message: "Edge case".to_string(),
688 severity: Severity::Warning,
689 fix: None,
690 };
691
692 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
693 assert_eq!(diagnostic.range.start.line, 0);
694 assert_eq!(diagnostic.range.start.character, 0);
695 }
696
697 #[test]
698 fn a_diagnostic_column_after_a_non_bmp_codepoint_counts_both_code_units() {
699 let text = "🎉 badword here\n";
703 let warning = LintWarning {
704 line: 1,
705 column: 3,
706 end_line: 1,
707 end_column: 10,
708 rule_name: Some("MD001".to_string()),
709 message: "Test".to_string(),
710 severity: Severity::Warning,
711 fix: None,
712 };
713
714 let diagnostic = warning_to_diagnostic(&warning, text);
715 assert_eq!(diagnostic.range.start.character, 3);
716 assert_eq!(diagnostic.range.end.character, 10);
717 }
718
719 #[test]
720 fn a_batch_of_diagnostics_places_each_column_on_its_own_line() {
721 let text = "🎉 first\nplain second\n";
722 let warning_of = |line: usize, column: usize| LintWarning {
723 line,
724 column,
725 end_line: line,
726 end_column: column + 1,
727 rule_name: Some("MD001".to_string()),
728 message: "Test".to_string(),
729 severity: Severity::Warning,
730 fix: None,
731 };
732
733 let diagnostics = warnings_to_diagnostics(&[warning_of(1, 3), warning_of(2, 3)], text);
734 assert_eq!(diagnostics[0].range.start.character, 3);
735 assert_eq!(diagnostics[1].range.start.character, 2);
736 }
737
738 #[test]
739 fn an_ignore_line_action_appends_after_the_last_code_unit_of_the_line() {
740 let text = "🎉 needs an ignore\n";
741 let warning = LintWarning {
742 line: 1,
743 column: 1,
744 end_line: 1,
745 end_column: 2,
746 rule_name: Some("MD001".to_string()),
747 message: "Test".to_string(),
748 severity: Severity::Warning,
749 fix: None,
750 };
751
752 let uri = Url::parse("file:///test.md").unwrap();
753 let action = warning_to_code_actions(&warning, &uri, text)
754 .into_iter()
755 .find(|action| action.title.contains("Ignore"))
756 .expect("an ignore-line action");
757 let edits = action.edit.unwrap().changes.unwrap().remove(&uri).unwrap();
758 assert_eq!(edits[0].range.start, Position { line: 0, character: 18 });
761 }
762
763 #[test]
764 fn test_warning_to_code_action_with_fix() {
765 let warning = LintWarning {
766 line: 1,
767 column: 1,
768 end_line: 1,
769 end_column: 5,
770 rule_name: Some("MD001".to_string()),
771 message: "Missing space".to_string(),
772 severity: Severity::Warning,
773 fix: Some(Fix::new(0..5, "Fixed".to_string())),
774 };
775
776 let uri = Url::parse("file:///test.md").unwrap();
777 let document_text = "Hello World";
778
779 let actions = warning_to_code_actions(&warning, &uri, document_text);
780 assert!(!actions.is_empty());
781 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
784 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
785 assert_eq!(action.is_preferred, Some(true));
786
787 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
788 let edits = &changes[&uri];
789 assert_eq!(edits.len(), 1);
790 assert_eq!(edits[0].new_text, "Fixed");
791 }
792
793 #[test]
794 fn test_warning_to_code_action_no_fix() {
795 let warning = LintWarning {
796 line: 1,
797 column: 1,
798 end_line: 1,
799 end_column: 5,
800 rule_name: Some("MD001".to_string()),
801 message: "No fix available".to_string(),
802 severity: Severity::Warning,
803 fix: None,
804 };
805
806 let uri = Url::parse("file:///test.md").unwrap();
807 let document_text = "Hello World";
808
809 let actions = warning_to_code_actions(&warning, &uri, document_text);
810 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
812 }
813
814 #[test]
815 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
816 let warning = LintWarning {
817 line: 2,
818 column: 1,
819 end_line: 2,
820 end_column: 100,
821 rule_name: Some("MD013".to_string()),
822 message: "Line length 95 exceeds 40 characters".to_string(),
823 severity: Severity::Warning,
824 fix: None,
825 };
826
827 let uri = Url::parse("file:///test.md").unwrap();
828 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";
829
830 let actions = warning_to_code_actions(&warning, &uri, document_text);
831 let reflow_action = actions
832 .iter()
833 .find(|action| action.title == "Reflow paragraph")
834 .expect("Expected manual reflow action for MD013");
835
836 let changes = reflow_action
837 .edit
838 .as_ref()
839 .and_then(|edit| edit.changes.as_ref())
840 .expect("Expected edits for reflow action");
841 let file_edits = changes.get(&uri).expect("Expected edits for URI");
842 assert_eq!(file_edits.len(), 1);
843 assert!(
844 file_edits[0]
845 .new_text
846 .lines()
847 .next()
848 .is_some_and(|line| line.starts_with("> ")),
849 "Expected blockquote prefix in reflow output"
850 );
851 }
852
853 #[test]
854 fn test_warning_to_code_action_multiline_fix() {
855 let warning = LintWarning {
856 line: 2,
857 column: 1,
858 end_line: 3,
859 end_column: 5,
860 rule_name: Some("MD001".to_string()),
861 message: "Multiline fix".to_string(),
862 severity: Severity::Warning,
863 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
864 };
865
866 let uri = Url::parse("file:///test.md").unwrap();
867 let document_text = "Hello\nWorld\nTest Line";
868
869 let actions = warning_to_code_actions(&warning, &uri, document_text);
870 assert!(!actions.is_empty());
871 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
874 let edits = &changes[&uri];
875 assert_eq!(edits[0].new_text, "Fixed\nContent");
876 assert_eq!(edits[0].range.start.line, 1);
877 assert_eq!(edits[0].range.start.character, 0);
878 }
879
880 #[test]
881 fn test_warning_to_code_action_atomic_with_additional_edits() {
882 let document_text = "See [docs](https://example.com) for details.\n";
888 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
889 let primary_end = document_text.find(" for details").unwrap();
890 let appended = "\n[docs]: https://example.com\n".to_string();
891
892 let warning = LintWarning {
893 line: 1,
894 column: primary_start + 1,
895 end_line: 1,
896 end_column: primary_end + 1,
897 rule_name: Some("MD054".to_string()),
898 message: "Inconsistent link style".to_string(),
899 severity: Severity::Warning,
900 fix: Some(Fix::with_additional_edits(
901 primary_start..primary_end,
902 "[docs]".to_string(),
903 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
904 )),
905 };
906
907 let uri = Url::parse("file:///test.md").unwrap();
908 let actions = warning_to_code_actions(&warning, &uri, document_text);
909
910 let fix_action = actions
911 .iter()
912 .find(|a| a.is_preferred == Some(true))
913 .expect("expected a preferred fix code action for MD054 ref-emit warning");
914 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
915
916 let edits = fix_action
917 .edit
918 .as_ref()
919 .and_then(|w| w.changes.as_ref())
920 .and_then(|c| c.get(&uri))
921 .expect("WorkspaceEdit should carry edits keyed by the document URI");
922
923 assert_eq!(
924 edits.len(),
925 2,
926 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
927 );
928 assert_eq!(edits[0].new_text, "[docs]");
929 assert_eq!(edits[1].new_text, appended);
930
931 assert_eq!(edits[1].range.start, edits[1].range.end);
933 }
934
935 #[test]
936 fn test_code_description_url_generation() {
937 let warning = LintWarning {
938 line: 1,
939 column: 1,
940 end_line: 1,
941 end_column: 5,
942 rule_name: Some("MD013".to_string()),
943 message: "Line too long".to_string(),
944 severity: Severity::Warning,
945 fix: None,
946 };
947
948 let diagnostic = warning_to_diagnostic(&warning, "Line too long\n");
949 assert!(diagnostic.code_description.is_some());
950
951 let url = diagnostic.code_description.unwrap().href;
952 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
953 }
954
955 #[test]
956 fn test_no_url_for_code_block_tool_warnings() {
957 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
960 let warning = LintWarning {
961 line: 1,
962 column: 1,
963 end_line: 1,
964 end_column: 10,
965 rule_name: Some(tool_name.to_string()),
966 message: "some tool warning".to_string(),
967 severity: Severity::Warning,
968 fix: None,
969 };
970
971 let diagnostic = warning_to_diagnostic(&warning, "some tool output\n");
972 assert!(
973 diagnostic.code_description.is_none(),
974 "Expected no URL for tool name '{tool_name}', but got one",
975 );
976 }
977 }
978
979 #[test]
980 fn test_lsp_config_partial_deserialization() {
981 let json = r#"{"enableLinting": false}"#;
983 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
984
985 assert!(!config.enable_linting);
986 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
989
990 #[test]
991 fn test_configuration_preference_serialization() {
992 let pref = ConfigurationPreference::EditorFirst;
994 let json = serde_json::to_string(&pref).unwrap();
995 assert_eq!(json, "\"editorFirst\"");
996
997 let pref = ConfigurationPreference::FilesystemFirst;
999 let json = serde_json::to_string(&pref).unwrap();
1000 assert_eq!(json, "\"filesystemFirst\"");
1001
1002 let pref = ConfigurationPreference::EditorOnly;
1004 let json = serde_json::to_string(&pref).unwrap();
1005 assert_eq!(json, "\"editorOnly\"");
1006
1007 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
1009 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
1010 }
1011
1012 #[test]
1013 fn test_lsp_rule_settings_deserialization() {
1014 let json = r#"{
1016 "lineLength": 120,
1017 "disable": ["MD001", "MD002"],
1018 "enable": ["MD013"]
1019 }"#;
1020 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1021
1022 assert_eq!(settings.line_length, Some(120));
1023 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
1024 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
1025 }
1026
1027 #[test]
1028 fn test_lsp_rule_settings_with_per_rule_config() {
1029 let json = r#"{
1031 "lineLength": 80,
1032 "MD013": {
1033 "lineLength": 120,
1034 "codeBlocks": false
1035 },
1036 "MD024": {
1037 "siblingsOnly": true
1038 }
1039 }"#;
1040 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1041
1042 assert_eq!(settings.line_length, Some(80));
1043
1044 let md013 = settings.rules.get("MD013").unwrap();
1046 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1047 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1048
1049 let md024 = settings.rules.get("MD024").unwrap();
1051 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1052 }
1053
1054 #[test]
1055 fn test_full_lsp_config_with_settings() {
1056 let json = r#"{
1058 "configPath": "/path/to/config",
1059 "enableLinting": true,
1060 "enableAutoFix": false,
1061 "configurationPreference": "editorFirst",
1062 "settings": {
1063 "lineLength": 100,
1064 "disable": ["MD033"],
1065 "MD013": {
1066 "lineLength": 120,
1067 "tables": false
1068 }
1069 }
1070 }"#;
1071 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1072
1073 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1074 assert!(config.enable_linting);
1075 assert!(!config.enable_auto_fix);
1076 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1077
1078 let settings = config.settings.unwrap();
1079 assert_eq!(settings.line_length, Some(100));
1080 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1081
1082 let md013 = settings.rules.get("MD013").unwrap();
1083 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1084 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1085 }
1086
1087 #[test]
1088 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1089 let warning = LintWarning {
1090 line: 5,
1091 column: 1,
1092 end_line: 5,
1093 end_column: 50,
1094 rule_name: Some("MD013".to_string()),
1095 message: "Line too long".to_string(),
1096 severity: Severity::Warning,
1097 fix: None,
1098 };
1099
1100 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1101 let uri = Url::parse("file:///test.md").unwrap();
1102
1103 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1104
1105 assert_eq!(action.title, "Ignore line-length (MD013) for this line");
1106 assert_eq!(action.is_preferred, Some(false));
1107 assert!(action.edit.is_some());
1108
1109 let edit = action.edit.unwrap();
1111 let changes = edit.changes.unwrap();
1112 let file_edits = changes.get(&uri).unwrap();
1113
1114 assert_eq!(file_edits.len(), 1);
1115 assert_eq!(file_edits[0].new_text, " <!-- rumdl-disable-line line-length -->");
1116 assert!(!file_edits[0].new_text.contains("markdownlint"));
1117
1118 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1122
1123 fn apply_ignore_line_edit(action: &CodeAction, uri: &Url, document: &str) -> String {
1125 let edits = action
1126 .edit
1127 .as_ref()
1128 .unwrap()
1129 .changes
1130 .as_ref()
1131 .unwrap()
1132 .get(uri)
1133 .unwrap();
1134 assert_eq!(edits.len(), 1);
1135 let edit = &edits[0];
1136 let mut lines: Vec<String> = document.lines().map(str::to_string).collect();
1137 let line = &mut lines[edit.range.start.line as usize];
1138 line.push_str(&edit.new_text);
1139 lines.join("\n")
1140 }
1141
1142 #[test]
1143 fn an_ignore_line_comment_names_the_rule_in_a_form_the_linter_accepts() {
1144 let long_line = "word ".repeat(40);
1145 let document = format!("# Title\n\n{long_line}text\n");
1146 let uri = Url::parse("file:///test.md").unwrap();
1147 let rules = crate::rules::all_rules(&crate::config::Config::default());
1148
1149 let before = crate::lint(
1150 &document,
1151 &rules,
1152 false,
1153 crate::config::MarkdownFlavor::Standard,
1154 None,
1155 None,
1156 )
1157 .unwrap();
1158 let warning = before
1159 .iter()
1160 .find(|w| w.rule_name.as_deref() == Some("MD013"))
1161 .expect("control: the long line must be reported before the comment is added");
1162
1163 let action = create_ignore_line_action(warning, &uri, &document).unwrap();
1164 let disabled = apply_ignore_line_edit(&action, &uri, &document);
1165 assert!(
1166 disabled.contains("<!-- rumdl-disable-line line-length -->"),
1167 "the comment names the rule readably, got: {disabled}"
1168 );
1169
1170 let after = crate::lint(
1171 &disabled,
1172 &rules,
1173 false,
1174 crate::config::MarkdownFlavor::Standard,
1175 None,
1176 None,
1177 )
1178 .unwrap();
1179 assert!(
1180 !after.iter().any(|w| w.rule_name.as_deref() == Some("MD013")),
1181 "the readable name must suppress the rule it names, got: {after:?}"
1182 );
1183 }
1184
1185 #[test]
1186 fn an_ignore_line_comment_falls_back_to_the_id_for_a_rule_with_no_readable_name() {
1187 let warning = LintWarning {
1188 line: 1,
1189 column: 1,
1190 end_line: 1,
1191 end_column: 2,
1192 rule_name: Some("MD999".to_string()),
1193 message: "From a rule the registry does not know".to_string(),
1194 severity: Severity::Warning,
1195 fix: None,
1196 };
1197 let uri = Url::parse("file:///test.md").unwrap();
1198
1199 let action = create_ignore_line_action(&warning, &uri, "text").unwrap();
1200 assert_eq!(action.title, "Ignore MD999 for this line");
1201 let edit = action.edit.unwrap();
1202 let file_edits = edit.changes.unwrap();
1203 assert_eq!(
1204 file_edits.get(&uri).unwrap()[0].new_text,
1205 " <!-- rumdl-disable-line MD999 -->"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_create_ignore_line_action_no_duplicate() {
1211 let warning = LintWarning {
1212 line: 1,
1213 column: 1,
1214 end_line: 1,
1215 end_column: 50,
1216 rule_name: Some("MD013".to_string()),
1217 message: "Line too long".to_string(),
1218 severity: Severity::Warning,
1219 fix: None,
1220 };
1221
1222 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1224 let uri = Url::parse("file:///test.md").unwrap();
1225
1226 let action = create_ignore_line_action(&warning, &uri, document);
1227
1228 assert!(action.is_none());
1230 }
1231
1232 #[test]
1233 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1234 let warning = LintWarning {
1235 line: 1,
1236 column: 1,
1237 end_line: 1,
1238 end_column: 50,
1239 rule_name: Some("MD013".to_string()),
1240 message: "Line too long".to_string(),
1241 severity: Severity::Warning,
1242 fix: None,
1243 };
1244
1245 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1247 let uri = Url::parse("file:///test.md").unwrap();
1248
1249 let action = create_ignore_line_action(&warning, &uri, document);
1250
1251 assert!(action.is_none());
1253 }
1254
1255 #[test]
1256 fn test_warning_to_code_actions_with_fix() {
1257 let warning = LintWarning {
1258 line: 1,
1259 column: 1,
1260 end_line: 1,
1261 end_column: 5,
1262 rule_name: Some("MD009".to_string()),
1263 message: "Trailing spaces".to_string(),
1264 severity: Severity::Warning,
1265 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1266 };
1267
1268 let uri = Url::parse("file:///test.md").unwrap();
1269 let document_text = "Hello \nWorld";
1270
1271 let actions = warning_to_code_actions(&warning, &uri, document_text);
1272
1273 assert_eq!(actions.len(), 2);
1275
1276 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1278 assert_eq!(actions[0].is_preferred, Some(true));
1279
1280 assert_eq!(actions[1].title, "Ignore no-trailing-spaces (MD009) for this line");
1282 assert_eq!(actions[1].is_preferred, Some(false));
1283 }
1284
1285 #[test]
1286 fn test_warning_to_code_actions_no_fix() {
1287 let warning = LintWarning {
1288 line: 1,
1289 column: 1,
1290 end_line: 1,
1291 end_column: 10,
1292 rule_name: Some("MD033".to_string()),
1293 message: "Inline HTML".to_string(),
1294 severity: Severity::Warning,
1295 fix: None,
1296 };
1297
1298 let uri = Url::parse("file:///test.md").unwrap();
1299 let document_text = "<div>HTML</div>";
1300
1301 let actions = warning_to_code_actions(&warning, &uri, document_text);
1302
1303 assert_eq!(actions.len(), 1);
1305 assert_eq!(actions[0].title, "Ignore no-inline-html (MD033) for this line");
1306 assert_eq!(actions[0].is_preferred, Some(false));
1307 }
1308
1309 #[test]
1310 fn test_warning_to_code_actions_no_rule_name() {
1311 let warning = LintWarning {
1312 line: 1,
1313 column: 1,
1314 end_line: 1,
1315 end_column: 5,
1316 rule_name: None,
1317 message: "Generic warning".to_string(),
1318 severity: Severity::Warning,
1319 fix: None,
1320 };
1321
1322 let uri = Url::parse("file:///test.md").unwrap();
1323 let document_text = "Hello World";
1324
1325 let actions = warning_to_code_actions(&warning, &uri, document_text);
1326
1327 assert_eq!(actions.len(), 0);
1329 }
1330
1331 #[test]
1332 fn test_legacy_warning_to_code_action_compatibility() {
1333 let warning = LintWarning {
1334 line: 1,
1335 column: 1,
1336 end_line: 1,
1337 end_column: 5,
1338 rule_name: Some("MD001".to_string()),
1339 message: "Test".to_string(),
1340 severity: Severity::Warning,
1341 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1342 };
1343
1344 let uri = Url::parse("file:///test.md").unwrap();
1345 let document_text = "Hello World";
1346
1347 #[allow(deprecated)]
1348 let action = warning_to_code_action(&warning, &uri, document_text);
1349
1350 assert!(action.is_some());
1352 let action = action.unwrap();
1353 assert_eq!(action.title, "Fix: Test");
1354 assert_eq!(action.is_preferred, Some(true));
1355 }
1356
1357 #[test]
1358 fn test_md034_convert_to_link_action() {
1359 let warning = LintWarning {
1361 line: 1,
1362 column: 1,
1363 end_line: 1,
1364 end_column: 25,
1365 rule_name: Some("MD034".to_string()),
1366 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1367 severity: Severity::Warning,
1368 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1369 };
1370
1371 let uri = Url::parse("file:///test.md").unwrap();
1372 let document_text = "https://example.com is a test URL";
1373
1374 let actions = warning_to_code_actions(&warning, &uri, document_text);
1375
1376 assert_eq!(actions.len(), 3);
1378
1379 assert_eq!(
1381 actions[0].title,
1382 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1383 );
1384 assert_eq!(actions[0].is_preferred, Some(true));
1385
1386 assert_eq!(actions[1].title, "Convert to markdown link");
1388 assert_eq!(actions[1].is_preferred, Some(false));
1389
1390 let edit = actions[1].edit.as_ref().unwrap();
1392 let changes = edit.changes.as_ref().unwrap();
1393 let file_edits = changes.get(&uri).unwrap();
1394 assert_eq!(file_edits.len(), 1);
1395
1396 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1398
1399 assert_eq!(actions[2].title, "Ignore no-bare-urls (MD034) for this line");
1401 }
1402
1403 #[test]
1404 fn test_md034_convert_to_link_action_email() {
1405 let warning = LintWarning {
1407 line: 1,
1408 column: 1,
1409 end_line: 1,
1410 end_column: 20,
1411 rule_name: Some("MD034".to_string()),
1412 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1413 severity: Severity::Warning,
1414 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1415 };
1416
1417 let uri = Url::parse("file:///test.md").unwrap();
1418 let document_text = "user@example.com is my email";
1419
1420 let actions = warning_to_code_actions(&warning, &uri, document_text);
1421
1422 assert_eq!(actions.len(), 3);
1424
1425 assert_eq!(actions[1].title, "Convert to markdown link");
1427
1428 let edit = actions[1].edit.as_ref().unwrap();
1429 let changes = edit.changes.as_ref().unwrap();
1430 let file_edits = changes.get(&uri).unwrap();
1431
1432 assert_eq!(file_edits[0].new_text, "[user@example.com](mailto:user@example.com)");
1435 }
1436
1437 #[test]
1438 fn test_md034_convert_to_link_action_leaves_an_existing_scheme_alone() {
1439 let warning = LintWarning {
1442 line: 1,
1443 column: 1,
1444 end_line: 1,
1445 end_column: 26,
1446 rule_name: Some("MD034".to_string()),
1447 message: "URL without angle brackets or link formatting: 'xmpp:user@example.com'".to_string(),
1448 severity: Severity::Warning,
1449 fix: Some(Fix::new(0..21, "<xmpp:user@example.com>".to_string())),
1450 };
1451
1452 let uri = Url::parse("file:///test.md").unwrap();
1453 let document_text = "xmpp:user@example.com is a chat address";
1454
1455 let actions = warning_to_code_actions(&warning, &uri, document_text);
1456 let edit = actions[1].edit.as_ref().unwrap();
1457 let file_edits = edit.changes.as_ref().unwrap().get(&uri).unwrap();
1458
1459 assert_eq!(file_edits[0].new_text, "[xmpp:user@example.com](xmpp:user@example.com)");
1460 }
1461
1462 #[test]
1463 fn test_link_destination_writes_the_scheme_only_for_a_bare_email() {
1464 assert_eq!(link_destination("user@example.com"), "mailto:user@example.com");
1466 assert_eq!(
1467 link_destination("first.last+tag@sub.example.co.uk"),
1468 "mailto:first.last+tag@sub.example.co.uk"
1469 );
1470 assert_eq!(link_destination("xmpp:user@example.com"), "xmpp:user@example.com");
1471 assert_eq!(
1472 link_destination("https://user@example.com/path"),
1473 "https://user@example.com/path"
1474 );
1475 assert_eq!(link_destination("https://example.com"), "https://example.com");
1476 }
1477
1478 #[test]
1479 fn test_converted_email_link_round_trips_through_md034() {
1480 use crate::rule::Rule;
1482 let rule = crate::rules::MD034NoBareUrls;
1483 let ctx = crate::lint_context::LintContext::new(
1484 "Mail [user@example.com](mailto:user@example.com) now\n",
1485 crate::config::MarkdownFlavor::Standard,
1486 None,
1487 );
1488 assert!(rule.check(&ctx).unwrap().is_empty());
1489 }
1490
1491 #[test]
1492 fn test_extract_url_from_fix_replacement() {
1493 assert_eq!(
1494 extract_url_from_fix_replacement("<https://example.com>"),
1495 Some("https://example.com")
1496 );
1497 assert_eq!(
1498 extract_url_from_fix_replacement("<user@example.com>"),
1499 Some("user@example.com")
1500 );
1501 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1502 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1503
1504 assert_eq!(
1506 extract_url_from_fix_replacement("[https://example.com](https://example.com)"),
1507 None
1508 );
1509 }
1510
1511 #[test]
1516 fn test_md034_convert_to_link_action_absent_for_mdx_link_fix() {
1517 let warning = LintWarning {
1518 line: 1,
1519 column: 1,
1520 end_line: 1,
1521 end_column: 20,
1522 rule_name: Some("MD034".to_string()),
1523 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1524 fix: Some(Fix::new(
1525 0..19,
1526 "[https://example.com](https://example.com)".to_string(),
1527 )),
1528 severity: Severity::Warning,
1529 };
1530
1531 let uri = Url::parse("file:///test.mdx").unwrap();
1532 let actions = warning_to_code_actions(&warning, &uri, "https://example.com is the site");
1533
1534 assert!(
1535 !actions.iter().any(|a| a.title == "Convert to markdown link"),
1536 "the MDX fix is already a link, so no conversion action should be offered"
1537 );
1538 assert!(
1539 actions.iter().any(|a| a.title.starts_with("Fix")),
1540 "the primary fix action must still be offered"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_extract_domain_for_placeholder() {
1546 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1547 assert_eq!(
1548 extract_domain_for_placeholder("https://example.com/path/to/page"),
1549 "example.com"
1550 );
1551 assert_eq!(
1552 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1553 "sub.example.com:8080"
1554 );
1555 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1556 assert_eq!(
1557 extract_domain_for_placeholder("ftp://files.example.com"),
1558 "files.example.com"
1559 );
1560 }
1561}