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 pub enable_symbols: bool,
120 pub link_completion_content_roots: Vec<String>,
124}
125
126impl Default for RumdlLspConfig {
127 fn default() -> Self {
128 Self {
129 config_path: None,
130 enable_linting: true,
131 enable_auto_fix: false,
132 enable_rules: None,
133 disable_rules: None,
134 configuration_preference: ConfigurationPreference::default(),
135 settings: None,
136 enable_link_completions: true,
137 enable_link_navigation: true,
138 enable_symbols: true,
139 link_completion_content_roots: Vec::new(),
140 }
141 }
142}
143
144pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
146 let start_position = Position {
147 line: (warning.line.saturating_sub(1)) as u32,
148 character: (warning.column.saturating_sub(1)) as u32,
149 };
150
151 let end_position = Position {
153 line: (warning.end_line.saturating_sub(1)) as u32,
154 character: (warning.end_column.saturating_sub(1)) as u32,
155 };
156
157 let severity = match warning.severity {
158 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
159 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
160 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
161 };
162
163 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
166 let is_rumdl_rule = rule_name.len() > 2
167 && rule_name[..2].eq_ignore_ascii_case("MD")
168 && rule_name[2..].chars().all(|c| c.is_ascii_digit());
169 if is_rumdl_rule {
170 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
171 .ok()
172 .map(|href| CodeDescription { href })
173 } else {
174 None
175 }
176 });
177
178 Diagnostic {
179 range: Range {
180 start: start_position,
181 end: end_position,
182 },
183 severity: Some(severity),
184 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
185 source: Some("rumdl".to_string()),
186 message: warning.message.clone(),
187 related_information: None,
188 tags: None,
189 code_description,
190 data: None,
191 }
192}
193
194fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
202 let mut line = 0u32;
203 let mut character = 0u32;
204 let mut byte_pos = 0;
205
206 let mut start_pos = None;
207 let mut end_pos = None;
208
209 for ch in text.chars() {
210 if byte_pos == byte_range.start {
211 start_pos = Some(Position { line, character });
212 }
213 if byte_pos == byte_range.end {
214 end_pos = Some(Position { line, character });
215 break;
216 }
217
218 if ch == '\n' {
219 line += 1;
220 character = 0;
221 } else {
222 character += ch.len_utf16() as u32;
223 }
224
225 byte_pos += ch.len_utf8();
226 }
227
228 if start_pos.is_none() && byte_pos >= byte_range.start {
231 start_pos = Some(Position { line, character });
232 }
233 if end_pos.is_none() && byte_pos >= byte_range.end {
234 end_pos = Some(Position { line, character });
235 }
236
237 match (start_pos, end_pos) {
238 (Some(start), Some(end)) => Some(Range { start, end }),
239 _ => {
240 log::warn!(
243 "Failed to convert byte range {:?} to LSP range for text of length {}",
244 byte_range,
245 text.len()
246 );
247 None
248 }
249 }
250}
251
252pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
255 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
256}
257
258pub(crate) fn warning_to_code_actions_with_md013_config(
262 warning: &crate::rule::LintWarning,
263 uri: &Url,
264 document_text: &str,
265 md013_config: Option<&MD013Config>,
266) -> Vec<CodeAction> {
267 let mut actions = Vec::new();
268
269 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
271 actions.push(fix_action);
272 }
273
274 if warning.rule_name.as_deref() == Some("MD013")
277 && warning.fix.is_none()
278 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
279 {
280 actions.push(reflow_action);
281 }
282
283 if warning.rule_name.as_deref() == Some("MD034")
286 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
287 {
288 actions.push(convert_action);
289 }
290
291 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
293 actions.push(ignore_line_action);
294 }
295
296 actions
297}
298
299fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
301 if let Some(fix) = &warning.fix {
302 let primary = TextEdit {
307 range: byte_range_to_lsp_range(document_text, fix.range.clone())?,
308 new_text: fix.replacement.clone(),
309 };
310
311 let mut edits = Vec::with_capacity(1 + fix.additional_edits.len());
312 edits.push(primary);
313 for extra in &fix.additional_edits {
314 edits.push(TextEdit {
315 range: byte_range_to_lsp_range(document_text, extra.range.clone())?,
316 new_text: extra.replacement.clone(),
317 });
318 }
319
320 let mut changes = std::collections::HashMap::new();
321 changes.insert(uri.clone(), edits);
322
323 let workspace_edit = WorkspaceEdit {
324 changes: Some(changes),
325 document_changes: None,
326 change_annotations: None,
327 };
328
329 Some(CodeAction {
330 title: format!("Fix: {}", warning.message),
331 kind: Some(CodeActionKind::QUICKFIX),
332 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
333 edit: Some(workspace_edit),
334 command: None,
335 is_preferred: Some(true),
336 disabled: None,
337 data: None,
338 })
339 } else {
340 None
341 }
342}
343
344fn create_reflow_action(
347 warning: &crate::rule::LintWarning,
348 uri: &Url,
349 document_text: &str,
350 md013_config: Option<&MD013Config>,
351) -> Option<CodeAction> {
352 let options = if let Some(config) = md013_config {
355 config.to_reflow_options()
356 } else {
357 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
358 crate::utils::text_reflow::ReflowOptions {
359 line_length,
360 ..Default::default()
361 }
362 };
363
364 let reflow_result =
366 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
367
368 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
370
371 let edit = TextEdit {
372 range,
373 new_text: reflow_result.reflowed_text,
374 };
375
376 let mut changes = std::collections::HashMap::new();
377 changes.insert(uri.clone(), vec![edit]);
378
379 let workspace_edit = WorkspaceEdit {
380 changes: Some(changes),
381 document_changes: None,
382 change_annotations: None,
383 };
384
385 Some(CodeAction {
386 title: "Reflow paragraph".to_string(),
387 kind: Some(CodeActionKind::QUICKFIX),
388 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
389 edit: Some(workspace_edit),
390 command: None,
391 is_preferred: Some(false), disabled: None,
393 data: None,
394 })
395}
396
397fn extract_line_length_from_message(message: &str) -> Option<usize> {
400 let exceeds_idx = message.find("exceeds")?;
402 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
406
407 num_str.parse::<usize>().ok()
408}
409
410fn create_convert_to_link_action(
414 warning: &crate::rule::LintWarning,
415 uri: &Url,
416 document_text: &str,
417) -> Option<CodeAction> {
418 let fix = warning.fix.as_ref()?;
420
421 let url = extract_url_from_fix_replacement(&fix.replacement)?;
424
425 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
427
428 let link_text = extract_domain_for_placeholder(url);
433 let new_text = format!("[{link_text}]({url})");
434
435 let edit = TextEdit { range, new_text };
436
437 let mut changes = std::collections::HashMap::new();
438 changes.insert(uri.clone(), vec![edit]);
439
440 let workspace_edit = WorkspaceEdit {
441 changes: Some(changes),
442 document_changes: None,
443 change_annotations: None,
444 };
445
446 Some(CodeAction {
447 title: "Convert to markdown link".to_string(),
448 kind: Some(CodeActionKind::QUICKFIX),
449 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
450 edit: Some(workspace_edit),
451 command: None,
452 is_preferred: Some(false), disabled: None,
454 data: None,
455 })
456}
457
458fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
461 let trimmed = replacement.trim();
463 if trimmed.starts_with('<') && trimmed.ends_with('>') {
464 Some(&trimmed[1..trimmed.len() - 1])
465 } else {
466 None
467 }
468}
469
470fn extract_domain_for_placeholder(url: &str) -> &str {
474 if url.contains('@') && !url.contains("://") {
476 return url;
477 }
478
479 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
481}
482
483fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
485 let rule_id = warning.rule_name.as_ref()?;
486 let warning_line = warning.line.saturating_sub(1);
487
488 let lines: Vec<&str> = document_text.lines().collect();
490 let line_content = lines.get(warning_line)?;
491
492 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
494 return None;
496 }
497
498 let line_end = Position {
500 line: warning_line as u32,
501 character: line_content.len() as u32,
502 };
503
504 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
506
507 let edit = TextEdit {
508 range: Range {
509 start: line_end,
510 end: line_end,
511 },
512 new_text: comment,
513 };
514
515 let mut changes = std::collections::HashMap::new();
516 changes.insert(uri.clone(), vec![edit]);
517
518 Some(CodeAction {
519 title: format!("Ignore {rule_id} for this line"),
520 kind: Some(CodeActionKind::QUICKFIX),
521 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
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);
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);
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);
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);
664 assert_eq!(diagnostic.range.start.line, 0);
665 assert_eq!(diagnostic.range.start.character, 0);
666 }
667
668 #[test]
669 fn test_byte_range_to_lsp_range_simple() {
670 let text = "Hello\nWorld";
671 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
672
673 assert_eq!(range.start.line, 0);
674 assert_eq!(range.start.character, 0);
675 assert_eq!(range.end.line, 0);
676 assert_eq!(range.end.character, 5);
677 }
678
679 #[test]
680 fn test_byte_range_to_lsp_range_multiline() {
681 let text = "Hello\nWorld\nTest";
682 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
685 assert_eq!(range.start.character, 0);
686 assert_eq!(range.end.line, 1);
687 assert_eq!(range.end.character, 5);
688 }
689
690 #[test]
691 fn test_byte_range_to_lsp_range_unicode() {
692 let text = "Hello 世界\nTest";
693 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
695
696 assert_eq!(range.start.line, 0);
697 assert_eq!(range.start.character, 6);
698 assert_eq!(range.end.line, 0);
699 assert_eq!(range.end.character, 8); }
701
702 #[test]
703 fn test_byte_range_to_lsp_range_non_bmp_counts_as_surrogate_pair() {
704 let text = "a🎉b"; let range = byte_range_to_lsp_range(text, 5..6).unwrap();
715 assert_eq!(range.start.line, 0);
716 assert_eq!(range.start.character, 3);
718 assert_eq!(range.end.line, 0);
719 assert_eq!(range.end.character, 4);
720 }
721
722 #[test]
723 fn test_byte_range_to_lsp_range_eof() {
724 let text = "Hello";
725 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
726
727 assert_eq!(range.start.line, 0);
728 assert_eq!(range.start.character, 0);
729 assert_eq!(range.end.line, 0);
730 assert_eq!(range.end.character, 5);
731 }
732
733 #[test]
734 fn test_byte_range_to_lsp_range_invalid() {
735 let text = "Hello";
736 let range = byte_range_to_lsp_range(text, 10..15);
738 assert!(range.is_none());
739 }
740
741 #[test]
742 fn test_byte_range_to_lsp_range_insertion_at_eof() {
743 let text = "Hello\nWorld";
745 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
747
748 assert_eq!(range.start.line, 1);
750 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
752 assert_eq!(range.end.character, 5);
753 }
754
755 #[test]
756 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
757 let text = "Hello\nWorld\n";
759 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
761
762 assert_eq!(range.start.line, 2);
764 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
766 assert_eq!(range.end.character, 0);
767 }
768
769 #[test]
770 fn test_warning_to_code_action_with_fix() {
771 let warning = LintWarning {
772 line: 1,
773 column: 1,
774 end_line: 1,
775 end_column: 5,
776 rule_name: Some("MD001".to_string()),
777 message: "Missing space".to_string(),
778 severity: Severity::Warning,
779 fix: Some(Fix::new(0..5, "Fixed".to_string())),
780 };
781
782 let uri = Url::parse("file:///test.md").unwrap();
783 let document_text = "Hello World";
784
785 let actions = warning_to_code_actions(&warning, &uri, document_text);
786 assert!(!actions.is_empty());
787 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
790 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
791 assert_eq!(action.is_preferred, Some(true));
792
793 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
794 let edits = &changes[&uri];
795 assert_eq!(edits.len(), 1);
796 assert_eq!(edits[0].new_text, "Fixed");
797 }
798
799 #[test]
800 fn test_warning_to_code_action_no_fix() {
801 let warning = LintWarning {
802 line: 1,
803 column: 1,
804 end_line: 1,
805 end_column: 5,
806 rule_name: Some("MD001".to_string()),
807 message: "No fix available".to_string(),
808 severity: Severity::Warning,
809 fix: None,
810 };
811
812 let uri = Url::parse("file:///test.md").unwrap();
813 let document_text = "Hello World";
814
815 let actions = warning_to_code_actions(&warning, &uri, document_text);
816 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
818 }
819
820 #[test]
821 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
822 let warning = LintWarning {
823 line: 2,
824 column: 1,
825 end_line: 2,
826 end_column: 100,
827 rule_name: Some("MD013".to_string()),
828 message: "Line length 95 exceeds 40 characters".to_string(),
829 severity: Severity::Warning,
830 fix: None,
831 };
832
833 let uri = Url::parse("file:///test.md").unwrap();
834 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";
835
836 let actions = warning_to_code_actions(&warning, &uri, document_text);
837 let reflow_action = actions
838 .iter()
839 .find(|action| action.title == "Reflow paragraph")
840 .expect("Expected manual reflow action for MD013");
841
842 let changes = reflow_action
843 .edit
844 .as_ref()
845 .and_then(|edit| edit.changes.as_ref())
846 .expect("Expected edits for reflow action");
847 let file_edits = changes.get(&uri).expect("Expected edits for URI");
848 assert_eq!(file_edits.len(), 1);
849 assert!(
850 file_edits[0]
851 .new_text
852 .lines()
853 .next()
854 .is_some_and(|line| line.starts_with("> ")),
855 "Expected blockquote prefix in reflow output"
856 );
857 }
858
859 #[test]
860 fn test_warning_to_code_action_multiline_fix() {
861 let warning = LintWarning {
862 line: 2,
863 column: 1,
864 end_line: 3,
865 end_column: 5,
866 rule_name: Some("MD001".to_string()),
867 message: "Multiline fix".to_string(),
868 severity: Severity::Warning,
869 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
870 };
871
872 let uri = Url::parse("file:///test.md").unwrap();
873 let document_text = "Hello\nWorld\nTest Line";
874
875 let actions = warning_to_code_actions(&warning, &uri, document_text);
876 assert!(!actions.is_empty());
877 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
880 let edits = &changes[&uri];
881 assert_eq!(edits[0].new_text, "Fixed\nContent");
882 assert_eq!(edits[0].range.start.line, 1);
883 assert_eq!(edits[0].range.start.character, 0);
884 }
885
886 #[test]
887 fn test_warning_to_code_action_atomic_with_additional_edits() {
888 let document_text = "See [docs](https://example.com) for details.\n";
894 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
895 let primary_end = document_text.find(" for details").unwrap();
896 let appended = "\n[docs]: https://example.com\n".to_string();
897
898 let warning = LintWarning {
899 line: 1,
900 column: primary_start + 1,
901 end_line: 1,
902 end_column: primary_end + 1,
903 rule_name: Some("MD054".to_string()),
904 message: "Inconsistent link style".to_string(),
905 severity: Severity::Warning,
906 fix: Some(Fix::with_additional_edits(
907 primary_start..primary_end,
908 "[docs]".to_string(),
909 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
910 )),
911 };
912
913 let uri = Url::parse("file:///test.md").unwrap();
914 let actions = warning_to_code_actions(&warning, &uri, document_text);
915
916 let fix_action = actions
917 .iter()
918 .find(|a| a.is_preferred == Some(true))
919 .expect("expected a preferred fix code action for MD054 ref-emit warning");
920 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
921
922 let edits = fix_action
923 .edit
924 .as_ref()
925 .and_then(|w| w.changes.as_ref())
926 .and_then(|c| c.get(&uri))
927 .expect("WorkspaceEdit should carry edits keyed by the document URI");
928
929 assert_eq!(
930 edits.len(),
931 2,
932 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
933 );
934 assert_eq!(edits[0].new_text, "[docs]");
935 assert_eq!(edits[1].new_text, appended);
936
937 assert_eq!(edits[1].range.start, edits[1].range.end);
939 }
940
941 #[test]
942 fn test_code_description_url_generation() {
943 let warning = LintWarning {
944 line: 1,
945 column: 1,
946 end_line: 1,
947 end_column: 5,
948 rule_name: Some("MD013".to_string()),
949 message: "Line too long".to_string(),
950 severity: Severity::Warning,
951 fix: None,
952 };
953
954 let diagnostic = warning_to_diagnostic(&warning);
955 assert!(diagnostic.code_description.is_some());
956
957 let url = diagnostic.code_description.unwrap().href;
958 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
959 }
960
961 #[test]
962 fn test_no_url_for_code_block_tool_warnings() {
963 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
966 let warning = LintWarning {
967 line: 1,
968 column: 1,
969 end_line: 1,
970 end_column: 10,
971 rule_name: Some(tool_name.to_string()),
972 message: "some tool warning".to_string(),
973 severity: Severity::Warning,
974 fix: None,
975 };
976
977 let diagnostic = warning_to_diagnostic(&warning);
978 assert!(
979 diagnostic.code_description.is_none(),
980 "Expected no URL for tool name '{tool_name}', but got one",
981 );
982 }
983 }
984
985 #[test]
986 fn test_lsp_config_partial_deserialization() {
987 let json = r#"{"enableLinting": false}"#;
989 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
990
991 assert!(!config.enable_linting);
992 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
995
996 #[test]
997 fn test_configuration_preference_serialization() {
998 let pref = ConfigurationPreference::EditorFirst;
1000 let json = serde_json::to_string(&pref).unwrap();
1001 assert_eq!(json, "\"editorFirst\"");
1002
1003 let pref = ConfigurationPreference::FilesystemFirst;
1005 let json = serde_json::to_string(&pref).unwrap();
1006 assert_eq!(json, "\"filesystemFirst\"");
1007
1008 let pref = ConfigurationPreference::EditorOnly;
1010 let json = serde_json::to_string(&pref).unwrap();
1011 assert_eq!(json, "\"editorOnly\"");
1012
1013 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
1015 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
1016 }
1017
1018 #[test]
1019 fn test_lsp_rule_settings_deserialization() {
1020 let json = r#"{
1022 "lineLength": 120,
1023 "disable": ["MD001", "MD002"],
1024 "enable": ["MD013"]
1025 }"#;
1026 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1027
1028 assert_eq!(settings.line_length, Some(120));
1029 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
1030 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
1031 }
1032
1033 #[test]
1034 fn test_lsp_rule_settings_with_per_rule_config() {
1035 let json = r#"{
1037 "lineLength": 80,
1038 "MD013": {
1039 "lineLength": 120,
1040 "codeBlocks": false
1041 },
1042 "MD024": {
1043 "siblingsOnly": true
1044 }
1045 }"#;
1046 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1047
1048 assert_eq!(settings.line_length, Some(80));
1049
1050 let md013 = settings.rules.get("MD013").unwrap();
1052 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1053 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1054
1055 let md024 = settings.rules.get("MD024").unwrap();
1057 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1058 }
1059
1060 #[test]
1061 fn test_full_lsp_config_with_settings() {
1062 let json = r#"{
1064 "configPath": "/path/to/config",
1065 "enableLinting": true,
1066 "enableAutoFix": false,
1067 "configurationPreference": "editorFirst",
1068 "settings": {
1069 "lineLength": 100,
1070 "disable": ["MD033"],
1071 "MD013": {
1072 "lineLength": 120,
1073 "tables": false
1074 }
1075 }
1076 }"#;
1077 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1078
1079 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1080 assert!(config.enable_linting);
1081 assert!(!config.enable_auto_fix);
1082 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1083
1084 let settings = config.settings.unwrap();
1085 assert_eq!(settings.line_length, Some(100));
1086 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1087
1088 let md013 = settings.rules.get("MD013").unwrap();
1089 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1090 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1091 }
1092
1093 #[test]
1094 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1095 let warning = LintWarning {
1096 line: 5,
1097 column: 1,
1098 end_line: 5,
1099 end_column: 50,
1100 rule_name: Some("MD013".to_string()),
1101 message: "Line too long".to_string(),
1102 severity: Severity::Warning,
1103 fix: None,
1104 };
1105
1106 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1107 let uri = Url::parse("file:///test.md").unwrap();
1108
1109 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1110
1111 assert_eq!(action.title, "Ignore MD013 for this line");
1112 assert_eq!(action.is_preferred, Some(false));
1113 assert!(action.edit.is_some());
1114
1115 let edit = action.edit.unwrap();
1117 let changes = edit.changes.unwrap();
1118 let file_edits = changes.get(&uri).unwrap();
1119
1120 assert_eq!(file_edits.len(), 1);
1121 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
1122 assert!(!file_edits[0].new_text.contains("markdownlint"));
1123
1124 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1128
1129 #[test]
1130 fn test_create_ignore_line_action_no_duplicate() {
1131 let warning = LintWarning {
1132 line: 1,
1133 column: 1,
1134 end_line: 1,
1135 end_column: 50,
1136 rule_name: Some("MD013".to_string()),
1137 message: "Line too long".to_string(),
1138 severity: Severity::Warning,
1139 fix: None,
1140 };
1141
1142 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1144 let uri = Url::parse("file:///test.md").unwrap();
1145
1146 let action = create_ignore_line_action(&warning, &uri, document);
1147
1148 assert!(action.is_none());
1150 }
1151
1152 #[test]
1153 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1154 let warning = LintWarning {
1155 line: 1,
1156 column: 1,
1157 end_line: 1,
1158 end_column: 50,
1159 rule_name: Some("MD013".to_string()),
1160 message: "Line too long".to_string(),
1161 severity: Severity::Warning,
1162 fix: None,
1163 };
1164
1165 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1167 let uri = Url::parse("file:///test.md").unwrap();
1168
1169 let action = create_ignore_line_action(&warning, &uri, document);
1170
1171 assert!(action.is_none());
1173 }
1174
1175 #[test]
1176 fn test_warning_to_code_actions_with_fix() {
1177 let warning = LintWarning {
1178 line: 1,
1179 column: 1,
1180 end_line: 1,
1181 end_column: 5,
1182 rule_name: Some("MD009".to_string()),
1183 message: "Trailing spaces".to_string(),
1184 severity: Severity::Warning,
1185 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1186 };
1187
1188 let uri = Url::parse("file:///test.md").unwrap();
1189 let document_text = "Hello \nWorld";
1190
1191 let actions = warning_to_code_actions(&warning, &uri, document_text);
1192
1193 assert_eq!(actions.len(), 2);
1195
1196 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1198 assert_eq!(actions[0].is_preferred, Some(true));
1199
1200 assert_eq!(actions[1].title, "Ignore MD009 for this line");
1202 assert_eq!(actions[1].is_preferred, Some(false));
1203 }
1204
1205 #[test]
1206 fn test_warning_to_code_actions_no_fix() {
1207 let warning = LintWarning {
1208 line: 1,
1209 column: 1,
1210 end_line: 1,
1211 end_column: 10,
1212 rule_name: Some("MD033".to_string()),
1213 message: "Inline HTML".to_string(),
1214 severity: Severity::Warning,
1215 fix: None,
1216 };
1217
1218 let uri = Url::parse("file:///test.md").unwrap();
1219 let document_text = "<div>HTML</div>";
1220
1221 let actions = warning_to_code_actions(&warning, &uri, document_text);
1222
1223 assert_eq!(actions.len(), 1);
1225 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1226 assert_eq!(actions[0].is_preferred, Some(false));
1227 }
1228
1229 #[test]
1230 fn test_warning_to_code_actions_no_rule_name() {
1231 let warning = LintWarning {
1232 line: 1,
1233 column: 1,
1234 end_line: 1,
1235 end_column: 5,
1236 rule_name: None,
1237 message: "Generic warning".to_string(),
1238 severity: Severity::Warning,
1239 fix: None,
1240 };
1241
1242 let uri = Url::parse("file:///test.md").unwrap();
1243 let document_text = "Hello World";
1244
1245 let actions = warning_to_code_actions(&warning, &uri, document_text);
1246
1247 assert_eq!(actions.len(), 0);
1249 }
1250
1251 #[test]
1252 fn test_legacy_warning_to_code_action_compatibility() {
1253 let warning = LintWarning {
1254 line: 1,
1255 column: 1,
1256 end_line: 1,
1257 end_column: 5,
1258 rule_name: Some("MD001".to_string()),
1259 message: "Test".to_string(),
1260 severity: Severity::Warning,
1261 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1262 };
1263
1264 let uri = Url::parse("file:///test.md").unwrap();
1265 let document_text = "Hello World";
1266
1267 #[allow(deprecated)]
1268 let action = warning_to_code_action(&warning, &uri, document_text);
1269
1270 assert!(action.is_some());
1272 let action = action.unwrap();
1273 assert_eq!(action.title, "Fix: Test");
1274 assert_eq!(action.is_preferred, Some(true));
1275 }
1276
1277 #[test]
1278 fn test_md034_convert_to_link_action() {
1279 let warning = LintWarning {
1281 line: 1,
1282 column: 1,
1283 end_line: 1,
1284 end_column: 25,
1285 rule_name: Some("MD034".to_string()),
1286 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1287 severity: Severity::Warning,
1288 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1289 };
1290
1291 let uri = Url::parse("file:///test.md").unwrap();
1292 let document_text = "https://example.com is a test URL";
1293
1294 let actions = warning_to_code_actions(&warning, &uri, document_text);
1295
1296 assert_eq!(actions.len(), 3);
1298
1299 assert_eq!(
1301 actions[0].title,
1302 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1303 );
1304 assert_eq!(actions[0].is_preferred, Some(true));
1305
1306 assert_eq!(actions[1].title, "Convert to markdown link");
1308 assert_eq!(actions[1].is_preferred, Some(false));
1309
1310 let edit = actions[1].edit.as_ref().unwrap();
1312 let changes = edit.changes.as_ref().unwrap();
1313 let file_edits = changes.get(&uri).unwrap();
1314 assert_eq!(file_edits.len(), 1);
1315
1316 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1318
1319 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1321 }
1322
1323 #[test]
1324 fn test_md034_convert_to_link_action_email() {
1325 let warning = LintWarning {
1327 line: 1,
1328 column: 1,
1329 end_line: 1,
1330 end_column: 20,
1331 rule_name: Some("MD034".to_string()),
1332 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1333 severity: Severity::Warning,
1334 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1335 };
1336
1337 let uri = Url::parse("file:///test.md").unwrap();
1338 let document_text = "user@example.com is my email";
1339
1340 let actions = warning_to_code_actions(&warning, &uri, document_text);
1341
1342 assert_eq!(actions.len(), 3);
1344
1345 assert_eq!(actions[1].title, "Convert to markdown link");
1347
1348 let edit = actions[1].edit.as_ref().unwrap();
1349 let changes = edit.changes.as_ref().unwrap();
1350 let file_edits = changes.get(&uri).unwrap();
1351
1352 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1354 }
1355
1356 #[test]
1357 fn test_extract_url_from_fix_replacement() {
1358 assert_eq!(
1359 extract_url_from_fix_replacement("<https://example.com>"),
1360 Some("https://example.com")
1361 );
1362 assert_eq!(
1363 extract_url_from_fix_replacement("<user@example.com>"),
1364 Some("user@example.com")
1365 );
1366 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1367 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1368 }
1369
1370 #[test]
1371 fn test_extract_domain_for_placeholder() {
1372 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1373 assert_eq!(
1374 extract_domain_for_placeholder("https://example.com/path/to/page"),
1375 "example.com"
1376 );
1377 assert_eq!(
1378 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1379 "sub.example.com:8080"
1380 );
1381 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1382 assert_eq!(
1383 extract_domain_for_placeholder("ftp://files.example.com"),
1384 "files.example.com"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_byte_range_to_lsp_range_trailing_newlines() {
1390 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1395 assert!(range.is_some());
1396 let range = range.unwrap();
1397
1398 assert_eq!(range.start.line, 2);
1401 assert_eq!(range.start.character, 0);
1402 assert_eq!(range.end.line, 3);
1403 assert_eq!(range.end.character, 0);
1404 }
1405
1406 #[test]
1407 fn test_byte_range_to_lsp_range_at_eof() {
1408 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1413 assert!(range.is_some());
1414 let range = range.unwrap();
1415
1416 assert_eq!(range.start.line, 1);
1418 assert_eq!(range.start.character, 0);
1419 }
1420}