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_map = crate::utils::NormalizedLineEndingMap::new(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: line_ending_map
365 .original_text_at(&reflow_result.reflowed_text, reflow_result.start_byte)
366 .into_owned(),
367 };
368
369 let mut changes = std::collections::HashMap::new();
370 changes.insert(uri.clone(), vec![edit]);
371
372 let workspace_edit = WorkspaceEdit {
373 changes: Some(changes),
374 document_changes: None,
375 change_annotations: None,
376 };
377
378 Some(CodeAction {
379 title: "Reflow paragraph".to_string(),
380 kind: Some(CodeActionKind::QUICKFIX),
381 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
382 edit: Some(workspace_edit),
383 command: None,
384 is_preferred: Some(false), disabled: None,
386 data: None,
387 })
388}
389
390fn extract_line_length_from_message(message: &str) -> Option<usize> {
393 let exceeds_idx = message.find("exceeds")?;
395 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
399
400 num_str.parse::<usize>().ok()
401}
402
403fn create_convert_to_link_action(
407 warning: &crate::rule::LintWarning,
408 uri: &Url,
409 document_text: &str,
410) -> Option<CodeAction> {
411 let fix = warning.fix.as_ref()?;
413
414 let url = extract_url_from_fix_replacement(&fix.replacement)?;
417
418 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
420
421 let link_text = extract_domain_for_placeholder(url);
426 let destination = link_destination(url);
427 let new_text = format!("[{link_text}]({destination})");
428
429 let edit = TextEdit { range, new_text };
430
431 let mut changes = std::collections::HashMap::new();
432 changes.insert(uri.clone(), vec![edit]);
433
434 let workspace_edit = WorkspaceEdit {
435 changes: Some(changes),
436 document_changes: None,
437 change_annotations: None,
438 };
439
440 Some(CodeAction {
441 title: "Convert to markdown link".to_string(),
442 kind: Some(CodeActionKind::QUICKFIX),
443 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
444 edit: Some(workspace_edit),
445 command: None,
446 is_preferred: Some(false), disabled: None,
448 data: None,
449 })
450}
451
452fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
461 let trimmed = replacement.trim();
463 if trimmed.starts_with('<') && trimmed.ends_with('>') {
464 Some(&trimmed[1..trimmed.len() - 1])
465 } else {
466 None
467 }
468}
469
470fn is_bare_email(url: &str) -> bool {
476 url.contains('@') && !url.contains(':')
477}
478
479fn link_destination(url: &str) -> std::borrow::Cow<'_, str> {
485 if is_bare_email(url) {
486 std::borrow::Cow::Owned(format!("mailto:{url}"))
487 } else {
488 std::borrow::Cow::Borrowed(url)
489 }
490}
491
492fn extract_domain_for_placeholder(url: &str) -> &str {
496 if is_bare_email(url) {
498 return url;
499 }
500
501 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
503}
504
505fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
507 let rule_id = warning.rule_name.as_ref()?;
508 let warning_line = warning.line.saturating_sub(1);
509
510 let lines: Vec<&str> = document_text.lines().collect();
512 let line_content = lines.get(warning_line)?;
513
514 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
516 return None;
518 }
519
520 let line_end = Position {
522 line: warning_line as u32,
523 character: utf16_len(line_content),
524 };
525
526 let rule_label = crate::config::primary_alias(rule_id).unwrap_or(rule_id.as_str());
530 let comment = format!(" <!-- rumdl-disable-line {rule_label} -->");
531
532 let edit = TextEdit {
533 range: Range {
534 start: line_end,
535 end: line_end,
536 },
537 new_text: comment,
538 };
539
540 let mut changes = std::collections::HashMap::new();
541 changes.insert(uri.clone(), vec![edit]);
542
543 let title = if rule_label == rule_id {
544 format!("Ignore {rule_id} for this line")
545 } else {
546 format!("Ignore {rule_label} ({rule_id}) for this line")
547 };
548
549 Some(CodeAction {
550 title,
551 kind: Some(CodeActionKind::QUICKFIX),
552 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
553 edit: Some(WorkspaceEdit {
554 changes: Some(changes),
555 document_changes: None,
556 change_annotations: None,
557 }),
558 command: None,
559 is_preferred: Some(false), disabled: None,
561 data: None,
562 })
563}
564
565#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
568pub fn warning_to_code_action(
569 warning: &crate::rule::LintWarning,
570 uri: &Url,
571 document_text: &str,
572) -> Option<CodeAction> {
573 warning_to_code_actions(warning, uri, document_text)
574 .into_iter()
575 .find(|action| action.is_preferred == Some(true))
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::rule::{Fix, LintWarning, Severity};
582
583 #[test]
584 fn test_rumdl_lsp_config_default() {
585 let config = RumdlLspConfig::default();
586 assert_eq!(config.config_path, None);
587 assert!(config.enable_linting);
588 assert!(!config.enable_auto_fix);
589 }
590
591 #[test]
592 fn test_rumdl_lsp_config_serialization() {
593 let config = RumdlLspConfig {
594 config_path: Some("/path/to/config.toml".to_string()),
595 enable_linting: false,
596 enable_auto_fix: true,
597 enable_rules: None,
598 disable_rules: None,
599 configuration_preference: ConfigurationPreference::EditorFirst,
600 settings: None,
601 enable_link_completions: true,
602 enable_link_navigation: true,
603 enable_symbols: true,
604 link_completion_content_roots: Vec::new(),
605 };
606
607 let json = serde_json::to_string(&config).unwrap();
609 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
610 assert!(json.contains("\"enableLinting\":false"));
611 assert!(json.contains("\"enableAutoFix\":true"));
612
613 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
615 assert_eq!(deserialized.config_path, config.config_path);
616 assert_eq!(deserialized.enable_linting, config.enable_linting);
617 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
618 }
619
620 #[test]
621 fn test_warning_to_diagnostic_basic() {
622 let warning = LintWarning {
623 line: 5,
624 column: 10,
625 end_line: 5,
626 end_column: 15,
627 rule_name: Some("MD001".to_string()),
628 message: "Test warning message".to_string(),
629 severity: Severity::Warning,
630 fix: None,
631 };
632
633 let diagnostic = warning_to_diagnostic(&warning, "one\ntwo\nthree\nfour\nfive: a longer line\n");
634
635 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
638 assert_eq!(diagnostic.range.end.character, 14);
639 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
640 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
641 assert_eq!(diagnostic.message, "Test warning message");
642 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
643 }
644
645 #[test]
646 fn test_warning_to_diagnostic_error_severity() {
647 let warning = LintWarning {
648 line: 1,
649 column: 1,
650 end_line: 1,
651 end_column: 5,
652 rule_name: Some("MD002".to_string()),
653 message: "Error message".to_string(),
654 severity: Severity::Error,
655 fix: None,
656 };
657
658 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
659 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
660 }
661
662 #[test]
663 fn test_warning_to_diagnostic_no_rule_name() {
664 let warning = LintWarning {
665 line: 1,
666 column: 1,
667 end_line: 1,
668 end_column: 5,
669 rule_name: None,
670 message: "Generic warning".to_string(),
671 severity: Severity::Warning,
672 fix: None,
673 };
674
675 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
676 assert_eq!(diagnostic.code, None);
677 assert!(diagnostic.code_description.is_none());
678 }
679
680 #[test]
681 fn test_warning_to_diagnostic_edge_cases() {
682 let warning = LintWarning {
684 line: 0,
685 column: 0,
686 end_line: 0,
687 end_column: 0,
688 rule_name: Some("MD001".to_string()),
689 message: "Edge case".to_string(),
690 severity: Severity::Warning,
691 fix: None,
692 };
693
694 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
695 assert_eq!(diagnostic.range.start.line, 0);
696 assert_eq!(diagnostic.range.start.character, 0);
697 }
698
699 #[test]
700 fn a_diagnostic_column_after_a_non_bmp_codepoint_counts_both_code_units() {
701 let text = "🎉 badword here\n";
705 let warning = LintWarning {
706 line: 1,
707 column: 3,
708 end_line: 1,
709 end_column: 10,
710 rule_name: Some("MD001".to_string()),
711 message: "Test".to_string(),
712 severity: Severity::Warning,
713 fix: None,
714 };
715
716 let diagnostic = warning_to_diagnostic(&warning, text);
717 assert_eq!(diagnostic.range.start.character, 3);
718 assert_eq!(diagnostic.range.end.character, 10);
719 }
720
721 #[test]
722 fn a_batch_of_diagnostics_places_each_column_on_its_own_line() {
723 let text = "🎉 first\nplain second\n";
724 let warning_of = |line: usize, column: usize| LintWarning {
725 line,
726 column,
727 end_line: line,
728 end_column: column + 1,
729 rule_name: Some("MD001".to_string()),
730 message: "Test".to_string(),
731 severity: Severity::Warning,
732 fix: None,
733 };
734
735 let diagnostics = warnings_to_diagnostics(&[warning_of(1, 3), warning_of(2, 3)], text);
736 assert_eq!(diagnostics[0].range.start.character, 3);
737 assert_eq!(diagnostics[1].range.start.character, 2);
738 }
739
740 #[test]
741 fn an_ignore_line_action_appends_after_the_last_code_unit_of_the_line() {
742 let text = "🎉 needs an ignore\n";
743 let warning = LintWarning {
744 line: 1,
745 column: 1,
746 end_line: 1,
747 end_column: 2,
748 rule_name: Some("MD001".to_string()),
749 message: "Test".to_string(),
750 severity: Severity::Warning,
751 fix: None,
752 };
753
754 let uri = Url::parse("file:///test.md").unwrap();
755 let action = warning_to_code_actions(&warning, &uri, text)
756 .into_iter()
757 .find(|action| action.title.contains("Ignore"))
758 .expect("an ignore-line action");
759 let edits = action.edit.unwrap().changes.unwrap().remove(&uri).unwrap();
760 assert_eq!(edits[0].range.start, Position { line: 0, character: 18 });
763 }
764
765 #[test]
766 fn test_warning_to_code_action_with_fix() {
767 let warning = LintWarning {
768 line: 1,
769 column: 1,
770 end_line: 1,
771 end_column: 5,
772 rule_name: Some("MD001".to_string()),
773 message: "Missing space".to_string(),
774 severity: Severity::Warning,
775 fix: Some(Fix::new(0..5, "Fixed".to_string())),
776 };
777
778 let uri = Url::parse("file:///test.md").unwrap();
779 let document_text = "Hello World";
780
781 let actions = warning_to_code_actions(&warning, &uri, document_text);
782 assert!(!actions.is_empty());
783 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
786 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
787 assert_eq!(action.is_preferred, Some(true));
788
789 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
790 let edits = &changes[&uri];
791 assert_eq!(edits.len(), 1);
792 assert_eq!(edits[0].new_text, "Fixed");
793 }
794
795 #[test]
796 fn test_warning_to_code_action_no_fix() {
797 let warning = LintWarning {
798 line: 1,
799 column: 1,
800 end_line: 1,
801 end_column: 5,
802 rule_name: Some("MD001".to_string()),
803 message: "No fix available".to_string(),
804 severity: Severity::Warning,
805 fix: None,
806 };
807
808 let uri = Url::parse("file:///test.md").unwrap();
809 let document_text = "Hello World";
810
811 let actions = warning_to_code_actions(&warning, &uri, document_text);
812 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
814 }
815
816 #[test]
817 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
818 let warning = LintWarning {
819 line: 2,
820 column: 1,
821 end_line: 2,
822 end_column: 100,
823 rule_name: Some("MD013".to_string()),
824 message: "Line length 95 exceeds 40 characters".to_string(),
825 severity: Severity::Warning,
826 fix: None,
827 };
828
829 let uri = Url::parse("file:///test.md").unwrap();
830 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";
831
832 let actions = warning_to_code_actions(&warning, &uri, document_text);
833 let reflow_action = actions
834 .iter()
835 .find(|action| action.title == "Reflow paragraph")
836 .expect("Expected manual reflow action for MD013");
837
838 let changes = reflow_action
839 .edit
840 .as_ref()
841 .and_then(|edit| edit.changes.as_ref())
842 .expect("Expected edits for reflow action");
843 let file_edits = changes.get(&uri).expect("Expected edits for URI");
844 assert_eq!(file_edits.len(), 1);
845 assert!(
846 file_edits[0]
847 .new_text
848 .lines()
849 .next()
850 .is_some_and(|line| line.starts_with("> ")),
851 "Expected blockquote prefix in reflow output"
852 );
853 }
854
855 #[test]
856 fn test_warning_to_code_action_multiline_fix() {
857 let warning = LintWarning {
858 line: 2,
859 column: 1,
860 end_line: 3,
861 end_column: 5,
862 rule_name: Some("MD001".to_string()),
863 message: "Multiline fix".to_string(),
864 severity: Severity::Warning,
865 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
866 };
867
868 let uri = Url::parse("file:///test.md").unwrap();
869 let document_text = "Hello\nWorld\nTest Line";
870
871 let actions = warning_to_code_actions(&warning, &uri, document_text);
872 assert!(!actions.is_empty());
873 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
876 let edits = &changes[&uri];
877 assert_eq!(edits[0].new_text, "Fixed\nContent");
878 assert_eq!(edits[0].range.start.line, 1);
879 assert_eq!(edits[0].range.start.character, 0);
880 }
881
882 #[test]
883 fn test_warning_to_code_action_atomic_with_additional_edits() {
884 let document_text = "See [docs](https://example.com) for details.\n";
890 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
891 let primary_end = document_text.find(" for details").unwrap();
892 let appended = "\n[docs]: https://example.com\n".to_string();
893
894 let warning = LintWarning {
895 line: 1,
896 column: primary_start + 1,
897 end_line: 1,
898 end_column: primary_end + 1,
899 rule_name: Some("MD054".to_string()),
900 message: "Inconsistent link style".to_string(),
901 severity: Severity::Warning,
902 fix: Some(Fix::with_additional_edits(
903 primary_start..primary_end,
904 "[docs]".to_string(),
905 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
906 )),
907 };
908
909 let uri = Url::parse("file:///test.md").unwrap();
910 let actions = warning_to_code_actions(&warning, &uri, document_text);
911
912 let fix_action = actions
913 .iter()
914 .find(|a| a.is_preferred == Some(true))
915 .expect("expected a preferred fix code action for MD054 ref-emit warning");
916 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
917
918 let edits = fix_action
919 .edit
920 .as_ref()
921 .and_then(|w| w.changes.as_ref())
922 .and_then(|c| c.get(&uri))
923 .expect("WorkspaceEdit should carry edits keyed by the document URI");
924
925 assert_eq!(
926 edits.len(),
927 2,
928 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
929 );
930 assert_eq!(edits[0].new_text, "[docs]");
931 assert_eq!(edits[1].new_text, appended);
932
933 assert_eq!(edits[1].range.start, edits[1].range.end);
935 }
936
937 #[test]
938 fn test_code_description_url_generation() {
939 let warning = LintWarning {
940 line: 1,
941 column: 1,
942 end_line: 1,
943 end_column: 5,
944 rule_name: Some("MD013".to_string()),
945 message: "Line too long".to_string(),
946 severity: Severity::Warning,
947 fix: None,
948 };
949
950 let diagnostic = warning_to_diagnostic(&warning, "Line too long\n");
951 assert!(diagnostic.code_description.is_some());
952
953 let url = diagnostic.code_description.unwrap().href;
954 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
955 }
956
957 #[test]
958 fn test_no_url_for_code_block_tool_warnings() {
959 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
962 let warning = LintWarning {
963 line: 1,
964 column: 1,
965 end_line: 1,
966 end_column: 10,
967 rule_name: Some(tool_name.to_string()),
968 message: "some tool warning".to_string(),
969 severity: Severity::Warning,
970 fix: None,
971 };
972
973 let diagnostic = warning_to_diagnostic(&warning, "some tool output\n");
974 assert!(
975 diagnostic.code_description.is_none(),
976 "Expected no URL for tool name '{tool_name}', but got one",
977 );
978 }
979 }
980
981 #[test]
982 fn test_lsp_config_partial_deserialization() {
983 let json = r#"{"enableLinting": false}"#;
985 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
986
987 assert!(!config.enable_linting);
988 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
991
992 #[test]
993 fn test_configuration_preference_serialization() {
994 let pref = ConfigurationPreference::EditorFirst;
996 let json = serde_json::to_string(&pref).unwrap();
997 assert_eq!(json, "\"editorFirst\"");
998
999 let pref = ConfigurationPreference::FilesystemFirst;
1001 let json = serde_json::to_string(&pref).unwrap();
1002 assert_eq!(json, "\"filesystemFirst\"");
1003
1004 let pref = ConfigurationPreference::EditorOnly;
1006 let json = serde_json::to_string(&pref).unwrap();
1007 assert_eq!(json, "\"editorOnly\"");
1008
1009 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
1011 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
1012 }
1013
1014 #[test]
1015 fn test_lsp_rule_settings_deserialization() {
1016 let json = r#"{
1018 "lineLength": 120,
1019 "disable": ["MD001", "MD002"],
1020 "enable": ["MD013"]
1021 }"#;
1022 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1023
1024 assert_eq!(settings.line_length, Some(120));
1025 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
1026 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
1027 }
1028
1029 #[test]
1030 fn test_lsp_rule_settings_with_per_rule_config() {
1031 let json = r#"{
1033 "lineLength": 80,
1034 "MD013": {
1035 "lineLength": 120,
1036 "codeBlocks": false
1037 },
1038 "MD024": {
1039 "siblingsOnly": true
1040 }
1041 }"#;
1042 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1043
1044 assert_eq!(settings.line_length, Some(80));
1045
1046 let md013 = settings.rules.get("MD013").unwrap();
1048 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1049 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1050
1051 let md024 = settings.rules.get("MD024").unwrap();
1053 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1054 }
1055
1056 #[test]
1057 fn test_full_lsp_config_with_settings() {
1058 let json = r#"{
1060 "configPath": "/path/to/config",
1061 "enableLinting": true,
1062 "enableAutoFix": false,
1063 "configurationPreference": "editorFirst",
1064 "settings": {
1065 "lineLength": 100,
1066 "disable": ["MD033"],
1067 "MD013": {
1068 "lineLength": 120,
1069 "tables": false
1070 }
1071 }
1072 }"#;
1073 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1074
1075 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1076 assert!(config.enable_linting);
1077 assert!(!config.enable_auto_fix);
1078 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1079
1080 let settings = config.settings.unwrap();
1081 assert_eq!(settings.line_length, Some(100));
1082 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1083
1084 let md013 = settings.rules.get("MD013").unwrap();
1085 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1086 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1087 }
1088
1089 #[test]
1090 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1091 let warning = LintWarning {
1092 line: 5,
1093 column: 1,
1094 end_line: 5,
1095 end_column: 50,
1096 rule_name: Some("MD013".to_string()),
1097 message: "Line too long".to_string(),
1098 severity: Severity::Warning,
1099 fix: None,
1100 };
1101
1102 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1103 let uri = Url::parse("file:///test.md").unwrap();
1104
1105 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1106
1107 assert_eq!(action.title, "Ignore line-length (MD013) for this line");
1108 assert_eq!(action.is_preferred, Some(false));
1109 assert!(action.edit.is_some());
1110
1111 let edit = action.edit.unwrap();
1113 let changes = edit.changes.unwrap();
1114 let file_edits = changes.get(&uri).unwrap();
1115
1116 assert_eq!(file_edits.len(), 1);
1117 assert_eq!(file_edits[0].new_text, " <!-- rumdl-disable-line line-length -->");
1118 assert!(!file_edits[0].new_text.contains("markdownlint"));
1119
1120 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1124
1125 fn apply_ignore_line_edit(action: &CodeAction, uri: &Url, document: &str) -> String {
1127 let edits = action
1128 .edit
1129 .as_ref()
1130 .unwrap()
1131 .changes
1132 .as_ref()
1133 .unwrap()
1134 .get(uri)
1135 .unwrap();
1136 assert_eq!(edits.len(), 1);
1137 let edit = &edits[0];
1138 let mut lines: Vec<String> = document.lines().map(str::to_string).collect();
1139 let line = &mut lines[edit.range.start.line as usize];
1140 line.push_str(&edit.new_text);
1141 lines.join("\n")
1142 }
1143
1144 #[test]
1145 fn an_ignore_line_comment_names_the_rule_in_a_form_the_linter_accepts() {
1146 let long_line = "word ".repeat(40);
1147 let document = format!("# Title\n\n{long_line}text\n");
1148 let uri = Url::parse("file:///test.md").unwrap();
1149 let rules = crate::rules::all_rules(&crate::config::Config::default());
1150
1151 let before = crate::lint(
1152 &document,
1153 &rules,
1154 false,
1155 crate::config::MarkdownFlavor::Standard,
1156 None,
1157 None,
1158 )
1159 .unwrap();
1160 let warning = before
1161 .iter()
1162 .find(|w| w.rule_name.as_deref() == Some("MD013"))
1163 .expect("control: the long line must be reported before the comment is added");
1164
1165 let action = create_ignore_line_action(warning, &uri, &document).unwrap();
1166 let disabled = apply_ignore_line_edit(&action, &uri, &document);
1167 assert!(
1168 disabled.contains("<!-- rumdl-disable-line line-length -->"),
1169 "the comment names the rule readably, got: {disabled}"
1170 );
1171
1172 let after = crate::lint(
1173 &disabled,
1174 &rules,
1175 false,
1176 crate::config::MarkdownFlavor::Standard,
1177 None,
1178 None,
1179 )
1180 .unwrap();
1181 assert!(
1182 !after.iter().any(|w| w.rule_name.as_deref() == Some("MD013")),
1183 "the readable name must suppress the rule it names, got: {after:?}"
1184 );
1185 }
1186
1187 #[test]
1188 fn an_ignore_line_comment_falls_back_to_the_id_for_a_rule_with_no_readable_name() {
1189 let warning = LintWarning {
1190 line: 1,
1191 column: 1,
1192 end_line: 1,
1193 end_column: 2,
1194 rule_name: Some("MD999".to_string()),
1195 message: "From a rule the registry does not know".to_string(),
1196 severity: Severity::Warning,
1197 fix: None,
1198 };
1199 let uri = Url::parse("file:///test.md").unwrap();
1200
1201 let action = create_ignore_line_action(&warning, &uri, "text").unwrap();
1202 assert_eq!(action.title, "Ignore MD999 for this line");
1203 let edit = action.edit.unwrap();
1204 let file_edits = edit.changes.unwrap();
1205 assert_eq!(
1206 file_edits.get(&uri).unwrap()[0].new_text,
1207 " <!-- rumdl-disable-line MD999 -->"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_create_ignore_line_action_no_duplicate() {
1213 let warning = LintWarning {
1214 line: 1,
1215 column: 1,
1216 end_line: 1,
1217 end_column: 50,
1218 rule_name: Some("MD013".to_string()),
1219 message: "Line too long".to_string(),
1220 severity: Severity::Warning,
1221 fix: None,
1222 };
1223
1224 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1226 let uri = Url::parse("file:///test.md").unwrap();
1227
1228 let action = create_ignore_line_action(&warning, &uri, document);
1229
1230 assert!(action.is_none());
1232 }
1233
1234 #[test]
1235 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1236 let warning = LintWarning {
1237 line: 1,
1238 column: 1,
1239 end_line: 1,
1240 end_column: 50,
1241 rule_name: Some("MD013".to_string()),
1242 message: "Line too long".to_string(),
1243 severity: Severity::Warning,
1244 fix: None,
1245 };
1246
1247 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1249 let uri = Url::parse("file:///test.md").unwrap();
1250
1251 let action = create_ignore_line_action(&warning, &uri, document);
1252
1253 assert!(action.is_none());
1255 }
1256
1257 #[test]
1258 fn test_warning_to_code_actions_with_fix() {
1259 let warning = LintWarning {
1260 line: 1,
1261 column: 1,
1262 end_line: 1,
1263 end_column: 5,
1264 rule_name: Some("MD009".to_string()),
1265 message: "Trailing spaces".to_string(),
1266 severity: Severity::Warning,
1267 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1268 };
1269
1270 let uri = Url::parse("file:///test.md").unwrap();
1271 let document_text = "Hello \nWorld";
1272
1273 let actions = warning_to_code_actions(&warning, &uri, document_text);
1274
1275 assert_eq!(actions.len(), 2);
1277
1278 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1280 assert_eq!(actions[0].is_preferred, Some(true));
1281
1282 assert_eq!(actions[1].title, "Ignore no-trailing-spaces (MD009) for this line");
1284 assert_eq!(actions[1].is_preferred, Some(false));
1285 }
1286
1287 #[test]
1288 fn test_warning_to_code_actions_no_fix() {
1289 let warning = LintWarning {
1290 line: 1,
1291 column: 1,
1292 end_line: 1,
1293 end_column: 10,
1294 rule_name: Some("MD033".to_string()),
1295 message: "Inline HTML".to_string(),
1296 severity: Severity::Warning,
1297 fix: None,
1298 };
1299
1300 let uri = Url::parse("file:///test.md").unwrap();
1301 let document_text = "<div>HTML</div>";
1302
1303 let actions = warning_to_code_actions(&warning, &uri, document_text);
1304
1305 assert_eq!(actions.len(), 1);
1307 assert_eq!(actions[0].title, "Ignore no-inline-html (MD033) for this line");
1308 assert_eq!(actions[0].is_preferred, Some(false));
1309 }
1310
1311 #[test]
1312 fn test_warning_to_code_actions_no_rule_name() {
1313 let warning = LintWarning {
1314 line: 1,
1315 column: 1,
1316 end_line: 1,
1317 end_column: 5,
1318 rule_name: None,
1319 message: "Generic warning".to_string(),
1320 severity: Severity::Warning,
1321 fix: None,
1322 };
1323
1324 let uri = Url::parse("file:///test.md").unwrap();
1325 let document_text = "Hello World";
1326
1327 let actions = warning_to_code_actions(&warning, &uri, document_text);
1328
1329 assert_eq!(actions.len(), 0);
1331 }
1332
1333 #[test]
1334 fn test_legacy_warning_to_code_action_compatibility() {
1335 let warning = LintWarning {
1336 line: 1,
1337 column: 1,
1338 end_line: 1,
1339 end_column: 5,
1340 rule_name: Some("MD001".to_string()),
1341 message: "Test".to_string(),
1342 severity: Severity::Warning,
1343 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1344 };
1345
1346 let uri = Url::parse("file:///test.md").unwrap();
1347 let document_text = "Hello World";
1348
1349 #[allow(deprecated)]
1350 let action = warning_to_code_action(&warning, &uri, document_text);
1351
1352 assert!(action.is_some());
1354 let action = action.unwrap();
1355 assert_eq!(action.title, "Fix: Test");
1356 assert_eq!(action.is_preferred, Some(true));
1357 }
1358
1359 #[test]
1360 fn test_md034_convert_to_link_action() {
1361 let warning = LintWarning {
1363 line: 1,
1364 column: 1,
1365 end_line: 1,
1366 end_column: 25,
1367 rule_name: Some("MD034".to_string()),
1368 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1369 severity: Severity::Warning,
1370 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1371 };
1372
1373 let uri = Url::parse("file:///test.md").unwrap();
1374 let document_text = "https://example.com is a test URL";
1375
1376 let actions = warning_to_code_actions(&warning, &uri, document_text);
1377
1378 assert_eq!(actions.len(), 3);
1380
1381 assert_eq!(
1383 actions[0].title,
1384 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1385 );
1386 assert_eq!(actions[0].is_preferred, Some(true));
1387
1388 assert_eq!(actions[1].title, "Convert to markdown link");
1390 assert_eq!(actions[1].is_preferred, Some(false));
1391
1392 let edit = actions[1].edit.as_ref().unwrap();
1394 let changes = edit.changes.as_ref().unwrap();
1395 let file_edits = changes.get(&uri).unwrap();
1396 assert_eq!(file_edits.len(), 1);
1397
1398 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1400
1401 assert_eq!(actions[2].title, "Ignore no-bare-urls (MD034) for this line");
1403 }
1404
1405 #[test]
1406 fn test_md034_convert_to_link_action_email() {
1407 let warning = LintWarning {
1409 line: 1,
1410 column: 1,
1411 end_line: 1,
1412 end_column: 20,
1413 rule_name: Some("MD034".to_string()),
1414 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1415 severity: Severity::Warning,
1416 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1417 };
1418
1419 let uri = Url::parse("file:///test.md").unwrap();
1420 let document_text = "user@example.com is my email";
1421
1422 let actions = warning_to_code_actions(&warning, &uri, document_text);
1423
1424 assert_eq!(actions.len(), 3);
1426
1427 assert_eq!(actions[1].title, "Convert to markdown link");
1429
1430 let edit = actions[1].edit.as_ref().unwrap();
1431 let changes = edit.changes.as_ref().unwrap();
1432 let file_edits = changes.get(&uri).unwrap();
1433
1434 assert_eq!(file_edits[0].new_text, "[user@example.com](mailto:user@example.com)");
1437 }
1438
1439 #[test]
1440 fn test_md034_convert_to_link_action_leaves_an_existing_scheme_alone() {
1441 let warning = LintWarning {
1444 line: 1,
1445 column: 1,
1446 end_line: 1,
1447 end_column: 26,
1448 rule_name: Some("MD034".to_string()),
1449 message: "URL without angle brackets or link formatting: 'xmpp:user@example.com'".to_string(),
1450 severity: Severity::Warning,
1451 fix: Some(Fix::new(0..21, "<xmpp:user@example.com>".to_string())),
1452 };
1453
1454 let uri = Url::parse("file:///test.md").unwrap();
1455 let document_text = "xmpp:user@example.com is a chat address";
1456
1457 let actions = warning_to_code_actions(&warning, &uri, document_text);
1458 let edit = actions[1].edit.as_ref().unwrap();
1459 let file_edits = edit.changes.as_ref().unwrap().get(&uri).unwrap();
1460
1461 assert_eq!(file_edits[0].new_text, "[xmpp:user@example.com](xmpp:user@example.com)");
1462 }
1463
1464 #[test]
1465 fn test_link_destination_writes_the_scheme_only_for_a_bare_email() {
1466 assert_eq!(link_destination("user@example.com"), "mailto:user@example.com");
1468 assert_eq!(
1469 link_destination("first.last+tag@sub.example.co.uk"),
1470 "mailto:first.last+tag@sub.example.co.uk"
1471 );
1472 assert_eq!(link_destination("xmpp:user@example.com"), "xmpp:user@example.com");
1473 assert_eq!(
1474 link_destination("https://user@example.com/path"),
1475 "https://user@example.com/path"
1476 );
1477 assert_eq!(link_destination("https://example.com"), "https://example.com");
1478 }
1479
1480 #[test]
1481 fn test_converted_email_link_round_trips_through_md034() {
1482 use crate::rule::Rule;
1484 let rule = crate::rules::MD034NoBareUrls;
1485 let ctx = crate::lint_context::LintContext::new(
1486 "Mail [user@example.com](mailto:user@example.com) now\n",
1487 crate::config::MarkdownFlavor::Standard,
1488 None,
1489 );
1490 assert!(rule.check(&ctx).unwrap().is_empty());
1491 }
1492
1493 #[test]
1494 fn test_extract_url_from_fix_replacement() {
1495 assert_eq!(
1496 extract_url_from_fix_replacement("<https://example.com>"),
1497 Some("https://example.com")
1498 );
1499 assert_eq!(
1500 extract_url_from_fix_replacement("<user@example.com>"),
1501 Some("user@example.com")
1502 );
1503 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1504 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1505
1506 assert_eq!(
1508 extract_url_from_fix_replacement("[https://example.com](https://example.com)"),
1509 None
1510 );
1511 }
1512
1513 #[test]
1518 fn test_md034_convert_to_link_action_absent_for_mdx_link_fix() {
1519 let warning = LintWarning {
1520 line: 1,
1521 column: 1,
1522 end_line: 1,
1523 end_column: 20,
1524 rule_name: Some("MD034".to_string()),
1525 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1526 fix: Some(Fix::new(
1527 0..19,
1528 "[https://example.com](https://example.com)".to_string(),
1529 )),
1530 severity: Severity::Warning,
1531 };
1532
1533 let uri = Url::parse("file:///test.mdx").unwrap();
1534 let actions = warning_to_code_actions(&warning, &uri, "https://example.com is the site");
1535
1536 assert!(
1537 !actions.iter().any(|a| a.title == "Convert to markdown link"),
1538 "the MDX fix is already a link, so no conversion action should be offered"
1539 );
1540 assert!(
1541 actions.iter().any(|a| a.title.starts_with("Fix")),
1542 "the primary fix action must still be offered"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_extract_domain_for_placeholder() {
1548 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1549 assert_eq!(
1550 extract_domain_for_placeholder("https://example.com/path/to/page"),
1551 "example.com"
1552 );
1553 assert_eq!(
1554 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1555 "sub.example.com:8080"
1556 );
1557 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1558 assert_eq!(
1559 extract_domain_for_placeholder("ftp://files.example.com"),
1560 "files.example.com"
1561 );
1562 }
1563}