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, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub enum ConfigurationPreference {
55 #[default]
57 EditorFirst,
58 FilesystemFirst,
60 EditorOnly,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(default, rename_all = "camelCase")]
70pub struct LspRuleSettings {
71 pub line_length: Option<usize>,
73 pub disable: Option<Vec<String>>,
75 pub enable: Option<Vec<String>>,
77 #[serde(flatten)]
79 pub rules: std::collections::HashMap<String, serde_json::Value>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(default, rename_all = "camelCase")]
88pub struct RumdlLspConfig {
89 pub config_path: Option<String>,
91 pub enable_linting: bool,
93 pub enable_auto_fix: bool,
95 pub enable_rules: Option<Vec<String>>,
98 pub disable_rules: Option<Vec<String>>,
100 pub configuration_preference: ConfigurationPreference,
102 pub settings: Option<LspRuleSettings>,
105}
106
107impl Default for RumdlLspConfig {
108 fn default() -> Self {
109 Self {
110 config_path: None,
111 enable_linting: true,
112 enable_auto_fix: false,
113 enable_rules: None,
114 disable_rules: None,
115 configuration_preference: ConfigurationPreference::default(),
116 settings: None,
117 }
118 }
119}
120
121pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
123 let start_position = Position {
124 line: (warning.line.saturating_sub(1)) as u32,
125 character: (warning.column.saturating_sub(1)) as u32,
126 };
127
128 let end_position = Position {
130 line: (warning.end_line.saturating_sub(1)) as u32,
131 character: (warning.end_column.saturating_sub(1)) as u32,
132 };
133
134 let severity = match warning.severity {
135 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
136 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
137 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
138 };
139
140 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
142 Url::parse(&format!(
144 "https://github.com/rvben/rumdl/blob/main/docs/{}.md",
145 rule_name.to_lowercase()
146 ))
147 .ok()
148 .map(|href| CodeDescription { href })
149 });
150
151 Diagnostic {
152 range: Range {
153 start: start_position,
154 end: end_position,
155 },
156 severity: Some(severity),
157 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
158 source: Some("rumdl".to_string()),
159 message: warning.message.clone(),
160 related_information: None,
161 tags: None,
162 code_description,
163 data: None,
164 }
165}
166
167fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
169 let mut line = 0u32;
170 let mut character = 0u32;
171 let mut byte_pos = 0;
172
173 let mut start_pos = None;
174 let mut end_pos = None;
175
176 for ch in text.chars() {
177 if byte_pos == byte_range.start {
178 start_pos = Some(Position { line, character });
179 }
180 if byte_pos == byte_range.end {
181 end_pos = Some(Position { line, character });
182 break;
183 }
184
185 if ch == '\n' {
186 line += 1;
187 character = 0;
188 } else {
189 character += 1;
190 }
191
192 byte_pos += ch.len_utf8();
193 }
194
195 if start_pos.is_none() && byte_pos >= byte_range.start {
198 start_pos = Some(Position { line, character });
199 }
200 if end_pos.is_none() && byte_pos >= byte_range.end {
201 end_pos = Some(Position { line, character });
202 }
203
204 match (start_pos, end_pos) {
205 (Some(start), Some(end)) => Some(Range { start, end }),
206 _ => {
207 log::warn!(
210 "Failed to convert byte range {:?} to LSP range for text of length {}",
211 byte_range,
212 text.len()
213 );
214 None
215 }
216 }
217}
218
219pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
222 let mut actions = Vec::new();
223
224 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
226 actions.push(fix_action);
227 }
228
229 if warning.rule_name.as_deref() == Some("MD013")
232 && warning.fix.is_none()
233 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text)
234 {
235 actions.push(reflow_action);
236 }
237
238 if warning.rule_name.as_deref() == Some("MD034")
241 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
242 {
243 actions.push(convert_action);
244 }
245
246 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
248 actions.push(ignore_line_action);
249 }
250
251 actions
252}
253
254fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
256 if let Some(fix) = &warning.fix {
257 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
259
260 let edit = TextEdit {
261 range,
262 new_text: fix.replacement.clone(),
263 };
264
265 let mut changes = std::collections::HashMap::new();
266 changes.insert(uri.clone(), vec![edit]);
267
268 let workspace_edit = WorkspaceEdit {
269 changes: Some(changes),
270 document_changes: None,
271 change_annotations: None,
272 };
273
274 Some(CodeAction {
275 title: format!("Fix: {}", warning.message),
276 kind: Some(CodeActionKind::QUICKFIX),
277 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
278 edit: Some(workspace_edit),
279 command: None,
280 is_preferred: Some(true),
281 disabled: None,
282 data: None,
283 })
284 } else {
285 None
286 }
287}
288
289fn create_reflow_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
292 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
294
295 let reflow_result = crate::utils::text_reflow::reflow_paragraph_at_line(document_text, warning.line, line_length)?;
297
298 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
300
301 let edit = TextEdit {
302 range,
303 new_text: reflow_result.reflowed_text,
304 };
305
306 let mut changes = std::collections::HashMap::new();
307 changes.insert(uri.clone(), vec![edit]);
308
309 let workspace_edit = WorkspaceEdit {
310 changes: Some(changes),
311 document_changes: None,
312 change_annotations: None,
313 };
314
315 Some(CodeAction {
316 title: "Reflow paragraph".to_string(),
317 kind: Some(CodeActionKind::QUICKFIX),
318 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
319 edit: Some(workspace_edit),
320 command: None,
321 is_preferred: Some(false), disabled: None,
323 data: None,
324 })
325}
326
327fn extract_line_length_from_message(message: &str) -> Option<usize> {
330 let exceeds_idx = message.find("exceeds")?;
332 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
336
337 num_str.parse::<usize>().ok()
338}
339
340fn create_convert_to_link_action(
344 warning: &crate::rule::LintWarning,
345 uri: &Url,
346 document_text: &str,
347) -> Option<CodeAction> {
348 let fix = warning.fix.as_ref()?;
350
351 let url = extract_url_from_fix_replacement(&fix.replacement)?;
354
355 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
357
358 let link_text = extract_domain_for_placeholder(url);
363 let new_text = format!("[{link_text}]({url})");
364
365 let edit = TextEdit { range, new_text };
366
367 let mut changes = std::collections::HashMap::new();
368 changes.insert(uri.clone(), vec![edit]);
369
370 let workspace_edit = WorkspaceEdit {
371 changes: Some(changes),
372 document_changes: None,
373 change_annotations: None,
374 };
375
376 Some(CodeAction {
377 title: "Convert to markdown link".to_string(),
378 kind: Some(CodeActionKind::QUICKFIX),
379 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
380 edit: Some(workspace_edit),
381 command: None,
382 is_preferred: Some(false), disabled: None,
384 data: None,
385 })
386}
387
388fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
391 let trimmed = replacement.trim();
393 if trimmed.starts_with('<') && trimmed.ends_with('>') {
394 Some(&trimmed[1..trimmed.len() - 1])
395 } else {
396 None
397 }
398}
399
400fn extract_domain_for_placeholder(url: &str) -> &str {
404 if url.contains('@') && !url.contains("://") {
406 return url;
407 }
408
409 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
411}
412
413fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
415 let rule_id = warning.rule_name.as_ref()?;
416 let warning_line = warning.line.saturating_sub(1);
417
418 let lines: Vec<&str> = document_text.lines().collect();
420 let line_content = lines.get(warning_line)?;
421
422 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
424 return None;
426 }
427
428 let line_end = Position {
430 line: warning_line as u32,
431 character: line_content.len() as u32,
432 };
433
434 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
436
437 let edit = TextEdit {
438 range: Range {
439 start: line_end,
440 end: line_end,
441 },
442 new_text: comment,
443 };
444
445 let mut changes = std::collections::HashMap::new();
446 changes.insert(uri.clone(), vec![edit]);
447
448 Some(CodeAction {
449 title: format!("Ignore {rule_id} for this line"),
450 kind: Some(CodeActionKind::QUICKFIX),
451 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
452 edit: Some(WorkspaceEdit {
453 changes: Some(changes),
454 document_changes: None,
455 change_annotations: None,
456 }),
457 command: None,
458 is_preferred: Some(false), disabled: None,
460 data: None,
461 })
462}
463
464#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
467pub fn warning_to_code_action(
468 warning: &crate::rule::LintWarning,
469 uri: &Url,
470 document_text: &str,
471) -> Option<CodeAction> {
472 warning_to_code_actions(warning, uri, document_text)
473 .into_iter()
474 .find(|action| action.is_preferred == Some(true))
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::rule::{Fix, LintWarning, Severity};
481
482 #[test]
483 fn test_rumdl_lsp_config_default() {
484 let config = RumdlLspConfig::default();
485 assert_eq!(config.config_path, None);
486 assert!(config.enable_linting);
487 assert!(!config.enable_auto_fix);
488 }
489
490 #[test]
491 fn test_rumdl_lsp_config_serialization() {
492 let config = RumdlLspConfig {
493 config_path: Some("/path/to/config.toml".to_string()),
494 enable_linting: false,
495 enable_auto_fix: true,
496 enable_rules: None,
497 disable_rules: None,
498 configuration_preference: ConfigurationPreference::EditorFirst,
499 settings: None,
500 };
501
502 let json = serde_json::to_string(&config).unwrap();
504 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
505 assert!(json.contains("\"enableLinting\":false"));
506 assert!(json.contains("\"enableAutoFix\":true"));
507
508 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
510 assert_eq!(deserialized.config_path, config.config_path);
511 assert_eq!(deserialized.enable_linting, config.enable_linting);
512 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
513 }
514
515 #[test]
516 fn test_warning_to_diagnostic_basic() {
517 let warning = LintWarning {
518 line: 5,
519 column: 10,
520 end_line: 5,
521 end_column: 15,
522 rule_name: Some("MD001".to_string()),
523 message: "Test warning message".to_string(),
524 severity: Severity::Warning,
525 fix: None,
526 };
527
528 let diagnostic = warning_to_diagnostic(&warning);
529
530 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
533 assert_eq!(diagnostic.range.end.character, 14);
534 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
535 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
536 assert_eq!(diagnostic.message, "Test warning message");
537 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
538 }
539
540 #[test]
541 fn test_warning_to_diagnostic_error_severity() {
542 let warning = LintWarning {
543 line: 1,
544 column: 1,
545 end_line: 1,
546 end_column: 5,
547 rule_name: Some("MD002".to_string()),
548 message: "Error message".to_string(),
549 severity: Severity::Error,
550 fix: None,
551 };
552
553 let diagnostic = warning_to_diagnostic(&warning);
554 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
555 }
556
557 #[test]
558 fn test_warning_to_diagnostic_no_rule_name() {
559 let warning = LintWarning {
560 line: 1,
561 column: 1,
562 end_line: 1,
563 end_column: 5,
564 rule_name: None,
565 message: "Generic warning".to_string(),
566 severity: Severity::Warning,
567 fix: None,
568 };
569
570 let diagnostic = warning_to_diagnostic(&warning);
571 assert_eq!(diagnostic.code, None);
572 assert!(diagnostic.code_description.is_none());
573 }
574
575 #[test]
576 fn test_warning_to_diagnostic_edge_cases() {
577 let warning = LintWarning {
579 line: 0,
580 column: 0,
581 end_line: 0,
582 end_column: 0,
583 rule_name: Some("MD001".to_string()),
584 message: "Edge case".to_string(),
585 severity: Severity::Warning,
586 fix: None,
587 };
588
589 let diagnostic = warning_to_diagnostic(&warning);
590 assert_eq!(diagnostic.range.start.line, 0);
591 assert_eq!(diagnostic.range.start.character, 0);
592 }
593
594 #[test]
595 fn test_byte_range_to_lsp_range_simple() {
596 let text = "Hello\nWorld";
597 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
598
599 assert_eq!(range.start.line, 0);
600 assert_eq!(range.start.character, 0);
601 assert_eq!(range.end.line, 0);
602 assert_eq!(range.end.character, 5);
603 }
604
605 #[test]
606 fn test_byte_range_to_lsp_range_multiline() {
607 let text = "Hello\nWorld\nTest";
608 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
611 assert_eq!(range.start.character, 0);
612 assert_eq!(range.end.line, 1);
613 assert_eq!(range.end.character, 5);
614 }
615
616 #[test]
617 fn test_byte_range_to_lsp_range_unicode() {
618 let text = "Hello 世界\nTest";
619 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
621
622 assert_eq!(range.start.line, 0);
623 assert_eq!(range.start.character, 6);
624 assert_eq!(range.end.line, 0);
625 assert_eq!(range.end.character, 8); }
627
628 #[test]
629 fn test_byte_range_to_lsp_range_eof() {
630 let text = "Hello";
631 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
632
633 assert_eq!(range.start.line, 0);
634 assert_eq!(range.start.character, 0);
635 assert_eq!(range.end.line, 0);
636 assert_eq!(range.end.character, 5);
637 }
638
639 #[test]
640 fn test_byte_range_to_lsp_range_invalid() {
641 let text = "Hello";
642 let range = byte_range_to_lsp_range(text, 10..15);
644 assert!(range.is_none());
645 }
646
647 #[test]
648 fn test_byte_range_to_lsp_range_insertion_at_eof() {
649 let text = "Hello\nWorld";
651 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
653
654 assert_eq!(range.start.line, 1);
656 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
658 assert_eq!(range.end.character, 5);
659 }
660
661 #[test]
662 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
663 let text = "Hello\nWorld\n";
665 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
667
668 assert_eq!(range.start.line, 2);
670 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
672 assert_eq!(range.end.character, 0);
673 }
674
675 #[test]
676 fn test_warning_to_code_action_with_fix() {
677 let warning = LintWarning {
678 line: 1,
679 column: 1,
680 end_line: 1,
681 end_column: 5,
682 rule_name: Some("MD001".to_string()),
683 message: "Missing space".to_string(),
684 severity: Severity::Warning,
685 fix: Some(Fix {
686 range: 0..5,
687 replacement: "Fixed".to_string(),
688 }),
689 };
690
691 let uri = Url::parse("file:///test.md").unwrap();
692 let document_text = "Hello World";
693
694 let actions = warning_to_code_actions(&warning, &uri, document_text);
695 assert!(!actions.is_empty());
696 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
699 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
700 assert_eq!(action.is_preferred, Some(true));
701
702 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
703 let edits = &changes[&uri];
704 assert_eq!(edits.len(), 1);
705 assert_eq!(edits[0].new_text, "Fixed");
706 }
707
708 #[test]
709 fn test_warning_to_code_action_no_fix() {
710 let warning = LintWarning {
711 line: 1,
712 column: 1,
713 end_line: 1,
714 end_column: 5,
715 rule_name: Some("MD001".to_string()),
716 message: "No fix available".to_string(),
717 severity: Severity::Warning,
718 fix: None,
719 };
720
721 let uri = Url::parse("file:///test.md").unwrap();
722 let document_text = "Hello World";
723
724 let actions = warning_to_code_actions(&warning, &uri, document_text);
725 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
727 }
728
729 #[test]
730 fn test_warning_to_code_action_multiline_fix() {
731 let warning = LintWarning {
732 line: 2,
733 column: 1,
734 end_line: 3,
735 end_column: 5,
736 rule_name: Some("MD001".to_string()),
737 message: "Multiline fix".to_string(),
738 severity: Severity::Warning,
739 fix: Some(Fix {
740 range: 6..16, replacement: "Fixed\nContent".to_string(),
742 }),
743 };
744
745 let uri = Url::parse("file:///test.md").unwrap();
746 let document_text = "Hello\nWorld\nTest Line";
747
748 let actions = warning_to_code_actions(&warning, &uri, document_text);
749 assert!(!actions.is_empty());
750 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
753 let edits = &changes[&uri];
754 assert_eq!(edits[0].new_text, "Fixed\nContent");
755 assert_eq!(edits[0].range.start.line, 1);
756 assert_eq!(edits[0].range.start.character, 0);
757 }
758
759 #[test]
760 fn test_code_description_url_generation() {
761 let warning = LintWarning {
762 line: 1,
763 column: 1,
764 end_line: 1,
765 end_column: 5,
766 rule_name: Some("MD013".to_string()),
767 message: "Line too long".to_string(),
768 severity: Severity::Warning,
769 fix: None,
770 };
771
772 let diagnostic = warning_to_diagnostic(&warning);
773 assert!(diagnostic.code_description.is_some());
774
775 let url = diagnostic.code_description.unwrap().href;
776 assert_eq!(url.as_str(), "https://github.com/rvben/rumdl/blob/main/docs/md013.md");
777 }
778
779 #[test]
780 fn test_lsp_config_partial_deserialization() {
781 let json = r#"{"enableLinting": false}"#;
783 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
784
785 assert!(!config.enable_linting);
786 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
789
790 #[test]
791 fn test_configuration_preference_serialization() {
792 let pref = ConfigurationPreference::EditorFirst;
794 let json = serde_json::to_string(&pref).unwrap();
795 assert_eq!(json, "\"editorFirst\"");
796
797 let pref = ConfigurationPreference::FilesystemFirst;
799 let json = serde_json::to_string(&pref).unwrap();
800 assert_eq!(json, "\"filesystemFirst\"");
801
802 let pref = ConfigurationPreference::EditorOnly;
804 let json = serde_json::to_string(&pref).unwrap();
805 assert_eq!(json, "\"editorOnly\"");
806
807 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
809 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
810 }
811
812 #[test]
813 fn test_lsp_rule_settings_deserialization() {
814 let json = r#"{
816 "lineLength": 120,
817 "disable": ["MD001", "MD002"],
818 "enable": ["MD013"]
819 }"#;
820 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
821
822 assert_eq!(settings.line_length, Some(120));
823 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
824 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
825 }
826
827 #[test]
828 fn test_lsp_rule_settings_with_per_rule_config() {
829 let json = r#"{
831 "lineLength": 80,
832 "MD013": {
833 "lineLength": 120,
834 "codeBlocks": false
835 },
836 "MD024": {
837 "siblingsOnly": true
838 }
839 }"#;
840 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
841
842 assert_eq!(settings.line_length, Some(80));
843
844 let md013 = settings.rules.get("MD013").unwrap();
846 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
847 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
848
849 let md024 = settings.rules.get("MD024").unwrap();
851 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
852 }
853
854 #[test]
855 fn test_full_lsp_config_with_settings() {
856 let json = r#"{
858 "configPath": "/path/to/config",
859 "enableLinting": true,
860 "enableAutoFix": false,
861 "configurationPreference": "editorFirst",
862 "settings": {
863 "lineLength": 100,
864 "disable": ["MD033"],
865 "MD013": {
866 "lineLength": 120,
867 "tables": false
868 }
869 }
870 }"#;
871 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
872
873 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
874 assert!(config.enable_linting);
875 assert!(!config.enable_auto_fix);
876 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
877
878 let settings = config.settings.unwrap();
879 assert_eq!(settings.line_length, Some(100));
880 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
881
882 let md013 = settings.rules.get("MD013").unwrap();
883 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
884 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
885 }
886
887 #[test]
888 fn test_create_ignore_line_action_uses_rumdl_syntax() {
889 let warning = LintWarning {
890 line: 5,
891 column: 1,
892 end_line: 5,
893 end_column: 50,
894 rule_name: Some("MD013".to_string()),
895 message: "Line too long".to_string(),
896 severity: Severity::Warning,
897 fix: None,
898 };
899
900 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
901 let uri = Url::parse("file:///test.md").unwrap();
902
903 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
904
905 assert_eq!(action.title, "Ignore MD013 for this line");
906 assert_eq!(action.is_preferred, Some(false));
907 assert!(action.edit.is_some());
908
909 let edit = action.edit.unwrap();
911 let changes = edit.changes.unwrap();
912 let file_edits = changes.get(&uri).unwrap();
913
914 assert_eq!(file_edits.len(), 1);
915 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
916 assert!(!file_edits[0].new_text.contains("markdownlint"));
917
918 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
922
923 #[test]
924 fn test_create_ignore_line_action_no_duplicate() {
925 let warning = LintWarning {
926 line: 1,
927 column: 1,
928 end_line: 1,
929 end_column: 50,
930 rule_name: Some("MD013".to_string()),
931 message: "Line too long".to_string(),
932 severity: Severity::Warning,
933 fix: None,
934 };
935
936 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
938 let uri = Url::parse("file:///test.md").unwrap();
939
940 let action = create_ignore_line_action(&warning, &uri, document);
941
942 assert!(action.is_none());
944 }
945
946 #[test]
947 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
948 let warning = LintWarning {
949 line: 1,
950 column: 1,
951 end_line: 1,
952 end_column: 50,
953 rule_name: Some("MD013".to_string()),
954 message: "Line too long".to_string(),
955 severity: Severity::Warning,
956 fix: None,
957 };
958
959 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
961 let uri = Url::parse("file:///test.md").unwrap();
962
963 let action = create_ignore_line_action(&warning, &uri, document);
964
965 assert!(action.is_none());
967 }
968
969 #[test]
970 fn test_warning_to_code_actions_with_fix() {
971 let warning = LintWarning {
972 line: 1,
973 column: 1,
974 end_line: 1,
975 end_column: 5,
976 rule_name: Some("MD009".to_string()),
977 message: "Trailing spaces".to_string(),
978 severity: Severity::Warning,
979 fix: Some(Fix {
980 range: 0..5,
981 replacement: "Fixed".to_string(),
982 }),
983 };
984
985 let uri = Url::parse("file:///test.md").unwrap();
986 let document_text = "Hello \nWorld";
987
988 let actions = warning_to_code_actions(&warning, &uri, document_text);
989
990 assert_eq!(actions.len(), 2);
992
993 assert_eq!(actions[0].title, "Fix: Trailing spaces");
995 assert_eq!(actions[0].is_preferred, Some(true));
996
997 assert_eq!(actions[1].title, "Ignore MD009 for this line");
999 assert_eq!(actions[1].is_preferred, Some(false));
1000 }
1001
1002 #[test]
1003 fn test_warning_to_code_actions_no_fix() {
1004 let warning = LintWarning {
1005 line: 1,
1006 column: 1,
1007 end_line: 1,
1008 end_column: 10,
1009 rule_name: Some("MD033".to_string()),
1010 message: "Inline HTML".to_string(),
1011 severity: Severity::Warning,
1012 fix: None,
1013 };
1014
1015 let uri = Url::parse("file:///test.md").unwrap();
1016 let document_text = "<div>HTML</div>";
1017
1018 let actions = warning_to_code_actions(&warning, &uri, document_text);
1019
1020 assert_eq!(actions.len(), 1);
1022 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1023 assert_eq!(actions[0].is_preferred, Some(false));
1024 }
1025
1026 #[test]
1027 fn test_warning_to_code_actions_no_rule_name() {
1028 let warning = LintWarning {
1029 line: 1,
1030 column: 1,
1031 end_line: 1,
1032 end_column: 5,
1033 rule_name: None,
1034 message: "Generic warning".to_string(),
1035 severity: Severity::Warning,
1036 fix: None,
1037 };
1038
1039 let uri = Url::parse("file:///test.md").unwrap();
1040 let document_text = "Hello World";
1041
1042 let actions = warning_to_code_actions(&warning, &uri, document_text);
1043
1044 assert_eq!(actions.len(), 0);
1046 }
1047
1048 #[test]
1049 fn test_legacy_warning_to_code_action_compatibility() {
1050 let warning = LintWarning {
1051 line: 1,
1052 column: 1,
1053 end_line: 1,
1054 end_column: 5,
1055 rule_name: Some("MD001".to_string()),
1056 message: "Test".to_string(),
1057 severity: Severity::Warning,
1058 fix: Some(Fix {
1059 range: 0..5,
1060 replacement: "Fixed".to_string(),
1061 }),
1062 };
1063
1064 let uri = Url::parse("file:///test.md").unwrap();
1065 let document_text = "Hello World";
1066
1067 #[allow(deprecated)]
1068 let action = warning_to_code_action(&warning, &uri, document_text);
1069
1070 assert!(action.is_some());
1072 let action = action.unwrap();
1073 assert_eq!(action.title, "Fix: Test");
1074 assert_eq!(action.is_preferred, Some(true));
1075 }
1076
1077 #[test]
1078 fn test_md034_convert_to_link_action() {
1079 let warning = LintWarning {
1081 line: 1,
1082 column: 1,
1083 end_line: 1,
1084 end_column: 25,
1085 rule_name: Some("MD034".to_string()),
1086 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1087 severity: Severity::Warning,
1088 fix: Some(Fix {
1089 range: 0..20, replacement: "<https://example.com>".to_string(),
1091 }),
1092 };
1093
1094 let uri = Url::parse("file:///test.md").unwrap();
1095 let document_text = "https://example.com is a test URL";
1096
1097 let actions = warning_to_code_actions(&warning, &uri, document_text);
1098
1099 assert_eq!(actions.len(), 3);
1101
1102 assert_eq!(
1104 actions[0].title,
1105 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1106 );
1107 assert_eq!(actions[0].is_preferred, Some(true));
1108
1109 assert_eq!(actions[1].title, "Convert to markdown link");
1111 assert_eq!(actions[1].is_preferred, Some(false));
1112
1113 let edit = actions[1].edit.as_ref().unwrap();
1115 let changes = edit.changes.as_ref().unwrap();
1116 let file_edits = changes.get(&uri).unwrap();
1117 assert_eq!(file_edits.len(), 1);
1118
1119 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1121
1122 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1124 }
1125
1126 #[test]
1127 fn test_md034_convert_to_link_action_email() {
1128 let warning = LintWarning {
1130 line: 1,
1131 column: 1,
1132 end_line: 1,
1133 end_column: 20,
1134 rule_name: Some("MD034".to_string()),
1135 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1136 severity: Severity::Warning,
1137 fix: Some(Fix {
1138 range: 0..16, replacement: "<user@example.com>".to_string(),
1140 }),
1141 };
1142
1143 let uri = Url::parse("file:///test.md").unwrap();
1144 let document_text = "user@example.com is my email";
1145
1146 let actions = warning_to_code_actions(&warning, &uri, document_text);
1147
1148 assert_eq!(actions.len(), 3);
1150
1151 assert_eq!(actions[1].title, "Convert to markdown link");
1153
1154 let edit = actions[1].edit.as_ref().unwrap();
1155 let changes = edit.changes.as_ref().unwrap();
1156 let file_edits = changes.get(&uri).unwrap();
1157
1158 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1160 }
1161
1162 #[test]
1163 fn test_extract_url_from_fix_replacement() {
1164 assert_eq!(
1165 extract_url_from_fix_replacement("<https://example.com>"),
1166 Some("https://example.com")
1167 );
1168 assert_eq!(
1169 extract_url_from_fix_replacement("<user@example.com>"),
1170 Some("user@example.com")
1171 );
1172 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1173 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1174 }
1175
1176 #[test]
1177 fn test_extract_domain_for_placeholder() {
1178 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1179 assert_eq!(
1180 extract_domain_for_placeholder("https://example.com/path/to/page"),
1181 "example.com"
1182 );
1183 assert_eq!(
1184 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1185 "sub.example.com:8080"
1186 );
1187 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1188 assert_eq!(
1189 extract_domain_for_placeholder("ftp://files.example.com"),
1190 "files.example.com"
1191 );
1192 }
1193
1194 #[test]
1195 fn test_byte_range_to_lsp_range_trailing_newlines() {
1196 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1201 assert!(range.is_some());
1202 let range = range.unwrap();
1203
1204 assert_eq!(range.start.line, 2);
1207 assert_eq!(range.start.character, 0);
1208 assert_eq!(range.end.line, 3);
1209 assert_eq!(range.end.character, 0);
1210 }
1211
1212 #[test]
1213 fn test_byte_range_to_lsp_range_at_eof() {
1214 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1219 assert!(range.is_some());
1220 let range = range.unwrap();
1221
1222 assert_eq!(range.start.line, 1);
1224 assert_eq!(range.start.character, 0);
1225 }
1226}