1use serde::{Deserialize, Serialize};
7use tower_lsp::lsp_types::*;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default, rename_all = "camelCase")]
14pub struct RumdlLspConfig {
15 pub config_path: Option<String>,
17 pub enable_linting: bool,
19 pub enable_auto_fix: bool,
21 pub enable_rules: Option<Vec<String>>,
24 pub disable_rules: Option<Vec<String>>,
26}
27
28impl Default for RumdlLspConfig {
29 fn default() -> Self {
30 Self {
31 config_path: None,
32 enable_linting: true,
33 enable_auto_fix: false,
34 enable_rules: None,
35 disable_rules: None,
36 }
37 }
38}
39
40pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
42 let start_position = Position {
43 line: (warning.line.saturating_sub(1)) as u32,
44 character: (warning.column.saturating_sub(1)) as u32,
45 };
46
47 let end_position = Position {
49 line: (warning.end_line.saturating_sub(1)) as u32,
50 character: (warning.end_column.saturating_sub(1)) as u32,
51 };
52
53 let severity = match warning.severity {
54 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
55 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
56 };
57
58 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
60 Url::parse(&format!(
62 "https://github.com/rvben/rumdl/blob/main/docs/{}.md",
63 rule_name.to_lowercase()
64 ))
65 .ok()
66 .map(|href| CodeDescription { href })
67 });
68
69 Diagnostic {
70 range: Range {
71 start: start_position,
72 end: end_position,
73 },
74 severity: Some(severity),
75 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
76 source: Some("rumdl".to_string()),
77 message: warning.message.clone(),
78 related_information: None,
79 tags: None,
80 code_description,
81 data: None,
82 }
83}
84
85fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
87 let mut line = 0u32;
88 let mut character = 0u32;
89 let mut byte_pos = 0;
90
91 let mut start_pos = None;
92 let mut end_pos = None;
93
94 for ch in text.chars() {
95 if byte_pos == byte_range.start {
96 start_pos = Some(Position { line, character });
97 }
98 if byte_pos == byte_range.end {
99 end_pos = Some(Position { line, character });
100 break;
101 }
102
103 if ch == '\n' {
104 line += 1;
105 character = 0;
106 } else {
107 character += 1;
108 }
109
110 byte_pos += ch.len_utf8();
111 }
112
113 if start_pos.is_none() && byte_pos >= byte_range.start {
116 start_pos = Some(Position { line, character });
117 }
118 if end_pos.is_none() && byte_pos >= byte_range.end {
119 end_pos = Some(Position { line, character });
120 }
121
122 match (start_pos, end_pos) {
123 (Some(start), Some(end)) => Some(Range { start, end }),
124 _ => {
125 log::warn!(
128 "Failed to convert byte range {:?} to LSP range for text of length {}",
129 byte_range,
130 text.len()
131 );
132 None
133 }
134 }
135}
136
137pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
140 let mut actions = Vec::new();
141
142 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
144 actions.push(fix_action);
145 }
146
147 if warning.rule_name.as_deref() == Some("MD013")
150 && warning.fix.is_none()
151 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text)
152 {
153 actions.push(reflow_action);
154 }
155
156 if warning.rule_name.as_deref() == Some("MD034")
159 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
160 {
161 actions.push(convert_action);
162 }
163
164 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
166 actions.push(ignore_line_action);
167 }
168
169 actions
170}
171
172fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
174 if let Some(fix) = &warning.fix {
175 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
177
178 let edit = TextEdit {
179 range,
180 new_text: fix.replacement.clone(),
181 };
182
183 let mut changes = std::collections::HashMap::new();
184 changes.insert(uri.clone(), vec![edit]);
185
186 let workspace_edit = WorkspaceEdit {
187 changes: Some(changes),
188 document_changes: None,
189 change_annotations: None,
190 };
191
192 Some(CodeAction {
193 title: format!("Fix: {}", warning.message),
194 kind: Some(CodeActionKind::QUICKFIX),
195 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
196 edit: Some(workspace_edit),
197 command: None,
198 is_preferred: Some(true),
199 disabled: None,
200 data: None,
201 })
202 } else {
203 None
204 }
205}
206
207fn create_reflow_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
210 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
212
213 let reflow_result = crate::utils::text_reflow::reflow_paragraph_at_line(document_text, warning.line, line_length)?;
215
216 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
218
219 let edit = TextEdit {
220 range,
221 new_text: reflow_result.reflowed_text,
222 };
223
224 let mut changes = std::collections::HashMap::new();
225 changes.insert(uri.clone(), vec![edit]);
226
227 let workspace_edit = WorkspaceEdit {
228 changes: Some(changes),
229 document_changes: None,
230 change_annotations: None,
231 };
232
233 Some(CodeAction {
234 title: "Reflow paragraph".to_string(),
235 kind: Some(CodeActionKind::QUICKFIX),
236 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
237 edit: Some(workspace_edit),
238 command: None,
239 is_preferred: Some(false), disabled: None,
241 data: None,
242 })
243}
244
245fn extract_line_length_from_message(message: &str) -> Option<usize> {
248 let exceeds_idx = message.find("exceeds")?;
250 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
254
255 num_str.parse::<usize>().ok()
256}
257
258fn create_convert_to_link_action(
262 warning: &crate::rule::LintWarning,
263 uri: &Url,
264 document_text: &str,
265) -> Option<CodeAction> {
266 let fix = warning.fix.as_ref()?;
268
269 let url = extract_url_from_fix_replacement(&fix.replacement)?;
272
273 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
275
276 let link_text = extract_domain_for_placeholder(url);
281 let new_text = format!("[{link_text}]({url})");
282
283 let edit = TextEdit { range, new_text };
284
285 let mut changes = std::collections::HashMap::new();
286 changes.insert(uri.clone(), vec![edit]);
287
288 let workspace_edit = WorkspaceEdit {
289 changes: Some(changes),
290 document_changes: None,
291 change_annotations: None,
292 };
293
294 Some(CodeAction {
295 title: "Convert to markdown link".to_string(),
296 kind: Some(CodeActionKind::QUICKFIX),
297 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
298 edit: Some(workspace_edit),
299 command: None,
300 is_preferred: Some(false), disabled: None,
302 data: None,
303 })
304}
305
306fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
309 let trimmed = replacement.trim();
311 if trimmed.starts_with('<') && trimmed.ends_with('>') {
312 Some(&trimmed[1..trimmed.len() - 1])
313 } else {
314 None
315 }
316}
317
318fn extract_domain_for_placeholder(url: &str) -> &str {
322 if url.contains('@') && !url.contains("://") {
324 return url;
325 }
326
327 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
329}
330
331fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
333 let rule_id = warning.rule_name.as_ref()?;
334 let warning_line = warning.line.saturating_sub(1);
335
336 let lines: Vec<&str> = document_text.lines().collect();
338 let line_content = lines.get(warning_line)?;
339
340 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
342 return None;
344 }
345
346 let line_end = Position {
348 line: warning_line as u32,
349 character: line_content.len() as u32,
350 };
351
352 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
354
355 let edit = TextEdit {
356 range: Range {
357 start: line_end,
358 end: line_end,
359 },
360 new_text: comment,
361 };
362
363 let mut changes = std::collections::HashMap::new();
364 changes.insert(uri.clone(), vec![edit]);
365
366 Some(CodeAction {
367 title: format!("Ignore {rule_id} for this line"),
368 kind: Some(CodeActionKind::QUICKFIX),
369 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
370 edit: Some(WorkspaceEdit {
371 changes: Some(changes),
372 document_changes: None,
373 change_annotations: None,
374 }),
375 command: None,
376 is_preferred: Some(false), disabled: None,
378 data: None,
379 })
380}
381
382#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
385pub fn warning_to_code_action(
386 warning: &crate::rule::LintWarning,
387 uri: &Url,
388 document_text: &str,
389) -> Option<CodeAction> {
390 warning_to_code_actions(warning, uri, document_text)
391 .into_iter()
392 .find(|action| action.is_preferred == Some(true))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::rule::{Fix, LintWarning, Severity};
399
400 #[test]
401 fn test_rumdl_lsp_config_default() {
402 let config = RumdlLspConfig::default();
403 assert_eq!(config.config_path, None);
404 assert!(config.enable_linting);
405 assert!(!config.enable_auto_fix);
406 }
407
408 #[test]
409 fn test_rumdl_lsp_config_serialization() {
410 let config = RumdlLspConfig {
411 config_path: Some("/path/to/config.toml".to_string()),
412 enable_linting: false,
413 enable_auto_fix: true,
414 enable_rules: None,
415 disable_rules: None,
416 };
417
418 let json = serde_json::to_string(&config).unwrap();
420 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
421 assert!(json.contains("\"enableLinting\":false"));
422 assert!(json.contains("\"enableAutoFix\":true"));
423
424 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
426 assert_eq!(deserialized.config_path, config.config_path);
427 assert_eq!(deserialized.enable_linting, config.enable_linting);
428 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
429 }
430
431 #[test]
432 fn test_warning_to_diagnostic_basic() {
433 let warning = LintWarning {
434 line: 5,
435 column: 10,
436 end_line: 5,
437 end_column: 15,
438 rule_name: Some("MD001".to_string()),
439 message: "Test warning message".to_string(),
440 severity: Severity::Warning,
441 fix: None,
442 };
443
444 let diagnostic = warning_to_diagnostic(&warning);
445
446 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
449 assert_eq!(diagnostic.range.end.character, 14);
450 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
451 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
452 assert_eq!(diagnostic.message, "Test warning message");
453 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
454 }
455
456 #[test]
457 fn test_warning_to_diagnostic_error_severity() {
458 let warning = LintWarning {
459 line: 1,
460 column: 1,
461 end_line: 1,
462 end_column: 5,
463 rule_name: Some("MD002".to_string()),
464 message: "Error message".to_string(),
465 severity: Severity::Error,
466 fix: None,
467 };
468
469 let diagnostic = warning_to_diagnostic(&warning);
470 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
471 }
472
473 #[test]
474 fn test_warning_to_diagnostic_no_rule_name() {
475 let warning = LintWarning {
476 line: 1,
477 column: 1,
478 end_line: 1,
479 end_column: 5,
480 rule_name: None,
481 message: "Generic warning".to_string(),
482 severity: Severity::Warning,
483 fix: None,
484 };
485
486 let diagnostic = warning_to_diagnostic(&warning);
487 assert_eq!(diagnostic.code, None);
488 assert!(diagnostic.code_description.is_none());
489 }
490
491 #[test]
492 fn test_warning_to_diagnostic_edge_cases() {
493 let warning = LintWarning {
495 line: 0,
496 column: 0,
497 end_line: 0,
498 end_column: 0,
499 rule_name: Some("MD001".to_string()),
500 message: "Edge case".to_string(),
501 severity: Severity::Warning,
502 fix: None,
503 };
504
505 let diagnostic = warning_to_diagnostic(&warning);
506 assert_eq!(diagnostic.range.start.line, 0);
507 assert_eq!(diagnostic.range.start.character, 0);
508 }
509
510 #[test]
511 fn test_byte_range_to_lsp_range_simple() {
512 let text = "Hello\nWorld";
513 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
514
515 assert_eq!(range.start.line, 0);
516 assert_eq!(range.start.character, 0);
517 assert_eq!(range.end.line, 0);
518 assert_eq!(range.end.character, 5);
519 }
520
521 #[test]
522 fn test_byte_range_to_lsp_range_multiline() {
523 let text = "Hello\nWorld\nTest";
524 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
527 assert_eq!(range.start.character, 0);
528 assert_eq!(range.end.line, 1);
529 assert_eq!(range.end.character, 5);
530 }
531
532 #[test]
533 fn test_byte_range_to_lsp_range_unicode() {
534 let text = "Hello 世界\nTest";
535 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
537
538 assert_eq!(range.start.line, 0);
539 assert_eq!(range.start.character, 6);
540 assert_eq!(range.end.line, 0);
541 assert_eq!(range.end.character, 8); }
543
544 #[test]
545 fn test_byte_range_to_lsp_range_eof() {
546 let text = "Hello";
547 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
548
549 assert_eq!(range.start.line, 0);
550 assert_eq!(range.start.character, 0);
551 assert_eq!(range.end.line, 0);
552 assert_eq!(range.end.character, 5);
553 }
554
555 #[test]
556 fn test_byte_range_to_lsp_range_invalid() {
557 let text = "Hello";
558 let range = byte_range_to_lsp_range(text, 10..15);
560 assert!(range.is_none());
561 }
562
563 #[test]
564 fn test_byte_range_to_lsp_range_insertion_at_eof() {
565 let text = "Hello\nWorld";
567 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
569
570 assert_eq!(range.start.line, 1);
572 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
574 assert_eq!(range.end.character, 5);
575 }
576
577 #[test]
578 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
579 let text = "Hello\nWorld\n";
581 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
583
584 assert_eq!(range.start.line, 2);
586 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
588 assert_eq!(range.end.character, 0);
589 }
590
591 #[test]
592 fn test_warning_to_code_action_with_fix() {
593 let warning = LintWarning {
594 line: 1,
595 column: 1,
596 end_line: 1,
597 end_column: 5,
598 rule_name: Some("MD001".to_string()),
599 message: "Missing space".to_string(),
600 severity: Severity::Warning,
601 fix: Some(Fix {
602 range: 0..5,
603 replacement: "Fixed".to_string(),
604 }),
605 };
606
607 let uri = Url::parse("file:///test.md").unwrap();
608 let document_text = "Hello World";
609
610 let actions = warning_to_code_actions(&warning, &uri, document_text);
611 assert!(!actions.is_empty());
612 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
615 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
616 assert_eq!(action.is_preferred, Some(true));
617
618 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
619 let edits = &changes[&uri];
620 assert_eq!(edits.len(), 1);
621 assert_eq!(edits[0].new_text, "Fixed");
622 }
623
624 #[test]
625 fn test_warning_to_code_action_no_fix() {
626 let warning = LintWarning {
627 line: 1,
628 column: 1,
629 end_line: 1,
630 end_column: 5,
631 rule_name: Some("MD001".to_string()),
632 message: "No fix available".to_string(),
633 severity: Severity::Warning,
634 fix: None,
635 };
636
637 let uri = Url::parse("file:///test.md").unwrap();
638 let document_text = "Hello World";
639
640 let actions = warning_to_code_actions(&warning, &uri, document_text);
641 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
643 }
644
645 #[test]
646 fn test_warning_to_code_action_multiline_fix() {
647 let warning = LintWarning {
648 line: 2,
649 column: 1,
650 end_line: 3,
651 end_column: 5,
652 rule_name: Some("MD001".to_string()),
653 message: "Multiline fix".to_string(),
654 severity: Severity::Warning,
655 fix: Some(Fix {
656 range: 6..16, replacement: "Fixed\nContent".to_string(),
658 }),
659 };
660
661 let uri = Url::parse("file:///test.md").unwrap();
662 let document_text = "Hello\nWorld\nTest Line";
663
664 let actions = warning_to_code_actions(&warning, &uri, document_text);
665 assert!(!actions.is_empty());
666 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
669 let edits = &changes[&uri];
670 assert_eq!(edits[0].new_text, "Fixed\nContent");
671 assert_eq!(edits[0].range.start.line, 1);
672 assert_eq!(edits[0].range.start.character, 0);
673 }
674
675 #[test]
676 fn test_code_description_url_generation() {
677 let warning = LintWarning {
678 line: 1,
679 column: 1,
680 end_line: 1,
681 end_column: 5,
682 rule_name: Some("MD013".to_string()),
683 message: "Line too long".to_string(),
684 severity: Severity::Warning,
685 fix: None,
686 };
687
688 let diagnostic = warning_to_diagnostic(&warning);
689 assert!(diagnostic.code_description.is_some());
690
691 let url = diagnostic.code_description.unwrap().href;
692 assert_eq!(url.as_str(), "https://github.com/rvben/rumdl/blob/main/docs/md013.md");
693 }
694
695 #[test]
696 fn test_lsp_config_partial_deserialization() {
697 let json = r#"{"enableLinting": false}"#;
699 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
700
701 assert!(!config.enable_linting);
702 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
705
706 #[test]
707 fn test_create_ignore_line_action_uses_rumdl_syntax() {
708 let warning = LintWarning {
709 line: 5,
710 column: 1,
711 end_line: 5,
712 end_column: 50,
713 rule_name: Some("MD013".to_string()),
714 message: "Line too long".to_string(),
715 severity: Severity::Warning,
716 fix: None,
717 };
718
719 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
720 let uri = Url::parse("file:///test.md").unwrap();
721
722 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
723
724 assert_eq!(action.title, "Ignore MD013 for this line");
725 assert_eq!(action.is_preferred, Some(false));
726 assert!(action.edit.is_some());
727
728 let edit = action.edit.unwrap();
730 let changes = edit.changes.unwrap();
731 let file_edits = changes.get(&uri).unwrap();
732
733 assert_eq!(file_edits.len(), 1);
734 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
735 assert!(!file_edits[0].new_text.contains("markdownlint"));
736
737 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
741
742 #[test]
743 fn test_create_ignore_line_action_no_duplicate() {
744 let warning = LintWarning {
745 line: 1,
746 column: 1,
747 end_line: 1,
748 end_column: 50,
749 rule_name: Some("MD013".to_string()),
750 message: "Line too long".to_string(),
751 severity: Severity::Warning,
752 fix: None,
753 };
754
755 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
757 let uri = Url::parse("file:///test.md").unwrap();
758
759 let action = create_ignore_line_action(&warning, &uri, document);
760
761 assert!(action.is_none());
763 }
764
765 #[test]
766 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
767 let warning = LintWarning {
768 line: 1,
769 column: 1,
770 end_line: 1,
771 end_column: 50,
772 rule_name: Some("MD013".to_string()),
773 message: "Line too long".to_string(),
774 severity: Severity::Warning,
775 fix: None,
776 };
777
778 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
780 let uri = Url::parse("file:///test.md").unwrap();
781
782 let action = create_ignore_line_action(&warning, &uri, document);
783
784 assert!(action.is_none());
786 }
787
788 #[test]
789 fn test_warning_to_code_actions_with_fix() {
790 let warning = LintWarning {
791 line: 1,
792 column: 1,
793 end_line: 1,
794 end_column: 5,
795 rule_name: Some("MD009".to_string()),
796 message: "Trailing spaces".to_string(),
797 severity: Severity::Warning,
798 fix: Some(Fix {
799 range: 0..5,
800 replacement: "Fixed".to_string(),
801 }),
802 };
803
804 let uri = Url::parse("file:///test.md").unwrap();
805 let document_text = "Hello \nWorld";
806
807 let actions = warning_to_code_actions(&warning, &uri, document_text);
808
809 assert_eq!(actions.len(), 2);
811
812 assert_eq!(actions[0].title, "Fix: Trailing spaces");
814 assert_eq!(actions[0].is_preferred, Some(true));
815
816 assert_eq!(actions[1].title, "Ignore MD009 for this line");
818 assert_eq!(actions[1].is_preferred, Some(false));
819 }
820
821 #[test]
822 fn test_warning_to_code_actions_no_fix() {
823 let warning = LintWarning {
824 line: 1,
825 column: 1,
826 end_line: 1,
827 end_column: 10,
828 rule_name: Some("MD033".to_string()),
829 message: "Inline HTML".to_string(),
830 severity: Severity::Warning,
831 fix: None,
832 };
833
834 let uri = Url::parse("file:///test.md").unwrap();
835 let document_text = "<div>HTML</div>";
836
837 let actions = warning_to_code_actions(&warning, &uri, document_text);
838
839 assert_eq!(actions.len(), 1);
841 assert_eq!(actions[0].title, "Ignore MD033 for this line");
842 assert_eq!(actions[0].is_preferred, Some(false));
843 }
844
845 #[test]
846 fn test_warning_to_code_actions_no_rule_name() {
847 let warning = LintWarning {
848 line: 1,
849 column: 1,
850 end_line: 1,
851 end_column: 5,
852 rule_name: None,
853 message: "Generic warning".to_string(),
854 severity: Severity::Warning,
855 fix: None,
856 };
857
858 let uri = Url::parse("file:///test.md").unwrap();
859 let document_text = "Hello World";
860
861 let actions = warning_to_code_actions(&warning, &uri, document_text);
862
863 assert_eq!(actions.len(), 0);
865 }
866
867 #[test]
868 fn test_legacy_warning_to_code_action_compatibility() {
869 let warning = LintWarning {
870 line: 1,
871 column: 1,
872 end_line: 1,
873 end_column: 5,
874 rule_name: Some("MD001".to_string()),
875 message: "Test".to_string(),
876 severity: Severity::Warning,
877 fix: Some(Fix {
878 range: 0..5,
879 replacement: "Fixed".to_string(),
880 }),
881 };
882
883 let uri = Url::parse("file:///test.md").unwrap();
884 let document_text = "Hello World";
885
886 #[allow(deprecated)]
887 let action = warning_to_code_action(&warning, &uri, document_text);
888
889 assert!(action.is_some());
891 let action = action.unwrap();
892 assert_eq!(action.title, "Fix: Test");
893 assert_eq!(action.is_preferred, Some(true));
894 }
895
896 #[test]
897 fn test_md034_convert_to_link_action() {
898 let warning = LintWarning {
900 line: 1,
901 column: 1,
902 end_line: 1,
903 end_column: 25,
904 rule_name: Some("MD034".to_string()),
905 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
906 severity: Severity::Warning,
907 fix: Some(Fix {
908 range: 0..20, replacement: "<https://example.com>".to_string(),
910 }),
911 };
912
913 let uri = Url::parse("file:///test.md").unwrap();
914 let document_text = "https://example.com is a test URL";
915
916 let actions = warning_to_code_actions(&warning, &uri, document_text);
917
918 assert_eq!(actions.len(), 3);
920
921 assert_eq!(
923 actions[0].title,
924 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
925 );
926 assert_eq!(actions[0].is_preferred, Some(true));
927
928 assert_eq!(actions[1].title, "Convert to markdown link");
930 assert_eq!(actions[1].is_preferred, Some(false));
931
932 let edit = actions[1].edit.as_ref().unwrap();
934 let changes = edit.changes.as_ref().unwrap();
935 let file_edits = changes.get(&uri).unwrap();
936 assert_eq!(file_edits.len(), 1);
937
938 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
940
941 assert_eq!(actions[2].title, "Ignore MD034 for this line");
943 }
944
945 #[test]
946 fn test_md034_convert_to_link_action_email() {
947 let warning = LintWarning {
949 line: 1,
950 column: 1,
951 end_line: 1,
952 end_column: 20,
953 rule_name: Some("MD034".to_string()),
954 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
955 severity: Severity::Warning,
956 fix: Some(Fix {
957 range: 0..16, replacement: "<user@example.com>".to_string(),
959 }),
960 };
961
962 let uri = Url::parse("file:///test.md").unwrap();
963 let document_text = "user@example.com is my email";
964
965 let actions = warning_to_code_actions(&warning, &uri, document_text);
966
967 assert_eq!(actions.len(), 3);
969
970 assert_eq!(actions[1].title, "Convert to markdown link");
972
973 let edit = actions[1].edit.as_ref().unwrap();
974 let changes = edit.changes.as_ref().unwrap();
975 let file_edits = changes.get(&uri).unwrap();
976
977 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
979 }
980
981 #[test]
982 fn test_extract_url_from_fix_replacement() {
983 assert_eq!(
984 extract_url_from_fix_replacement("<https://example.com>"),
985 Some("https://example.com")
986 );
987 assert_eq!(
988 extract_url_from_fix_replacement("<user@example.com>"),
989 Some("user@example.com")
990 );
991 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
992 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
993 }
994
995 #[test]
996 fn test_extract_domain_for_placeholder() {
997 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
998 assert_eq!(
999 extract_domain_for_placeholder("https://example.com/path/to/page"),
1000 "example.com"
1001 );
1002 assert_eq!(
1003 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1004 "sub.example.com:8080"
1005 );
1006 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1007 assert_eq!(
1008 extract_domain_for_placeholder("ftp://files.example.com"),
1009 "files.example.com"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_byte_range_to_lsp_range_trailing_newlines() {
1015 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1020 assert!(range.is_some());
1021 let range = range.unwrap();
1022
1023 assert_eq!(range.start.line, 2);
1026 assert_eq!(range.start.character, 0);
1027 assert_eq!(range.end.line, 3);
1028 assert_eq!(range.end.character, 0);
1029 }
1030
1031 #[test]
1032 fn test_byte_range_to_lsp_range_at_eof() {
1033 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1038 assert!(range.is_some());
1039 let range = range.unwrap();
1040
1041 assert_eq!(range.start.line, 1);
1043 assert_eq!(range.start.character, 0);
1044 }
1045}