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 reflow_result =
351 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
352
353 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
355
356 let edit = TextEdit {
357 range,
358 new_text: reflow_result.reflowed_text,
359 };
360
361 let mut changes = std::collections::HashMap::new();
362 changes.insert(uri.clone(), vec![edit]);
363
364 let workspace_edit = WorkspaceEdit {
365 changes: Some(changes),
366 document_changes: None,
367 change_annotations: None,
368 };
369
370 Some(CodeAction {
371 title: "Reflow paragraph".to_string(),
372 kind: Some(CodeActionKind::QUICKFIX),
373 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
374 edit: Some(workspace_edit),
375 command: None,
376 is_preferred: Some(false), disabled: None,
378 data: None,
379 })
380}
381
382fn extract_line_length_from_message(message: &str) -> Option<usize> {
385 let exceeds_idx = message.find("exceeds")?;
387 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
391
392 num_str.parse::<usize>().ok()
393}
394
395fn create_convert_to_link_action(
399 warning: &crate::rule::LintWarning,
400 uri: &Url,
401 document_text: &str,
402) -> Option<CodeAction> {
403 let fix = warning.fix.as_ref()?;
405
406 let url = extract_url_from_fix_replacement(&fix.replacement)?;
409
410 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
412
413 let link_text = extract_domain_for_placeholder(url);
418 let new_text = format!("[{link_text}]({url})");
419
420 let edit = TextEdit { range, new_text };
421
422 let mut changes = std::collections::HashMap::new();
423 changes.insert(uri.clone(), vec![edit]);
424
425 let workspace_edit = WorkspaceEdit {
426 changes: Some(changes),
427 document_changes: None,
428 change_annotations: None,
429 };
430
431 Some(CodeAction {
432 title: "Convert to markdown link".to_string(),
433 kind: Some(CodeActionKind::QUICKFIX),
434 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
435 edit: Some(workspace_edit),
436 command: None,
437 is_preferred: Some(false), disabled: None,
439 data: None,
440 })
441}
442
443fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
446 let trimmed = replacement.trim();
448 if trimmed.starts_with('<') && trimmed.ends_with('>') {
449 Some(&trimmed[1..trimmed.len() - 1])
450 } else {
451 None
452 }
453}
454
455fn extract_domain_for_placeholder(url: &str) -> &str {
459 if url.contains('@') && !url.contains("://") {
461 return url;
462 }
463
464 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
466}
467
468fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
470 let rule_id = warning.rule_name.as_ref()?;
471 let warning_line = warning.line.saturating_sub(1);
472
473 let lines: Vec<&str> = document_text.lines().collect();
475 let line_content = lines.get(warning_line)?;
476
477 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
479 return None;
481 }
482
483 let line_end = Position {
485 line: warning_line as u32,
486 character: utf16_len(line_content),
487 };
488
489 let rule_label = crate::config::primary_alias(rule_id).unwrap_or(rule_id.as_str());
493 let comment = format!(" <!-- rumdl-disable-line {rule_label} -->");
494
495 let edit = TextEdit {
496 range: Range {
497 start: line_end,
498 end: line_end,
499 },
500 new_text: comment,
501 };
502
503 let mut changes = std::collections::HashMap::new();
504 changes.insert(uri.clone(), vec![edit]);
505
506 let title = if rule_label == rule_id {
507 format!("Ignore {rule_id} for this line")
508 } else {
509 format!("Ignore {rule_label} ({rule_id}) for this line")
510 };
511
512 Some(CodeAction {
513 title,
514 kind: Some(CodeActionKind::QUICKFIX),
515 diagnostics: Some(vec![warning_to_diagnostic(warning, document_text)]),
516 edit: Some(WorkspaceEdit {
517 changes: Some(changes),
518 document_changes: None,
519 change_annotations: None,
520 }),
521 command: None,
522 is_preferred: Some(false), disabled: None,
524 data: None,
525 })
526}
527
528#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
531pub fn warning_to_code_action(
532 warning: &crate::rule::LintWarning,
533 uri: &Url,
534 document_text: &str,
535) -> Option<CodeAction> {
536 warning_to_code_actions(warning, uri, document_text)
537 .into_iter()
538 .find(|action| action.is_preferred == Some(true))
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::rule::{Fix, LintWarning, Severity};
545
546 #[test]
547 fn test_rumdl_lsp_config_default() {
548 let config = RumdlLspConfig::default();
549 assert_eq!(config.config_path, None);
550 assert!(config.enable_linting);
551 assert!(!config.enable_auto_fix);
552 }
553
554 #[test]
555 fn test_rumdl_lsp_config_serialization() {
556 let config = RumdlLspConfig {
557 config_path: Some("/path/to/config.toml".to_string()),
558 enable_linting: false,
559 enable_auto_fix: true,
560 enable_rules: None,
561 disable_rules: None,
562 configuration_preference: ConfigurationPreference::EditorFirst,
563 settings: None,
564 enable_link_completions: true,
565 enable_link_navigation: true,
566 enable_symbols: true,
567 link_completion_content_roots: Vec::new(),
568 };
569
570 let json = serde_json::to_string(&config).unwrap();
572 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
573 assert!(json.contains("\"enableLinting\":false"));
574 assert!(json.contains("\"enableAutoFix\":true"));
575
576 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
578 assert_eq!(deserialized.config_path, config.config_path);
579 assert_eq!(deserialized.enable_linting, config.enable_linting);
580 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
581 }
582
583 #[test]
584 fn test_warning_to_diagnostic_basic() {
585 let warning = LintWarning {
586 line: 5,
587 column: 10,
588 end_line: 5,
589 end_column: 15,
590 rule_name: Some("MD001".to_string()),
591 message: "Test warning message".to_string(),
592 severity: Severity::Warning,
593 fix: None,
594 };
595
596 let diagnostic = warning_to_diagnostic(&warning, "one\ntwo\nthree\nfour\nfive: a longer line\n");
597
598 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
601 assert_eq!(diagnostic.range.end.character, 14);
602 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
603 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
604 assert_eq!(diagnostic.message, "Test warning message");
605 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
606 }
607
608 #[test]
609 fn test_warning_to_diagnostic_error_severity() {
610 let warning = LintWarning {
611 line: 1,
612 column: 1,
613 end_line: 1,
614 end_column: 5,
615 rule_name: Some("MD002".to_string()),
616 message: "Error message".to_string(),
617 severity: Severity::Error,
618 fix: None,
619 };
620
621 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
622 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
623 }
624
625 #[test]
626 fn test_warning_to_diagnostic_no_rule_name() {
627 let warning = LintWarning {
628 line: 1,
629 column: 1,
630 end_line: 1,
631 end_column: 5,
632 rule_name: None,
633 message: "Generic warning".to_string(),
634 severity: Severity::Warning,
635 fix: None,
636 };
637
638 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
639 assert_eq!(diagnostic.code, None);
640 assert!(diagnostic.code_description.is_none());
641 }
642
643 #[test]
644 fn test_warning_to_diagnostic_edge_cases() {
645 let warning = LintWarning {
647 line: 0,
648 column: 0,
649 end_line: 0,
650 end_column: 0,
651 rule_name: Some("MD001".to_string()),
652 message: "Edge case".to_string(),
653 severity: Severity::Warning,
654 fix: None,
655 };
656
657 let diagnostic = warning_to_diagnostic(&warning, "a line of text\n");
658 assert_eq!(diagnostic.range.start.line, 0);
659 assert_eq!(diagnostic.range.start.character, 0);
660 }
661
662 #[test]
663 fn a_diagnostic_column_after_a_non_bmp_codepoint_counts_both_code_units() {
664 let text = "🎉 badword here\n";
668 let warning = LintWarning {
669 line: 1,
670 column: 3,
671 end_line: 1,
672 end_column: 10,
673 rule_name: Some("MD001".to_string()),
674 message: "Test".to_string(),
675 severity: Severity::Warning,
676 fix: None,
677 };
678
679 let diagnostic = warning_to_diagnostic(&warning, text);
680 assert_eq!(diagnostic.range.start.character, 3);
681 assert_eq!(diagnostic.range.end.character, 10);
682 }
683
684 #[test]
685 fn a_batch_of_diagnostics_places_each_column_on_its_own_line() {
686 let text = "🎉 first\nplain second\n";
687 let warning_of = |line: usize, column: usize| LintWarning {
688 line,
689 column,
690 end_line: line,
691 end_column: column + 1,
692 rule_name: Some("MD001".to_string()),
693 message: "Test".to_string(),
694 severity: Severity::Warning,
695 fix: None,
696 };
697
698 let diagnostics = warnings_to_diagnostics(&[warning_of(1, 3), warning_of(2, 3)], text);
699 assert_eq!(diagnostics[0].range.start.character, 3);
700 assert_eq!(diagnostics[1].range.start.character, 2);
701 }
702
703 #[test]
704 fn an_ignore_line_action_appends_after_the_last_code_unit_of_the_line() {
705 let text = "🎉 needs an ignore\n";
706 let warning = LintWarning {
707 line: 1,
708 column: 1,
709 end_line: 1,
710 end_column: 2,
711 rule_name: Some("MD001".to_string()),
712 message: "Test".to_string(),
713 severity: Severity::Warning,
714 fix: None,
715 };
716
717 let uri = Url::parse("file:///test.md").unwrap();
718 let action = warning_to_code_actions(&warning, &uri, text)
719 .into_iter()
720 .find(|action| action.title.contains("Ignore"))
721 .expect("an ignore-line action");
722 let edits = action.edit.unwrap().changes.unwrap().remove(&uri).unwrap();
723 assert_eq!(edits[0].range.start, Position { line: 0, character: 18 });
726 }
727
728 #[test]
729 fn test_warning_to_code_action_with_fix() {
730 let warning = LintWarning {
731 line: 1,
732 column: 1,
733 end_line: 1,
734 end_column: 5,
735 rule_name: Some("MD001".to_string()),
736 message: "Missing space".to_string(),
737 severity: Severity::Warning,
738 fix: Some(Fix::new(0..5, "Fixed".to_string())),
739 };
740
741 let uri = Url::parse("file:///test.md").unwrap();
742 let document_text = "Hello World";
743
744 let actions = warning_to_code_actions(&warning, &uri, document_text);
745 assert!(!actions.is_empty());
746 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
749 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
750 assert_eq!(action.is_preferred, Some(true));
751
752 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
753 let edits = &changes[&uri];
754 assert_eq!(edits.len(), 1);
755 assert_eq!(edits[0].new_text, "Fixed");
756 }
757
758 #[test]
759 fn test_warning_to_code_action_no_fix() {
760 let warning = LintWarning {
761 line: 1,
762 column: 1,
763 end_line: 1,
764 end_column: 5,
765 rule_name: Some("MD001".to_string()),
766 message: "No fix available".to_string(),
767 severity: Severity::Warning,
768 fix: None,
769 };
770
771 let uri = Url::parse("file:///test.md").unwrap();
772 let document_text = "Hello World";
773
774 let actions = warning_to_code_actions(&warning, &uri, document_text);
775 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
777 }
778
779 #[test]
780 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
781 let warning = LintWarning {
782 line: 2,
783 column: 1,
784 end_line: 2,
785 end_column: 100,
786 rule_name: Some("MD013".to_string()),
787 message: "Line length 95 exceeds 40 characters".to_string(),
788 severity: Severity::Warning,
789 fix: None,
790 };
791
792 let uri = Url::parse("file:///test.md").unwrap();
793 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";
794
795 let actions = warning_to_code_actions(&warning, &uri, document_text);
796 let reflow_action = actions
797 .iter()
798 .find(|action| action.title == "Reflow paragraph")
799 .expect("Expected manual reflow action for MD013");
800
801 let changes = reflow_action
802 .edit
803 .as_ref()
804 .and_then(|edit| edit.changes.as_ref())
805 .expect("Expected edits for reflow action");
806 let file_edits = changes.get(&uri).expect("Expected edits for URI");
807 assert_eq!(file_edits.len(), 1);
808 assert!(
809 file_edits[0]
810 .new_text
811 .lines()
812 .next()
813 .is_some_and(|line| line.starts_with("> ")),
814 "Expected blockquote prefix in reflow output"
815 );
816 }
817
818 #[test]
819 fn test_warning_to_code_action_multiline_fix() {
820 let warning = LintWarning {
821 line: 2,
822 column: 1,
823 end_line: 3,
824 end_column: 5,
825 rule_name: Some("MD001".to_string()),
826 message: "Multiline fix".to_string(),
827 severity: Severity::Warning,
828 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
829 };
830
831 let uri = Url::parse("file:///test.md").unwrap();
832 let document_text = "Hello\nWorld\nTest Line";
833
834 let actions = warning_to_code_actions(&warning, &uri, document_text);
835 assert!(!actions.is_empty());
836 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
839 let edits = &changes[&uri];
840 assert_eq!(edits[0].new_text, "Fixed\nContent");
841 assert_eq!(edits[0].range.start.line, 1);
842 assert_eq!(edits[0].range.start.character, 0);
843 }
844
845 #[test]
846 fn test_warning_to_code_action_atomic_with_additional_edits() {
847 let document_text = "See [docs](https://example.com) for details.\n";
853 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
854 let primary_end = document_text.find(" for details").unwrap();
855 let appended = "\n[docs]: https://example.com\n".to_string();
856
857 let warning = LintWarning {
858 line: 1,
859 column: primary_start + 1,
860 end_line: 1,
861 end_column: primary_end + 1,
862 rule_name: Some("MD054".to_string()),
863 message: "Inconsistent link style".to_string(),
864 severity: Severity::Warning,
865 fix: Some(Fix::with_additional_edits(
866 primary_start..primary_end,
867 "[docs]".to_string(),
868 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
869 )),
870 };
871
872 let uri = Url::parse("file:///test.md").unwrap();
873 let actions = warning_to_code_actions(&warning, &uri, document_text);
874
875 let fix_action = actions
876 .iter()
877 .find(|a| a.is_preferred == Some(true))
878 .expect("expected a preferred fix code action for MD054 ref-emit warning");
879 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
880
881 let edits = fix_action
882 .edit
883 .as_ref()
884 .and_then(|w| w.changes.as_ref())
885 .and_then(|c| c.get(&uri))
886 .expect("WorkspaceEdit should carry edits keyed by the document URI");
887
888 assert_eq!(
889 edits.len(),
890 2,
891 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
892 );
893 assert_eq!(edits[0].new_text, "[docs]");
894 assert_eq!(edits[1].new_text, appended);
895
896 assert_eq!(edits[1].range.start, edits[1].range.end);
898 }
899
900 #[test]
901 fn test_code_description_url_generation() {
902 let warning = LintWarning {
903 line: 1,
904 column: 1,
905 end_line: 1,
906 end_column: 5,
907 rule_name: Some("MD013".to_string()),
908 message: "Line too long".to_string(),
909 severity: Severity::Warning,
910 fix: None,
911 };
912
913 let diagnostic = warning_to_diagnostic(&warning, "Line too long\n");
914 assert!(diagnostic.code_description.is_some());
915
916 let url = diagnostic.code_description.unwrap().href;
917 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
918 }
919
920 #[test]
921 fn test_no_url_for_code_block_tool_warnings() {
922 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
925 let warning = LintWarning {
926 line: 1,
927 column: 1,
928 end_line: 1,
929 end_column: 10,
930 rule_name: Some(tool_name.to_string()),
931 message: "some tool warning".to_string(),
932 severity: Severity::Warning,
933 fix: None,
934 };
935
936 let diagnostic = warning_to_diagnostic(&warning, "some tool output\n");
937 assert!(
938 diagnostic.code_description.is_none(),
939 "Expected no URL for tool name '{tool_name}', but got one",
940 );
941 }
942 }
943
944 #[test]
945 fn test_lsp_config_partial_deserialization() {
946 let json = r#"{"enableLinting": false}"#;
948 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
949
950 assert!(!config.enable_linting);
951 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
954
955 #[test]
956 fn test_configuration_preference_serialization() {
957 let pref = ConfigurationPreference::EditorFirst;
959 let json = serde_json::to_string(&pref).unwrap();
960 assert_eq!(json, "\"editorFirst\"");
961
962 let pref = ConfigurationPreference::FilesystemFirst;
964 let json = serde_json::to_string(&pref).unwrap();
965 assert_eq!(json, "\"filesystemFirst\"");
966
967 let pref = ConfigurationPreference::EditorOnly;
969 let json = serde_json::to_string(&pref).unwrap();
970 assert_eq!(json, "\"editorOnly\"");
971
972 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
974 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
975 }
976
977 #[test]
978 fn test_lsp_rule_settings_deserialization() {
979 let json = r#"{
981 "lineLength": 120,
982 "disable": ["MD001", "MD002"],
983 "enable": ["MD013"]
984 }"#;
985 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
986
987 assert_eq!(settings.line_length, Some(120));
988 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
989 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
990 }
991
992 #[test]
993 fn test_lsp_rule_settings_with_per_rule_config() {
994 let json = r#"{
996 "lineLength": 80,
997 "MD013": {
998 "lineLength": 120,
999 "codeBlocks": false
1000 },
1001 "MD024": {
1002 "siblingsOnly": true
1003 }
1004 }"#;
1005 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1006
1007 assert_eq!(settings.line_length, Some(80));
1008
1009 let md013 = settings.rules.get("MD013").unwrap();
1011 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1012 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1013
1014 let md024 = settings.rules.get("MD024").unwrap();
1016 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1017 }
1018
1019 #[test]
1020 fn test_full_lsp_config_with_settings() {
1021 let json = r#"{
1023 "configPath": "/path/to/config",
1024 "enableLinting": true,
1025 "enableAutoFix": false,
1026 "configurationPreference": "editorFirst",
1027 "settings": {
1028 "lineLength": 100,
1029 "disable": ["MD033"],
1030 "MD013": {
1031 "lineLength": 120,
1032 "tables": false
1033 }
1034 }
1035 }"#;
1036 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1037
1038 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1039 assert!(config.enable_linting);
1040 assert!(!config.enable_auto_fix);
1041 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1042
1043 let settings = config.settings.unwrap();
1044 assert_eq!(settings.line_length, Some(100));
1045 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1046
1047 let md013 = settings.rules.get("MD013").unwrap();
1048 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1049 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1050 }
1051
1052 #[test]
1053 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1054 let warning = LintWarning {
1055 line: 5,
1056 column: 1,
1057 end_line: 5,
1058 end_column: 50,
1059 rule_name: Some("MD013".to_string()),
1060 message: "Line too long".to_string(),
1061 severity: Severity::Warning,
1062 fix: None,
1063 };
1064
1065 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1066 let uri = Url::parse("file:///test.md").unwrap();
1067
1068 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1069
1070 assert_eq!(action.title, "Ignore line-length (MD013) for this line");
1071 assert_eq!(action.is_preferred, Some(false));
1072 assert!(action.edit.is_some());
1073
1074 let edit = action.edit.unwrap();
1076 let changes = edit.changes.unwrap();
1077 let file_edits = changes.get(&uri).unwrap();
1078
1079 assert_eq!(file_edits.len(), 1);
1080 assert_eq!(file_edits[0].new_text, " <!-- rumdl-disable-line line-length -->");
1081 assert!(!file_edits[0].new_text.contains("markdownlint"));
1082
1083 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1087
1088 fn apply_ignore_line_edit(action: &CodeAction, uri: &Url, document: &str) -> String {
1090 let edits = action
1091 .edit
1092 .as_ref()
1093 .unwrap()
1094 .changes
1095 .as_ref()
1096 .unwrap()
1097 .get(uri)
1098 .unwrap();
1099 assert_eq!(edits.len(), 1);
1100 let edit = &edits[0];
1101 let mut lines: Vec<String> = document.lines().map(str::to_string).collect();
1102 let line = &mut lines[edit.range.start.line as usize];
1103 line.push_str(&edit.new_text);
1104 lines.join("\n")
1105 }
1106
1107 #[test]
1108 fn an_ignore_line_comment_names_the_rule_in_a_form_the_linter_accepts() {
1109 let long_line = "word ".repeat(40);
1110 let document = format!("# Title\n\n{long_line}text\n");
1111 let uri = Url::parse("file:///test.md").unwrap();
1112 let rules = crate::rules::all_rules(&crate::config::Config::default());
1113
1114 let before = crate::lint(
1115 &document,
1116 &rules,
1117 false,
1118 crate::config::MarkdownFlavor::Standard,
1119 None,
1120 None,
1121 )
1122 .unwrap();
1123 let warning = before
1124 .iter()
1125 .find(|w| w.rule_name.as_deref() == Some("MD013"))
1126 .expect("control: the long line must be reported before the comment is added");
1127
1128 let action = create_ignore_line_action(warning, &uri, &document).unwrap();
1129 let disabled = apply_ignore_line_edit(&action, &uri, &document);
1130 assert!(
1131 disabled.contains("<!-- rumdl-disable-line line-length -->"),
1132 "the comment names the rule readably, got: {disabled}"
1133 );
1134
1135 let after = crate::lint(
1136 &disabled,
1137 &rules,
1138 false,
1139 crate::config::MarkdownFlavor::Standard,
1140 None,
1141 None,
1142 )
1143 .unwrap();
1144 assert!(
1145 !after.iter().any(|w| w.rule_name.as_deref() == Some("MD013")),
1146 "the readable name must suppress the rule it names, got: {after:?}"
1147 );
1148 }
1149
1150 #[test]
1151 fn an_ignore_line_comment_falls_back_to_the_id_for_a_rule_with_no_readable_name() {
1152 let warning = LintWarning {
1153 line: 1,
1154 column: 1,
1155 end_line: 1,
1156 end_column: 2,
1157 rule_name: Some("MD999".to_string()),
1158 message: "From a rule the registry does not know".to_string(),
1159 severity: Severity::Warning,
1160 fix: None,
1161 };
1162 let uri = Url::parse("file:///test.md").unwrap();
1163
1164 let action = create_ignore_line_action(&warning, &uri, "text").unwrap();
1165 assert_eq!(action.title, "Ignore MD999 for this line");
1166 let edit = action.edit.unwrap();
1167 let file_edits = edit.changes.unwrap();
1168 assert_eq!(
1169 file_edits.get(&uri).unwrap()[0].new_text,
1170 " <!-- rumdl-disable-line MD999 -->"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_create_ignore_line_action_no_duplicate() {
1176 let warning = LintWarning {
1177 line: 1,
1178 column: 1,
1179 end_line: 1,
1180 end_column: 50,
1181 rule_name: Some("MD013".to_string()),
1182 message: "Line too long".to_string(),
1183 severity: Severity::Warning,
1184 fix: None,
1185 };
1186
1187 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1189 let uri = Url::parse("file:///test.md").unwrap();
1190
1191 let action = create_ignore_line_action(&warning, &uri, document);
1192
1193 assert!(action.is_none());
1195 }
1196
1197 #[test]
1198 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1199 let warning = LintWarning {
1200 line: 1,
1201 column: 1,
1202 end_line: 1,
1203 end_column: 50,
1204 rule_name: Some("MD013".to_string()),
1205 message: "Line too long".to_string(),
1206 severity: Severity::Warning,
1207 fix: None,
1208 };
1209
1210 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1212 let uri = Url::parse("file:///test.md").unwrap();
1213
1214 let action = create_ignore_line_action(&warning, &uri, document);
1215
1216 assert!(action.is_none());
1218 }
1219
1220 #[test]
1221 fn test_warning_to_code_actions_with_fix() {
1222 let warning = LintWarning {
1223 line: 1,
1224 column: 1,
1225 end_line: 1,
1226 end_column: 5,
1227 rule_name: Some("MD009".to_string()),
1228 message: "Trailing spaces".to_string(),
1229 severity: Severity::Warning,
1230 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1231 };
1232
1233 let uri = Url::parse("file:///test.md").unwrap();
1234 let document_text = "Hello \nWorld";
1235
1236 let actions = warning_to_code_actions(&warning, &uri, document_text);
1237
1238 assert_eq!(actions.len(), 2);
1240
1241 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1243 assert_eq!(actions[0].is_preferred, Some(true));
1244
1245 assert_eq!(actions[1].title, "Ignore no-trailing-spaces (MD009) for this line");
1247 assert_eq!(actions[1].is_preferred, Some(false));
1248 }
1249
1250 #[test]
1251 fn test_warning_to_code_actions_no_fix() {
1252 let warning = LintWarning {
1253 line: 1,
1254 column: 1,
1255 end_line: 1,
1256 end_column: 10,
1257 rule_name: Some("MD033".to_string()),
1258 message: "Inline HTML".to_string(),
1259 severity: Severity::Warning,
1260 fix: None,
1261 };
1262
1263 let uri = Url::parse("file:///test.md").unwrap();
1264 let document_text = "<div>HTML</div>";
1265
1266 let actions = warning_to_code_actions(&warning, &uri, document_text);
1267
1268 assert_eq!(actions.len(), 1);
1270 assert_eq!(actions[0].title, "Ignore no-inline-html (MD033) for this line");
1271 assert_eq!(actions[0].is_preferred, Some(false));
1272 }
1273
1274 #[test]
1275 fn test_warning_to_code_actions_no_rule_name() {
1276 let warning = LintWarning {
1277 line: 1,
1278 column: 1,
1279 end_line: 1,
1280 end_column: 5,
1281 rule_name: None,
1282 message: "Generic warning".to_string(),
1283 severity: Severity::Warning,
1284 fix: None,
1285 };
1286
1287 let uri = Url::parse("file:///test.md").unwrap();
1288 let document_text = "Hello World";
1289
1290 let actions = warning_to_code_actions(&warning, &uri, document_text);
1291
1292 assert_eq!(actions.len(), 0);
1294 }
1295
1296 #[test]
1297 fn test_legacy_warning_to_code_action_compatibility() {
1298 let warning = LintWarning {
1299 line: 1,
1300 column: 1,
1301 end_line: 1,
1302 end_column: 5,
1303 rule_name: Some("MD001".to_string()),
1304 message: "Test".to_string(),
1305 severity: Severity::Warning,
1306 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1307 };
1308
1309 let uri = Url::parse("file:///test.md").unwrap();
1310 let document_text = "Hello World";
1311
1312 #[allow(deprecated)]
1313 let action = warning_to_code_action(&warning, &uri, document_text);
1314
1315 assert!(action.is_some());
1317 let action = action.unwrap();
1318 assert_eq!(action.title, "Fix: Test");
1319 assert_eq!(action.is_preferred, Some(true));
1320 }
1321
1322 #[test]
1323 fn test_md034_convert_to_link_action() {
1324 let warning = LintWarning {
1326 line: 1,
1327 column: 1,
1328 end_line: 1,
1329 end_column: 25,
1330 rule_name: Some("MD034".to_string()),
1331 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1332 severity: Severity::Warning,
1333 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1334 };
1335
1336 let uri = Url::parse("file:///test.md").unwrap();
1337 let document_text = "https://example.com is a test URL";
1338
1339 let actions = warning_to_code_actions(&warning, &uri, document_text);
1340
1341 assert_eq!(actions.len(), 3);
1343
1344 assert_eq!(
1346 actions[0].title,
1347 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1348 );
1349 assert_eq!(actions[0].is_preferred, Some(true));
1350
1351 assert_eq!(actions[1].title, "Convert to markdown link");
1353 assert_eq!(actions[1].is_preferred, Some(false));
1354
1355 let edit = actions[1].edit.as_ref().unwrap();
1357 let changes = edit.changes.as_ref().unwrap();
1358 let file_edits = changes.get(&uri).unwrap();
1359 assert_eq!(file_edits.len(), 1);
1360
1361 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1363
1364 assert_eq!(actions[2].title, "Ignore no-bare-urls (MD034) for this line");
1366 }
1367
1368 #[test]
1369 fn test_md034_convert_to_link_action_email() {
1370 let warning = LintWarning {
1372 line: 1,
1373 column: 1,
1374 end_line: 1,
1375 end_column: 20,
1376 rule_name: Some("MD034".to_string()),
1377 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1378 severity: Severity::Warning,
1379 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1380 };
1381
1382 let uri = Url::parse("file:///test.md").unwrap();
1383 let document_text = "user@example.com is my email";
1384
1385 let actions = warning_to_code_actions(&warning, &uri, document_text);
1386
1387 assert_eq!(actions.len(), 3);
1389
1390 assert_eq!(actions[1].title, "Convert to markdown link");
1392
1393 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
1397 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1399 }
1400
1401 #[test]
1402 fn test_extract_url_from_fix_replacement() {
1403 assert_eq!(
1404 extract_url_from_fix_replacement("<https://example.com>"),
1405 Some("https://example.com")
1406 );
1407 assert_eq!(
1408 extract_url_from_fix_replacement("<user@example.com>"),
1409 Some("user@example.com")
1410 );
1411 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1412 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1413 }
1414
1415 #[test]
1416 fn test_extract_domain_for_placeholder() {
1417 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1418 assert_eq!(
1419 extract_domain_for_placeholder("https://example.com/path/to/page"),
1420 "example.com"
1421 );
1422 assert_eq!(
1423 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1424 "sub.example.com:8080"
1425 );
1426 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1427 assert_eq!(
1428 extract_domain_for_placeholder("ftp://files.example.com"),
1429 "files.example.com"
1430 );
1431 }
1432}