1use crate::rules::md013_line_length::MD013Config;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use tower_lsp::lsp_types::*;
10
11#[derive(Debug, Clone, PartialEq)]
13pub enum IndexState {
14 Building {
16 progress: f32,
18 files_indexed: usize,
20 total_files: usize,
22 },
23 Ready,
25 Error(String),
27}
28
29impl Default for IndexState {
30 fn default() -> Self {
31 Self::Building {
32 progress: 0.0,
33 files_indexed: 0,
34 total_files: 0,
35 }
36 }
37}
38
39#[derive(Debug)]
41pub enum IndexUpdate {
42 FileChanged { path: PathBuf, content: String },
44 FileDeleted { path: PathBuf },
46 FullRescan,
48 Shutdown,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub enum ConfigurationPreference {
56 #[default]
58 EditorFirst,
59 FilesystemFirst,
61 EditorOnly,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70#[serde(default, rename_all = "camelCase")]
71pub struct LspRuleSettings {
72 pub line_length: Option<usize>,
74 pub disable: Option<Vec<String>>,
76 pub enable: Option<Vec<String>>,
78 #[serde(flatten)]
80 pub rules: std::collections::HashMap<String, serde_json::Value>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default, rename_all = "camelCase")]
89pub struct RumdlLspConfig {
90 pub config_path: Option<String>,
92 pub enable_linting: bool,
94 pub enable_auto_fix: bool,
96 pub enable_rules: Option<Vec<String>>,
99 pub disable_rules: Option<Vec<String>>,
101 pub configuration_preference: ConfigurationPreference,
103 pub settings: Option<LspRuleSettings>,
106 pub enable_link_completions: bool,
109 pub enable_link_navigation: bool,
113}
114
115impl Default for RumdlLspConfig {
116 fn default() -> Self {
117 Self {
118 config_path: None,
119 enable_linting: true,
120 enable_auto_fix: false,
121 enable_rules: None,
122 disable_rules: None,
123 configuration_preference: ConfigurationPreference::default(),
124 settings: None,
125 enable_link_completions: true,
126 enable_link_navigation: true,
127 }
128 }
129}
130
131pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
133 let start_position = Position {
134 line: (warning.line.saturating_sub(1)) as u32,
135 character: (warning.column.saturating_sub(1)) as u32,
136 };
137
138 let end_position = Position {
140 line: (warning.end_line.saturating_sub(1)) as u32,
141 character: (warning.end_column.saturating_sub(1)) as u32,
142 };
143
144 let severity = match warning.severity {
145 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
146 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
147 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
148 };
149
150 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
153 let is_rumdl_rule = rule_name.len() > 2
154 && rule_name[..2].eq_ignore_ascii_case("MD")
155 && rule_name[2..].chars().all(|c| c.is_ascii_digit());
156 if is_rumdl_rule {
157 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
158 .ok()
159 .map(|href| CodeDescription { href })
160 } else {
161 None
162 }
163 });
164
165 Diagnostic {
166 range: Range {
167 start: start_position,
168 end: end_position,
169 },
170 severity: Some(severity),
171 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
172 source: Some("rumdl".to_string()),
173 message: warning.message.clone(),
174 related_information: None,
175 tags: None,
176 code_description,
177 data: None,
178 }
179}
180
181fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
189 let mut line = 0u32;
190 let mut character = 0u32;
191 let mut byte_pos = 0;
192
193 let mut start_pos = None;
194 let mut end_pos = None;
195
196 for ch in text.chars() {
197 if byte_pos == byte_range.start {
198 start_pos = Some(Position { line, character });
199 }
200 if byte_pos == byte_range.end {
201 end_pos = Some(Position { line, character });
202 break;
203 }
204
205 if ch == '\n' {
206 line += 1;
207 character = 0;
208 } else {
209 character += ch.len_utf16() as u32;
210 }
211
212 byte_pos += ch.len_utf8();
213 }
214
215 if start_pos.is_none() && byte_pos >= byte_range.start {
218 start_pos = Some(Position { line, character });
219 }
220 if end_pos.is_none() && byte_pos >= byte_range.end {
221 end_pos = Some(Position { line, character });
222 }
223
224 match (start_pos, end_pos) {
225 (Some(start), Some(end)) => Some(Range { start, end }),
226 _ => {
227 log::warn!(
230 "Failed to convert byte range {:?} to LSP range for text of length {}",
231 byte_range,
232 text.len()
233 );
234 None
235 }
236 }
237}
238
239pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
242 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
243}
244
245pub(crate) fn warning_to_code_actions_with_md013_config(
249 warning: &crate::rule::LintWarning,
250 uri: &Url,
251 document_text: &str,
252 md013_config: Option<&MD013Config>,
253) -> Vec<CodeAction> {
254 let mut actions = Vec::new();
255
256 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
258 actions.push(fix_action);
259 }
260
261 if warning.rule_name.as_deref() == Some("MD013")
264 && warning.fix.is_none()
265 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
266 {
267 actions.push(reflow_action);
268 }
269
270 if warning.rule_name.as_deref() == Some("MD034")
273 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
274 {
275 actions.push(convert_action);
276 }
277
278 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
280 actions.push(ignore_line_action);
281 }
282
283 actions
284}
285
286fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
288 if let Some(fix) = &warning.fix {
289 let primary = TextEdit {
294 range: byte_range_to_lsp_range(document_text, fix.range.clone())?,
295 new_text: fix.replacement.clone(),
296 };
297
298 let mut edits = Vec::with_capacity(1 + fix.additional_edits.len());
299 edits.push(primary);
300 for extra in &fix.additional_edits {
301 edits.push(TextEdit {
302 range: byte_range_to_lsp_range(document_text, extra.range.clone())?,
303 new_text: extra.replacement.clone(),
304 });
305 }
306
307 let mut changes = std::collections::HashMap::new();
308 changes.insert(uri.clone(), edits);
309
310 let workspace_edit = WorkspaceEdit {
311 changes: Some(changes),
312 document_changes: None,
313 change_annotations: None,
314 };
315
316 Some(CodeAction {
317 title: format!("Fix: {}", warning.message),
318 kind: Some(CodeActionKind::QUICKFIX),
319 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
320 edit: Some(workspace_edit),
321 command: None,
322 is_preferred: Some(true),
323 disabled: None,
324 data: None,
325 })
326 } else {
327 None
328 }
329}
330
331fn create_reflow_action(
334 warning: &crate::rule::LintWarning,
335 uri: &Url,
336 document_text: &str,
337 md013_config: Option<&MD013Config>,
338) -> Option<CodeAction> {
339 let options = if let Some(config) = md013_config {
342 config.to_reflow_options()
343 } else {
344 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
345 crate::utils::text_reflow::ReflowOptions {
346 line_length,
347 ..Default::default()
348 }
349 };
350
351 let reflow_result =
353 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
354
355 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
357
358 let edit = TextEdit {
359 range,
360 new_text: reflow_result.reflowed_text,
361 };
362
363 let mut changes = std::collections::HashMap::new();
364 changes.insert(uri.clone(), vec![edit]);
365
366 let workspace_edit = WorkspaceEdit {
367 changes: Some(changes),
368 document_changes: None,
369 change_annotations: None,
370 };
371
372 Some(CodeAction {
373 title: "Reflow paragraph".to_string(),
374 kind: Some(CodeActionKind::QUICKFIX),
375 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
376 edit: Some(workspace_edit),
377 command: None,
378 is_preferred: Some(false), disabled: None,
380 data: None,
381 })
382}
383
384fn extract_line_length_from_message(message: &str) -> Option<usize> {
387 let exceeds_idx = message.find("exceeds")?;
389 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
393
394 num_str.parse::<usize>().ok()
395}
396
397fn create_convert_to_link_action(
401 warning: &crate::rule::LintWarning,
402 uri: &Url,
403 document_text: &str,
404) -> Option<CodeAction> {
405 let fix = warning.fix.as_ref()?;
407
408 let url = extract_url_from_fix_replacement(&fix.replacement)?;
411
412 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
414
415 let link_text = extract_domain_for_placeholder(url);
420 let new_text = format!("[{link_text}]({url})");
421
422 let edit = TextEdit { range, new_text };
423
424 let mut changes = std::collections::HashMap::new();
425 changes.insert(uri.clone(), vec![edit]);
426
427 let workspace_edit = WorkspaceEdit {
428 changes: Some(changes),
429 document_changes: None,
430 change_annotations: None,
431 };
432
433 Some(CodeAction {
434 title: "Convert to markdown link".to_string(),
435 kind: Some(CodeActionKind::QUICKFIX),
436 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
437 edit: Some(workspace_edit),
438 command: None,
439 is_preferred: Some(false), disabled: None,
441 data: None,
442 })
443}
444
445fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
448 let trimmed = replacement.trim();
450 if trimmed.starts_with('<') && trimmed.ends_with('>') {
451 Some(&trimmed[1..trimmed.len() - 1])
452 } else {
453 None
454 }
455}
456
457fn extract_domain_for_placeholder(url: &str) -> &str {
461 if url.contains('@') && !url.contains("://") {
463 return url;
464 }
465
466 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
468}
469
470fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
472 let rule_id = warning.rule_name.as_ref()?;
473 let warning_line = warning.line.saturating_sub(1);
474
475 let lines: Vec<&str> = document_text.lines().collect();
477 let line_content = lines.get(warning_line)?;
478
479 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
481 return None;
483 }
484
485 let line_end = Position {
487 line: warning_line as u32,
488 character: line_content.len() as u32,
489 };
490
491 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
493
494 let edit = TextEdit {
495 range: Range {
496 start: line_end,
497 end: line_end,
498 },
499 new_text: comment,
500 };
501
502 let mut changes = std::collections::HashMap::new();
503 changes.insert(uri.clone(), vec![edit]);
504
505 Some(CodeAction {
506 title: format!("Ignore {rule_id} for this line"),
507 kind: Some(CodeActionKind::QUICKFIX),
508 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
509 edit: Some(WorkspaceEdit {
510 changes: Some(changes),
511 document_changes: None,
512 change_annotations: None,
513 }),
514 command: None,
515 is_preferred: Some(false), disabled: None,
517 data: None,
518 })
519}
520
521#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
524pub fn warning_to_code_action(
525 warning: &crate::rule::LintWarning,
526 uri: &Url,
527 document_text: &str,
528) -> Option<CodeAction> {
529 warning_to_code_actions(warning, uri, document_text)
530 .into_iter()
531 .find(|action| action.is_preferred == Some(true))
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::rule::{Fix, LintWarning, Severity};
538
539 #[test]
540 fn test_rumdl_lsp_config_default() {
541 let config = RumdlLspConfig::default();
542 assert_eq!(config.config_path, None);
543 assert!(config.enable_linting);
544 assert!(!config.enable_auto_fix);
545 }
546
547 #[test]
548 fn test_rumdl_lsp_config_serialization() {
549 let config = RumdlLspConfig {
550 config_path: Some("/path/to/config.toml".to_string()),
551 enable_linting: false,
552 enable_auto_fix: true,
553 enable_rules: None,
554 disable_rules: None,
555 configuration_preference: ConfigurationPreference::EditorFirst,
556 settings: None,
557 enable_link_completions: true,
558 enable_link_navigation: true,
559 };
560
561 let json = serde_json::to_string(&config).unwrap();
563 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
564 assert!(json.contains("\"enableLinting\":false"));
565 assert!(json.contains("\"enableAutoFix\":true"));
566
567 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
569 assert_eq!(deserialized.config_path, config.config_path);
570 assert_eq!(deserialized.enable_linting, config.enable_linting);
571 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
572 }
573
574 #[test]
575 fn test_warning_to_diagnostic_basic() {
576 let warning = LintWarning {
577 line: 5,
578 column: 10,
579 end_line: 5,
580 end_column: 15,
581 rule_name: Some("MD001".to_string()),
582 message: "Test warning message".to_string(),
583 severity: Severity::Warning,
584 fix: None,
585 };
586
587 let diagnostic = warning_to_diagnostic(&warning);
588
589 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
592 assert_eq!(diagnostic.range.end.character, 14);
593 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
594 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
595 assert_eq!(diagnostic.message, "Test warning message");
596 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
597 }
598
599 #[test]
600 fn test_warning_to_diagnostic_error_severity() {
601 let warning = LintWarning {
602 line: 1,
603 column: 1,
604 end_line: 1,
605 end_column: 5,
606 rule_name: Some("MD002".to_string()),
607 message: "Error message".to_string(),
608 severity: Severity::Error,
609 fix: None,
610 };
611
612 let diagnostic = warning_to_diagnostic(&warning);
613 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
614 }
615
616 #[test]
617 fn test_warning_to_diagnostic_no_rule_name() {
618 let warning = LintWarning {
619 line: 1,
620 column: 1,
621 end_line: 1,
622 end_column: 5,
623 rule_name: None,
624 message: "Generic warning".to_string(),
625 severity: Severity::Warning,
626 fix: None,
627 };
628
629 let diagnostic = warning_to_diagnostic(&warning);
630 assert_eq!(diagnostic.code, None);
631 assert!(diagnostic.code_description.is_none());
632 }
633
634 #[test]
635 fn test_warning_to_diagnostic_edge_cases() {
636 let warning = LintWarning {
638 line: 0,
639 column: 0,
640 end_line: 0,
641 end_column: 0,
642 rule_name: Some("MD001".to_string()),
643 message: "Edge case".to_string(),
644 severity: Severity::Warning,
645 fix: None,
646 };
647
648 let diagnostic = warning_to_diagnostic(&warning);
649 assert_eq!(diagnostic.range.start.line, 0);
650 assert_eq!(diagnostic.range.start.character, 0);
651 }
652
653 #[test]
654 fn test_byte_range_to_lsp_range_simple() {
655 let text = "Hello\nWorld";
656 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
657
658 assert_eq!(range.start.line, 0);
659 assert_eq!(range.start.character, 0);
660 assert_eq!(range.end.line, 0);
661 assert_eq!(range.end.character, 5);
662 }
663
664 #[test]
665 fn test_byte_range_to_lsp_range_multiline() {
666 let text = "Hello\nWorld\nTest";
667 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
670 assert_eq!(range.start.character, 0);
671 assert_eq!(range.end.line, 1);
672 assert_eq!(range.end.character, 5);
673 }
674
675 #[test]
676 fn test_byte_range_to_lsp_range_unicode() {
677 let text = "Hello 世界\nTest";
678 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
680
681 assert_eq!(range.start.line, 0);
682 assert_eq!(range.start.character, 6);
683 assert_eq!(range.end.line, 0);
684 assert_eq!(range.end.character, 8); }
686
687 #[test]
688 fn test_byte_range_to_lsp_range_non_bmp_counts_as_surrogate_pair() {
689 let text = "a🎉b"; let range = byte_range_to_lsp_range(text, 5..6).unwrap();
700 assert_eq!(range.start.line, 0);
701 assert_eq!(range.start.character, 3);
703 assert_eq!(range.end.line, 0);
704 assert_eq!(range.end.character, 4);
705 }
706
707 #[test]
708 fn test_byte_range_to_lsp_range_eof() {
709 let text = "Hello";
710 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
711
712 assert_eq!(range.start.line, 0);
713 assert_eq!(range.start.character, 0);
714 assert_eq!(range.end.line, 0);
715 assert_eq!(range.end.character, 5);
716 }
717
718 #[test]
719 fn test_byte_range_to_lsp_range_invalid() {
720 let text = "Hello";
721 let range = byte_range_to_lsp_range(text, 10..15);
723 assert!(range.is_none());
724 }
725
726 #[test]
727 fn test_byte_range_to_lsp_range_insertion_at_eof() {
728 let text = "Hello\nWorld";
730 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
732
733 assert_eq!(range.start.line, 1);
735 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
737 assert_eq!(range.end.character, 5);
738 }
739
740 #[test]
741 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
742 let text = "Hello\nWorld\n";
744 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
746
747 assert_eq!(range.start.line, 2);
749 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
751 assert_eq!(range.end.character, 0);
752 }
753
754 #[test]
755 fn test_warning_to_code_action_with_fix() {
756 let warning = LintWarning {
757 line: 1,
758 column: 1,
759 end_line: 1,
760 end_column: 5,
761 rule_name: Some("MD001".to_string()),
762 message: "Missing space".to_string(),
763 severity: Severity::Warning,
764 fix: Some(Fix::new(0..5, "Fixed".to_string())),
765 };
766
767 let uri = Url::parse("file:///test.md").unwrap();
768 let document_text = "Hello World";
769
770 let actions = warning_to_code_actions(&warning, &uri, document_text);
771 assert!(!actions.is_empty());
772 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
775 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
776 assert_eq!(action.is_preferred, Some(true));
777
778 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
779 let edits = &changes[&uri];
780 assert_eq!(edits.len(), 1);
781 assert_eq!(edits[0].new_text, "Fixed");
782 }
783
784 #[test]
785 fn test_warning_to_code_action_no_fix() {
786 let warning = LintWarning {
787 line: 1,
788 column: 1,
789 end_line: 1,
790 end_column: 5,
791 rule_name: Some("MD001".to_string()),
792 message: "No fix available".to_string(),
793 severity: Severity::Warning,
794 fix: None,
795 };
796
797 let uri = Url::parse("file:///test.md").unwrap();
798 let document_text = "Hello World";
799
800 let actions = warning_to_code_actions(&warning, &uri, document_text);
801 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
803 }
804
805 #[test]
806 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
807 let warning = LintWarning {
808 line: 2,
809 column: 1,
810 end_line: 2,
811 end_column: 100,
812 rule_name: Some("MD013".to_string()),
813 message: "Line length 95 exceeds 40 characters".to_string(),
814 severity: Severity::Warning,
815 fix: None,
816 };
817
818 let uri = Url::parse("file:///test.md").unwrap();
819 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";
820
821 let actions = warning_to_code_actions(&warning, &uri, document_text);
822 let reflow_action = actions
823 .iter()
824 .find(|action| action.title == "Reflow paragraph")
825 .expect("Expected manual reflow action for MD013");
826
827 let changes = reflow_action
828 .edit
829 .as_ref()
830 .and_then(|edit| edit.changes.as_ref())
831 .expect("Expected edits for reflow action");
832 let file_edits = changes.get(&uri).expect("Expected edits for URI");
833 assert_eq!(file_edits.len(), 1);
834 assert!(
835 file_edits[0]
836 .new_text
837 .lines()
838 .next()
839 .is_some_and(|line| line.starts_with("> ")),
840 "Expected blockquote prefix in reflow output"
841 );
842 }
843
844 #[test]
845 fn test_warning_to_code_action_multiline_fix() {
846 let warning = LintWarning {
847 line: 2,
848 column: 1,
849 end_line: 3,
850 end_column: 5,
851 rule_name: Some("MD001".to_string()),
852 message: "Multiline fix".to_string(),
853 severity: Severity::Warning,
854 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
855 };
856
857 let uri = Url::parse("file:///test.md").unwrap();
858 let document_text = "Hello\nWorld\nTest Line";
859
860 let actions = warning_to_code_actions(&warning, &uri, document_text);
861 assert!(!actions.is_empty());
862 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
865 let edits = &changes[&uri];
866 assert_eq!(edits[0].new_text, "Fixed\nContent");
867 assert_eq!(edits[0].range.start.line, 1);
868 assert_eq!(edits[0].range.start.character, 0);
869 }
870
871 #[test]
872 fn test_warning_to_code_action_atomic_with_additional_edits() {
873 let document_text = "See [docs](https://example.com) for details.\n";
879 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
880 let primary_end = document_text.find(" for details").unwrap();
881 let appended = "\n[docs]: https://example.com\n".to_string();
882
883 let warning = LintWarning {
884 line: 1,
885 column: primary_start + 1,
886 end_line: 1,
887 end_column: primary_end + 1,
888 rule_name: Some("MD054".to_string()),
889 message: "Inconsistent link style".to_string(),
890 severity: Severity::Warning,
891 fix: Some(Fix::with_additional_edits(
892 primary_start..primary_end,
893 "[docs]".to_string(),
894 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
895 )),
896 };
897
898 let uri = Url::parse("file:///test.md").unwrap();
899 let actions = warning_to_code_actions(&warning, &uri, document_text);
900
901 let fix_action = actions
902 .iter()
903 .find(|a| a.is_preferred == Some(true))
904 .expect("expected a preferred fix code action for MD054 ref-emit warning");
905 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
906
907 let edits = fix_action
908 .edit
909 .as_ref()
910 .and_then(|w| w.changes.as_ref())
911 .and_then(|c| c.get(&uri))
912 .expect("WorkspaceEdit should carry edits keyed by the document URI");
913
914 assert_eq!(
915 edits.len(),
916 2,
917 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
918 );
919 assert_eq!(edits[0].new_text, "[docs]");
920 assert_eq!(edits[1].new_text, appended);
921
922 assert_eq!(edits[1].range.start, edits[1].range.end);
924 }
925
926 #[test]
927 fn test_code_description_url_generation() {
928 let warning = LintWarning {
929 line: 1,
930 column: 1,
931 end_line: 1,
932 end_column: 5,
933 rule_name: Some("MD013".to_string()),
934 message: "Line too long".to_string(),
935 severity: Severity::Warning,
936 fix: None,
937 };
938
939 let diagnostic = warning_to_diagnostic(&warning);
940 assert!(diagnostic.code_description.is_some());
941
942 let url = diagnostic.code_description.unwrap().href;
943 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
944 }
945
946 #[test]
947 fn test_no_url_for_code_block_tool_warnings() {
948 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
951 let warning = LintWarning {
952 line: 1,
953 column: 1,
954 end_line: 1,
955 end_column: 10,
956 rule_name: Some(tool_name.to_string()),
957 message: "some tool warning".to_string(),
958 severity: Severity::Warning,
959 fix: None,
960 };
961
962 let diagnostic = warning_to_diagnostic(&warning);
963 assert!(
964 diagnostic.code_description.is_none(),
965 "Expected no URL for tool name '{tool_name}', but got one",
966 );
967 }
968 }
969
970 #[test]
971 fn test_lsp_config_partial_deserialization() {
972 let json = r#"{"enableLinting": false}"#;
974 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
975
976 assert!(!config.enable_linting);
977 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
980
981 #[test]
982 fn test_configuration_preference_serialization() {
983 let pref = ConfigurationPreference::EditorFirst;
985 let json = serde_json::to_string(&pref).unwrap();
986 assert_eq!(json, "\"editorFirst\"");
987
988 let pref = ConfigurationPreference::FilesystemFirst;
990 let json = serde_json::to_string(&pref).unwrap();
991 assert_eq!(json, "\"filesystemFirst\"");
992
993 let pref = ConfigurationPreference::EditorOnly;
995 let json = serde_json::to_string(&pref).unwrap();
996 assert_eq!(json, "\"editorOnly\"");
997
998 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
1000 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
1001 }
1002
1003 #[test]
1004 fn test_lsp_rule_settings_deserialization() {
1005 let json = r#"{
1007 "lineLength": 120,
1008 "disable": ["MD001", "MD002"],
1009 "enable": ["MD013"]
1010 }"#;
1011 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1012
1013 assert_eq!(settings.line_length, Some(120));
1014 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
1015 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
1016 }
1017
1018 #[test]
1019 fn test_lsp_rule_settings_with_per_rule_config() {
1020 let json = r#"{
1022 "lineLength": 80,
1023 "MD013": {
1024 "lineLength": 120,
1025 "codeBlocks": false
1026 },
1027 "MD024": {
1028 "siblingsOnly": true
1029 }
1030 }"#;
1031 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1032
1033 assert_eq!(settings.line_length, Some(80));
1034
1035 let md013 = settings.rules.get("MD013").unwrap();
1037 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1038 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1039
1040 let md024 = settings.rules.get("MD024").unwrap();
1042 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1043 }
1044
1045 #[test]
1046 fn test_full_lsp_config_with_settings() {
1047 let json = r#"{
1049 "configPath": "/path/to/config",
1050 "enableLinting": true,
1051 "enableAutoFix": false,
1052 "configurationPreference": "editorFirst",
1053 "settings": {
1054 "lineLength": 100,
1055 "disable": ["MD033"],
1056 "MD013": {
1057 "lineLength": 120,
1058 "tables": false
1059 }
1060 }
1061 }"#;
1062 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1063
1064 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1065 assert!(config.enable_linting);
1066 assert!(!config.enable_auto_fix);
1067 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1068
1069 let settings = config.settings.unwrap();
1070 assert_eq!(settings.line_length, Some(100));
1071 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1072
1073 let md013 = settings.rules.get("MD013").unwrap();
1074 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1075 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1076 }
1077
1078 #[test]
1079 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1080 let warning = LintWarning {
1081 line: 5,
1082 column: 1,
1083 end_line: 5,
1084 end_column: 50,
1085 rule_name: Some("MD013".to_string()),
1086 message: "Line too long".to_string(),
1087 severity: Severity::Warning,
1088 fix: None,
1089 };
1090
1091 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1092 let uri = Url::parse("file:///test.md").unwrap();
1093
1094 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1095
1096 assert_eq!(action.title, "Ignore MD013 for this line");
1097 assert_eq!(action.is_preferred, Some(false));
1098 assert!(action.edit.is_some());
1099
1100 let edit = action.edit.unwrap();
1102 let changes = edit.changes.unwrap();
1103 let file_edits = changes.get(&uri).unwrap();
1104
1105 assert_eq!(file_edits.len(), 1);
1106 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
1107 assert!(!file_edits[0].new_text.contains("markdownlint"));
1108
1109 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1113
1114 #[test]
1115 fn test_create_ignore_line_action_no_duplicate() {
1116 let warning = LintWarning {
1117 line: 1,
1118 column: 1,
1119 end_line: 1,
1120 end_column: 50,
1121 rule_name: Some("MD013".to_string()),
1122 message: "Line too long".to_string(),
1123 severity: Severity::Warning,
1124 fix: None,
1125 };
1126
1127 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1129 let uri = Url::parse("file:///test.md").unwrap();
1130
1131 let action = create_ignore_line_action(&warning, &uri, document);
1132
1133 assert!(action.is_none());
1135 }
1136
1137 #[test]
1138 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1139 let warning = LintWarning {
1140 line: 1,
1141 column: 1,
1142 end_line: 1,
1143 end_column: 50,
1144 rule_name: Some("MD013".to_string()),
1145 message: "Line too long".to_string(),
1146 severity: Severity::Warning,
1147 fix: None,
1148 };
1149
1150 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1152 let uri = Url::parse("file:///test.md").unwrap();
1153
1154 let action = create_ignore_line_action(&warning, &uri, document);
1155
1156 assert!(action.is_none());
1158 }
1159
1160 #[test]
1161 fn test_warning_to_code_actions_with_fix() {
1162 let warning = LintWarning {
1163 line: 1,
1164 column: 1,
1165 end_line: 1,
1166 end_column: 5,
1167 rule_name: Some("MD009".to_string()),
1168 message: "Trailing spaces".to_string(),
1169 severity: Severity::Warning,
1170 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1171 };
1172
1173 let uri = Url::parse("file:///test.md").unwrap();
1174 let document_text = "Hello \nWorld";
1175
1176 let actions = warning_to_code_actions(&warning, &uri, document_text);
1177
1178 assert_eq!(actions.len(), 2);
1180
1181 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1183 assert_eq!(actions[0].is_preferred, Some(true));
1184
1185 assert_eq!(actions[1].title, "Ignore MD009 for this line");
1187 assert_eq!(actions[1].is_preferred, Some(false));
1188 }
1189
1190 #[test]
1191 fn test_warning_to_code_actions_no_fix() {
1192 let warning = LintWarning {
1193 line: 1,
1194 column: 1,
1195 end_line: 1,
1196 end_column: 10,
1197 rule_name: Some("MD033".to_string()),
1198 message: "Inline HTML".to_string(),
1199 severity: Severity::Warning,
1200 fix: None,
1201 };
1202
1203 let uri = Url::parse("file:///test.md").unwrap();
1204 let document_text = "<div>HTML</div>";
1205
1206 let actions = warning_to_code_actions(&warning, &uri, document_text);
1207
1208 assert_eq!(actions.len(), 1);
1210 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1211 assert_eq!(actions[0].is_preferred, Some(false));
1212 }
1213
1214 #[test]
1215 fn test_warning_to_code_actions_no_rule_name() {
1216 let warning = LintWarning {
1217 line: 1,
1218 column: 1,
1219 end_line: 1,
1220 end_column: 5,
1221 rule_name: None,
1222 message: "Generic warning".to_string(),
1223 severity: Severity::Warning,
1224 fix: None,
1225 };
1226
1227 let uri = Url::parse("file:///test.md").unwrap();
1228 let document_text = "Hello World";
1229
1230 let actions = warning_to_code_actions(&warning, &uri, document_text);
1231
1232 assert_eq!(actions.len(), 0);
1234 }
1235
1236 #[test]
1237 fn test_legacy_warning_to_code_action_compatibility() {
1238 let warning = LintWarning {
1239 line: 1,
1240 column: 1,
1241 end_line: 1,
1242 end_column: 5,
1243 rule_name: Some("MD001".to_string()),
1244 message: "Test".to_string(),
1245 severity: Severity::Warning,
1246 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1247 };
1248
1249 let uri = Url::parse("file:///test.md").unwrap();
1250 let document_text = "Hello World";
1251
1252 #[allow(deprecated)]
1253 let action = warning_to_code_action(&warning, &uri, document_text);
1254
1255 assert!(action.is_some());
1257 let action = action.unwrap();
1258 assert_eq!(action.title, "Fix: Test");
1259 assert_eq!(action.is_preferred, Some(true));
1260 }
1261
1262 #[test]
1263 fn test_md034_convert_to_link_action() {
1264 let warning = LintWarning {
1266 line: 1,
1267 column: 1,
1268 end_line: 1,
1269 end_column: 25,
1270 rule_name: Some("MD034".to_string()),
1271 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1272 severity: Severity::Warning,
1273 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1274 };
1275
1276 let uri = Url::parse("file:///test.md").unwrap();
1277 let document_text = "https://example.com is a test URL";
1278
1279 let actions = warning_to_code_actions(&warning, &uri, document_text);
1280
1281 assert_eq!(actions.len(), 3);
1283
1284 assert_eq!(
1286 actions[0].title,
1287 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1288 );
1289 assert_eq!(actions[0].is_preferred, Some(true));
1290
1291 assert_eq!(actions[1].title, "Convert to markdown link");
1293 assert_eq!(actions[1].is_preferred, Some(false));
1294
1295 let edit = actions[1].edit.as_ref().unwrap();
1297 let changes = edit.changes.as_ref().unwrap();
1298 let file_edits = changes.get(&uri).unwrap();
1299 assert_eq!(file_edits.len(), 1);
1300
1301 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1303
1304 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1306 }
1307
1308 #[test]
1309 fn test_md034_convert_to_link_action_email() {
1310 let warning = LintWarning {
1312 line: 1,
1313 column: 1,
1314 end_line: 1,
1315 end_column: 20,
1316 rule_name: Some("MD034".to_string()),
1317 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1318 severity: Severity::Warning,
1319 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1320 };
1321
1322 let uri = Url::parse("file:///test.md").unwrap();
1323 let document_text = "user@example.com is my email";
1324
1325 let actions = warning_to_code_actions(&warning, &uri, document_text);
1326
1327 assert_eq!(actions.len(), 3);
1329
1330 assert_eq!(actions[1].title, "Convert to markdown link");
1332
1333 let edit = actions[1].edit.as_ref().unwrap();
1334 let changes = edit.changes.as_ref().unwrap();
1335 let file_edits = changes.get(&uri).unwrap();
1336
1337 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1339 }
1340
1341 #[test]
1342 fn test_extract_url_from_fix_replacement() {
1343 assert_eq!(
1344 extract_url_from_fix_replacement("<https://example.com>"),
1345 Some("https://example.com")
1346 );
1347 assert_eq!(
1348 extract_url_from_fix_replacement("<user@example.com>"),
1349 Some("user@example.com")
1350 );
1351 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1352 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1353 }
1354
1355 #[test]
1356 fn test_extract_domain_for_placeholder() {
1357 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1358 assert_eq!(
1359 extract_domain_for_placeholder("https://example.com/path/to/page"),
1360 "example.com"
1361 );
1362 assert_eq!(
1363 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1364 "sub.example.com:8080"
1365 );
1366 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1367 assert_eq!(
1368 extract_domain_for_placeholder("ftp://files.example.com"),
1369 "files.example.com"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_byte_range_to_lsp_range_trailing_newlines() {
1375 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1380 assert!(range.is_some());
1381 let range = range.unwrap();
1382
1383 assert_eq!(range.start.line, 2);
1386 assert_eq!(range.start.character, 0);
1387 assert_eq!(range.end.line, 3);
1388 assert_eq!(range.end.character, 0);
1389 }
1390
1391 #[test]
1392 fn test_byte_range_to_lsp_range_at_eof() {
1393 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1398 assert!(range.is_some());
1399 let range = range.unwrap();
1400
1401 assert_eq!(range.start.line, 1);
1403 assert_eq!(range.start.character, 0);
1404 }
1405}