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