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