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