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}
107
108impl Default for RumdlLspConfig {
109 fn default() -> Self {
110 Self {
111 config_path: None,
112 enable_linting: true,
113 enable_auto_fix: false,
114 enable_rules: None,
115 disable_rules: None,
116 configuration_preference: ConfigurationPreference::default(),
117 settings: None,
118 }
119 }
120}
121
122pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
124 let start_position = Position {
125 line: (warning.line.saturating_sub(1)) as u32,
126 character: (warning.column.saturating_sub(1)) as u32,
127 };
128
129 let end_position = Position {
131 line: (warning.end_line.saturating_sub(1)) as u32,
132 character: (warning.end_column.saturating_sub(1)) as u32,
133 };
134
135 let severity = match warning.severity {
136 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
137 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
138 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
139 };
140
141 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
143 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
145 .ok()
146 .map(|href| CodeDescription { href })
147 });
148
149 Diagnostic {
150 range: Range {
151 start: start_position,
152 end: end_position,
153 },
154 severity: Some(severity),
155 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
156 source: Some("rumdl".to_string()),
157 message: warning.message.clone(),
158 related_information: None,
159 tags: None,
160 code_description,
161 data: None,
162 }
163}
164
165fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
167 let mut line = 0u32;
168 let mut character = 0u32;
169 let mut byte_pos = 0;
170
171 let mut start_pos = None;
172 let mut end_pos = None;
173
174 for ch in text.chars() {
175 if byte_pos == byte_range.start {
176 start_pos = Some(Position { line, character });
177 }
178 if byte_pos == byte_range.end {
179 end_pos = Some(Position { line, character });
180 break;
181 }
182
183 if ch == '\n' {
184 line += 1;
185 character = 0;
186 } else {
187 character += 1;
188 }
189
190 byte_pos += ch.len_utf8();
191 }
192
193 if start_pos.is_none() && byte_pos >= byte_range.start {
196 start_pos = Some(Position { line, character });
197 }
198 if end_pos.is_none() && byte_pos >= byte_range.end {
199 end_pos = Some(Position { line, character });
200 }
201
202 match (start_pos, end_pos) {
203 (Some(start), Some(end)) => Some(Range { start, end }),
204 _ => {
205 log::warn!(
208 "Failed to convert byte range {:?} to LSP range for text of length {}",
209 byte_range,
210 text.len()
211 );
212 None
213 }
214 }
215}
216
217pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
220 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
221}
222
223pub(crate) fn warning_to_code_actions_with_md013_config(
227 warning: &crate::rule::LintWarning,
228 uri: &Url,
229 document_text: &str,
230 md013_config: Option<&MD013Config>,
231) -> Vec<CodeAction> {
232 let mut actions = Vec::new();
233
234 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
236 actions.push(fix_action);
237 }
238
239 if warning.rule_name.as_deref() == Some("MD013")
242 && warning.fix.is_none()
243 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
244 {
245 actions.push(reflow_action);
246 }
247
248 if warning.rule_name.as_deref() == Some("MD034")
251 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
252 {
253 actions.push(convert_action);
254 }
255
256 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
258 actions.push(ignore_line_action);
259 }
260
261 actions
262}
263
264fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
266 if let Some(fix) = &warning.fix {
267 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
269
270 let edit = TextEdit {
271 range,
272 new_text: fix.replacement.clone(),
273 };
274
275 let mut changes = std::collections::HashMap::new();
276 changes.insert(uri.clone(), vec![edit]);
277
278 let workspace_edit = WorkspaceEdit {
279 changes: Some(changes),
280 document_changes: None,
281 change_annotations: None,
282 };
283
284 Some(CodeAction {
285 title: format!("Fix: {}", warning.message),
286 kind: Some(CodeActionKind::QUICKFIX),
287 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
288 edit: Some(workspace_edit),
289 command: None,
290 is_preferred: Some(true),
291 disabled: None,
292 data: None,
293 })
294 } else {
295 None
296 }
297}
298
299fn create_reflow_action(
302 warning: &crate::rule::LintWarning,
303 uri: &Url,
304 document_text: &str,
305 md013_config: Option<&MD013Config>,
306) -> Option<CodeAction> {
307 let options = if let Some(config) = md013_config {
310 config.to_reflow_options()
311 } else {
312 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
313 crate::utils::text_reflow::ReflowOptions {
314 line_length,
315 ..Default::default()
316 }
317 };
318
319 let reflow_result =
321 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
322
323 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
325
326 let edit = TextEdit {
327 range,
328 new_text: reflow_result.reflowed_text,
329 };
330
331 let mut changes = std::collections::HashMap::new();
332 changes.insert(uri.clone(), vec![edit]);
333
334 let workspace_edit = WorkspaceEdit {
335 changes: Some(changes),
336 document_changes: None,
337 change_annotations: None,
338 };
339
340 Some(CodeAction {
341 title: "Reflow paragraph".to_string(),
342 kind: Some(CodeActionKind::QUICKFIX),
343 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
344 edit: Some(workspace_edit),
345 command: None,
346 is_preferred: Some(false), disabled: None,
348 data: None,
349 })
350}
351
352fn extract_line_length_from_message(message: &str) -> Option<usize> {
355 let exceeds_idx = message.find("exceeds")?;
357 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
361
362 num_str.parse::<usize>().ok()
363}
364
365fn create_convert_to_link_action(
369 warning: &crate::rule::LintWarning,
370 uri: &Url,
371 document_text: &str,
372) -> Option<CodeAction> {
373 let fix = warning.fix.as_ref()?;
375
376 let url = extract_url_from_fix_replacement(&fix.replacement)?;
379
380 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
382
383 let link_text = extract_domain_for_placeholder(url);
388 let new_text = format!("[{link_text}]({url})");
389
390 let edit = TextEdit { range, new_text };
391
392 let mut changes = std::collections::HashMap::new();
393 changes.insert(uri.clone(), vec![edit]);
394
395 let workspace_edit = WorkspaceEdit {
396 changes: Some(changes),
397 document_changes: None,
398 change_annotations: None,
399 };
400
401 Some(CodeAction {
402 title: "Convert to markdown link".to_string(),
403 kind: Some(CodeActionKind::QUICKFIX),
404 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
405 edit: Some(workspace_edit),
406 command: None,
407 is_preferred: Some(false), disabled: None,
409 data: None,
410 })
411}
412
413fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
416 let trimmed = replacement.trim();
418 if trimmed.starts_with('<') && trimmed.ends_with('>') {
419 Some(&trimmed[1..trimmed.len() - 1])
420 } else {
421 None
422 }
423}
424
425fn extract_domain_for_placeholder(url: &str) -> &str {
429 if url.contains('@') && !url.contains("://") {
431 return url;
432 }
433
434 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
436}
437
438fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
440 let rule_id = warning.rule_name.as_ref()?;
441 let warning_line = warning.line.saturating_sub(1);
442
443 let lines: Vec<&str> = document_text.lines().collect();
445 let line_content = lines.get(warning_line)?;
446
447 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
449 return None;
451 }
452
453 let line_end = Position {
455 line: warning_line as u32,
456 character: line_content.len() as u32,
457 };
458
459 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
461
462 let edit = TextEdit {
463 range: Range {
464 start: line_end,
465 end: line_end,
466 },
467 new_text: comment,
468 };
469
470 let mut changes = std::collections::HashMap::new();
471 changes.insert(uri.clone(), vec![edit]);
472
473 Some(CodeAction {
474 title: format!("Ignore {rule_id} for this line"),
475 kind: Some(CodeActionKind::QUICKFIX),
476 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
477 edit: Some(WorkspaceEdit {
478 changes: Some(changes),
479 document_changes: None,
480 change_annotations: None,
481 }),
482 command: None,
483 is_preferred: Some(false), disabled: None,
485 data: None,
486 })
487}
488
489#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
492pub fn warning_to_code_action(
493 warning: &crate::rule::LintWarning,
494 uri: &Url,
495 document_text: &str,
496) -> Option<CodeAction> {
497 warning_to_code_actions(warning, uri, document_text)
498 .into_iter()
499 .find(|action| action.is_preferred == Some(true))
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::rule::{Fix, LintWarning, Severity};
506
507 #[test]
508 fn test_rumdl_lsp_config_default() {
509 let config = RumdlLspConfig::default();
510 assert_eq!(config.config_path, None);
511 assert!(config.enable_linting);
512 assert!(!config.enable_auto_fix);
513 }
514
515 #[test]
516 fn test_rumdl_lsp_config_serialization() {
517 let config = RumdlLspConfig {
518 config_path: Some("/path/to/config.toml".to_string()),
519 enable_linting: false,
520 enable_auto_fix: true,
521 enable_rules: None,
522 disable_rules: None,
523 configuration_preference: ConfigurationPreference::EditorFirst,
524 settings: None,
525 };
526
527 let json = serde_json::to_string(&config).unwrap();
529 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
530 assert!(json.contains("\"enableLinting\":false"));
531 assert!(json.contains("\"enableAutoFix\":true"));
532
533 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
535 assert_eq!(deserialized.config_path, config.config_path);
536 assert_eq!(deserialized.enable_linting, config.enable_linting);
537 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
538 }
539
540 #[test]
541 fn test_warning_to_diagnostic_basic() {
542 let warning = LintWarning {
543 line: 5,
544 column: 10,
545 end_line: 5,
546 end_column: 15,
547 rule_name: Some("MD001".to_string()),
548 message: "Test warning message".to_string(),
549 severity: Severity::Warning,
550 fix: None,
551 };
552
553 let diagnostic = warning_to_diagnostic(&warning);
554
555 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
558 assert_eq!(diagnostic.range.end.character, 14);
559 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
560 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
561 assert_eq!(diagnostic.message, "Test warning message");
562 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
563 }
564
565 #[test]
566 fn test_warning_to_diagnostic_error_severity() {
567 let warning = LintWarning {
568 line: 1,
569 column: 1,
570 end_line: 1,
571 end_column: 5,
572 rule_name: Some("MD002".to_string()),
573 message: "Error message".to_string(),
574 severity: Severity::Error,
575 fix: None,
576 };
577
578 let diagnostic = warning_to_diagnostic(&warning);
579 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
580 }
581
582 #[test]
583 fn test_warning_to_diagnostic_no_rule_name() {
584 let warning = LintWarning {
585 line: 1,
586 column: 1,
587 end_line: 1,
588 end_column: 5,
589 rule_name: None,
590 message: "Generic warning".to_string(),
591 severity: Severity::Warning,
592 fix: None,
593 };
594
595 let diagnostic = warning_to_diagnostic(&warning);
596 assert_eq!(diagnostic.code, None);
597 assert!(diagnostic.code_description.is_none());
598 }
599
600 #[test]
601 fn test_warning_to_diagnostic_edge_cases() {
602 let warning = LintWarning {
604 line: 0,
605 column: 0,
606 end_line: 0,
607 end_column: 0,
608 rule_name: Some("MD001".to_string()),
609 message: "Edge case".to_string(),
610 severity: Severity::Warning,
611 fix: None,
612 };
613
614 let diagnostic = warning_to_diagnostic(&warning);
615 assert_eq!(diagnostic.range.start.line, 0);
616 assert_eq!(diagnostic.range.start.character, 0);
617 }
618
619 #[test]
620 fn test_byte_range_to_lsp_range_simple() {
621 let text = "Hello\nWorld";
622 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
623
624 assert_eq!(range.start.line, 0);
625 assert_eq!(range.start.character, 0);
626 assert_eq!(range.end.line, 0);
627 assert_eq!(range.end.character, 5);
628 }
629
630 #[test]
631 fn test_byte_range_to_lsp_range_multiline() {
632 let text = "Hello\nWorld\nTest";
633 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
636 assert_eq!(range.start.character, 0);
637 assert_eq!(range.end.line, 1);
638 assert_eq!(range.end.character, 5);
639 }
640
641 #[test]
642 fn test_byte_range_to_lsp_range_unicode() {
643 let text = "Hello 世界\nTest";
644 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
646
647 assert_eq!(range.start.line, 0);
648 assert_eq!(range.start.character, 6);
649 assert_eq!(range.end.line, 0);
650 assert_eq!(range.end.character, 8); }
652
653 #[test]
654 fn test_byte_range_to_lsp_range_eof() {
655 let text = "Hello";
656 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
657
658 assert_eq!(range.start.line, 0);
659 assert_eq!(range.start.character, 0);
660 assert_eq!(range.end.line, 0);
661 assert_eq!(range.end.character, 5);
662 }
663
664 #[test]
665 fn test_byte_range_to_lsp_range_invalid() {
666 let text = "Hello";
667 let range = byte_range_to_lsp_range(text, 10..15);
669 assert!(range.is_none());
670 }
671
672 #[test]
673 fn test_byte_range_to_lsp_range_insertion_at_eof() {
674 let text = "Hello\nWorld";
676 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
678
679 assert_eq!(range.start.line, 1);
681 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
683 assert_eq!(range.end.character, 5);
684 }
685
686 #[test]
687 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
688 let text = "Hello\nWorld\n";
690 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
692
693 assert_eq!(range.start.line, 2);
695 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
697 assert_eq!(range.end.character, 0);
698 }
699
700 #[test]
701 fn test_warning_to_code_action_with_fix() {
702 let warning = LintWarning {
703 line: 1,
704 column: 1,
705 end_line: 1,
706 end_column: 5,
707 rule_name: Some("MD001".to_string()),
708 message: "Missing space".to_string(),
709 severity: Severity::Warning,
710 fix: Some(Fix {
711 range: 0..5,
712 replacement: "Fixed".to_string(),
713 }),
714 };
715
716 let uri = Url::parse("file:///test.md").unwrap();
717 let document_text = "Hello World";
718
719 let actions = warning_to_code_actions(&warning, &uri, document_text);
720 assert!(!actions.is_empty());
721 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
724 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
725 assert_eq!(action.is_preferred, Some(true));
726
727 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
728 let edits = &changes[&uri];
729 assert_eq!(edits.len(), 1);
730 assert_eq!(edits[0].new_text, "Fixed");
731 }
732
733 #[test]
734 fn test_warning_to_code_action_no_fix() {
735 let warning = LintWarning {
736 line: 1,
737 column: 1,
738 end_line: 1,
739 end_column: 5,
740 rule_name: Some("MD001".to_string()),
741 message: "No fix available".to_string(),
742 severity: Severity::Warning,
743 fix: None,
744 };
745
746 let uri = Url::parse("file:///test.md").unwrap();
747 let document_text = "Hello World";
748
749 let actions = warning_to_code_actions(&warning, &uri, document_text);
750 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
752 }
753
754 #[test]
755 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
756 let warning = LintWarning {
757 line: 2,
758 column: 1,
759 end_line: 2,
760 end_column: 100,
761 rule_name: Some("MD013".to_string()),
762 message: "Line length 95 exceeds 40 characters".to_string(),
763 severity: Severity::Warning,
764 fix: None,
765 };
766
767 let uri = Url::parse("file:///test.md").unwrap();
768 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";
769
770 let actions = warning_to_code_actions(&warning, &uri, document_text);
771 let reflow_action = actions
772 .iter()
773 .find(|action| action.title == "Reflow paragraph")
774 .expect("Expected manual reflow action for MD013");
775
776 let changes = reflow_action
777 .edit
778 .as_ref()
779 .and_then(|edit| edit.changes.as_ref())
780 .expect("Expected edits for reflow action");
781 let file_edits = changes.get(&uri).expect("Expected edits for URI");
782 assert_eq!(file_edits.len(), 1);
783 assert!(
784 file_edits[0]
785 .new_text
786 .lines()
787 .next()
788 .is_some_and(|line| line.starts_with("> ")),
789 "Expected blockquote prefix in reflow output"
790 );
791 }
792
793 #[test]
794 fn test_warning_to_code_action_multiline_fix() {
795 let warning = LintWarning {
796 line: 2,
797 column: 1,
798 end_line: 3,
799 end_column: 5,
800 rule_name: Some("MD001".to_string()),
801 message: "Multiline fix".to_string(),
802 severity: Severity::Warning,
803 fix: Some(Fix {
804 range: 6..16, replacement: "Fixed\nContent".to_string(),
806 }),
807 };
808
809 let uri = Url::parse("file:///test.md").unwrap();
810 let document_text = "Hello\nWorld\nTest Line";
811
812 let actions = warning_to_code_actions(&warning, &uri, document_text);
813 assert!(!actions.is_empty());
814 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
817 let edits = &changes[&uri];
818 assert_eq!(edits[0].new_text, "Fixed\nContent");
819 assert_eq!(edits[0].range.start.line, 1);
820 assert_eq!(edits[0].range.start.character, 0);
821 }
822
823 #[test]
824 fn test_code_description_url_generation() {
825 let warning = LintWarning {
826 line: 1,
827 column: 1,
828 end_line: 1,
829 end_column: 5,
830 rule_name: Some("MD013".to_string()),
831 message: "Line too long".to_string(),
832 severity: Severity::Warning,
833 fix: None,
834 };
835
836 let diagnostic = warning_to_diagnostic(&warning);
837 assert!(diagnostic.code_description.is_some());
838
839 let url = diagnostic.code_description.unwrap().href;
840 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
841 }
842
843 #[test]
844 fn test_lsp_config_partial_deserialization() {
845 let json = r#"{"enableLinting": false}"#;
847 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
848
849 assert!(!config.enable_linting);
850 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
853
854 #[test]
855 fn test_configuration_preference_serialization() {
856 let pref = ConfigurationPreference::EditorFirst;
858 let json = serde_json::to_string(&pref).unwrap();
859 assert_eq!(json, "\"editorFirst\"");
860
861 let pref = ConfigurationPreference::FilesystemFirst;
863 let json = serde_json::to_string(&pref).unwrap();
864 assert_eq!(json, "\"filesystemFirst\"");
865
866 let pref = ConfigurationPreference::EditorOnly;
868 let json = serde_json::to_string(&pref).unwrap();
869 assert_eq!(json, "\"editorOnly\"");
870
871 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
873 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
874 }
875
876 #[test]
877 fn test_lsp_rule_settings_deserialization() {
878 let json = r#"{
880 "lineLength": 120,
881 "disable": ["MD001", "MD002"],
882 "enable": ["MD013"]
883 }"#;
884 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
885
886 assert_eq!(settings.line_length, Some(120));
887 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
888 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
889 }
890
891 #[test]
892 fn test_lsp_rule_settings_with_per_rule_config() {
893 let json = r#"{
895 "lineLength": 80,
896 "MD013": {
897 "lineLength": 120,
898 "codeBlocks": false
899 },
900 "MD024": {
901 "siblingsOnly": true
902 }
903 }"#;
904 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
905
906 assert_eq!(settings.line_length, Some(80));
907
908 let md013 = settings.rules.get("MD013").unwrap();
910 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
911 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
912
913 let md024 = settings.rules.get("MD024").unwrap();
915 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
916 }
917
918 #[test]
919 fn test_full_lsp_config_with_settings() {
920 let json = r#"{
922 "configPath": "/path/to/config",
923 "enableLinting": true,
924 "enableAutoFix": false,
925 "configurationPreference": "editorFirst",
926 "settings": {
927 "lineLength": 100,
928 "disable": ["MD033"],
929 "MD013": {
930 "lineLength": 120,
931 "tables": false
932 }
933 }
934 }"#;
935 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
936
937 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
938 assert!(config.enable_linting);
939 assert!(!config.enable_auto_fix);
940 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
941
942 let settings = config.settings.unwrap();
943 assert_eq!(settings.line_length, Some(100));
944 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
945
946 let md013 = settings.rules.get("MD013").unwrap();
947 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
948 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
949 }
950
951 #[test]
952 fn test_create_ignore_line_action_uses_rumdl_syntax() {
953 let warning = LintWarning {
954 line: 5,
955 column: 1,
956 end_line: 5,
957 end_column: 50,
958 rule_name: Some("MD013".to_string()),
959 message: "Line too long".to_string(),
960 severity: Severity::Warning,
961 fix: None,
962 };
963
964 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
965 let uri = Url::parse("file:///test.md").unwrap();
966
967 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
968
969 assert_eq!(action.title, "Ignore MD013 for this line");
970 assert_eq!(action.is_preferred, Some(false));
971 assert!(action.edit.is_some());
972
973 let edit = action.edit.unwrap();
975 let changes = edit.changes.unwrap();
976 let file_edits = changes.get(&uri).unwrap();
977
978 assert_eq!(file_edits.len(), 1);
979 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
980 assert!(!file_edits[0].new_text.contains("markdownlint"));
981
982 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
986
987 #[test]
988 fn test_create_ignore_line_action_no_duplicate() {
989 let warning = LintWarning {
990 line: 1,
991 column: 1,
992 end_line: 1,
993 end_column: 50,
994 rule_name: Some("MD013".to_string()),
995 message: "Line too long".to_string(),
996 severity: Severity::Warning,
997 fix: None,
998 };
999
1000 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1002 let uri = Url::parse("file:///test.md").unwrap();
1003
1004 let action = create_ignore_line_action(&warning, &uri, document);
1005
1006 assert!(action.is_none());
1008 }
1009
1010 #[test]
1011 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1012 let warning = LintWarning {
1013 line: 1,
1014 column: 1,
1015 end_line: 1,
1016 end_column: 50,
1017 rule_name: Some("MD013".to_string()),
1018 message: "Line too long".to_string(),
1019 severity: Severity::Warning,
1020 fix: None,
1021 };
1022
1023 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1025 let uri = Url::parse("file:///test.md").unwrap();
1026
1027 let action = create_ignore_line_action(&warning, &uri, document);
1028
1029 assert!(action.is_none());
1031 }
1032
1033 #[test]
1034 fn test_warning_to_code_actions_with_fix() {
1035 let warning = LintWarning {
1036 line: 1,
1037 column: 1,
1038 end_line: 1,
1039 end_column: 5,
1040 rule_name: Some("MD009".to_string()),
1041 message: "Trailing spaces".to_string(),
1042 severity: Severity::Warning,
1043 fix: Some(Fix {
1044 range: 0..5,
1045 replacement: "Fixed".to_string(),
1046 }),
1047 };
1048
1049 let uri = Url::parse("file:///test.md").unwrap();
1050 let document_text = "Hello \nWorld";
1051
1052 let actions = warning_to_code_actions(&warning, &uri, document_text);
1053
1054 assert_eq!(actions.len(), 2);
1056
1057 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1059 assert_eq!(actions[0].is_preferred, Some(true));
1060
1061 assert_eq!(actions[1].title, "Ignore MD009 for this line");
1063 assert_eq!(actions[1].is_preferred, Some(false));
1064 }
1065
1066 #[test]
1067 fn test_warning_to_code_actions_no_fix() {
1068 let warning = LintWarning {
1069 line: 1,
1070 column: 1,
1071 end_line: 1,
1072 end_column: 10,
1073 rule_name: Some("MD033".to_string()),
1074 message: "Inline HTML".to_string(),
1075 severity: Severity::Warning,
1076 fix: None,
1077 };
1078
1079 let uri = Url::parse("file:///test.md").unwrap();
1080 let document_text = "<div>HTML</div>";
1081
1082 let actions = warning_to_code_actions(&warning, &uri, document_text);
1083
1084 assert_eq!(actions.len(), 1);
1086 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1087 assert_eq!(actions[0].is_preferred, Some(false));
1088 }
1089
1090 #[test]
1091 fn test_warning_to_code_actions_no_rule_name() {
1092 let warning = LintWarning {
1093 line: 1,
1094 column: 1,
1095 end_line: 1,
1096 end_column: 5,
1097 rule_name: None,
1098 message: "Generic warning".to_string(),
1099 severity: Severity::Warning,
1100 fix: None,
1101 };
1102
1103 let uri = Url::parse("file:///test.md").unwrap();
1104 let document_text = "Hello World";
1105
1106 let actions = warning_to_code_actions(&warning, &uri, document_text);
1107
1108 assert_eq!(actions.len(), 0);
1110 }
1111
1112 #[test]
1113 fn test_legacy_warning_to_code_action_compatibility() {
1114 let warning = LintWarning {
1115 line: 1,
1116 column: 1,
1117 end_line: 1,
1118 end_column: 5,
1119 rule_name: Some("MD001".to_string()),
1120 message: "Test".to_string(),
1121 severity: Severity::Warning,
1122 fix: Some(Fix {
1123 range: 0..5,
1124 replacement: "Fixed".to_string(),
1125 }),
1126 };
1127
1128 let uri = Url::parse("file:///test.md").unwrap();
1129 let document_text = "Hello World";
1130
1131 #[allow(deprecated)]
1132 let action = warning_to_code_action(&warning, &uri, document_text);
1133
1134 assert!(action.is_some());
1136 let action = action.unwrap();
1137 assert_eq!(action.title, "Fix: Test");
1138 assert_eq!(action.is_preferred, Some(true));
1139 }
1140
1141 #[test]
1142 fn test_md034_convert_to_link_action() {
1143 let warning = LintWarning {
1145 line: 1,
1146 column: 1,
1147 end_line: 1,
1148 end_column: 25,
1149 rule_name: Some("MD034".to_string()),
1150 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1151 severity: Severity::Warning,
1152 fix: Some(Fix {
1153 range: 0..20, replacement: "<https://example.com>".to_string(),
1155 }),
1156 };
1157
1158 let uri = Url::parse("file:///test.md").unwrap();
1159 let document_text = "https://example.com is a test URL";
1160
1161 let actions = warning_to_code_actions(&warning, &uri, document_text);
1162
1163 assert_eq!(actions.len(), 3);
1165
1166 assert_eq!(
1168 actions[0].title,
1169 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1170 );
1171 assert_eq!(actions[0].is_preferred, Some(true));
1172
1173 assert_eq!(actions[1].title, "Convert to markdown link");
1175 assert_eq!(actions[1].is_preferred, Some(false));
1176
1177 let edit = actions[1].edit.as_ref().unwrap();
1179 let changes = edit.changes.as_ref().unwrap();
1180 let file_edits = changes.get(&uri).unwrap();
1181 assert_eq!(file_edits.len(), 1);
1182
1183 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1185
1186 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1188 }
1189
1190 #[test]
1191 fn test_md034_convert_to_link_action_email() {
1192 let warning = LintWarning {
1194 line: 1,
1195 column: 1,
1196 end_line: 1,
1197 end_column: 20,
1198 rule_name: Some("MD034".to_string()),
1199 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1200 severity: Severity::Warning,
1201 fix: Some(Fix {
1202 range: 0..16, replacement: "<user@example.com>".to_string(),
1204 }),
1205 };
1206
1207 let uri = Url::parse("file:///test.md").unwrap();
1208 let document_text = "user@example.com is my email";
1209
1210 let actions = warning_to_code_actions(&warning, &uri, document_text);
1211
1212 assert_eq!(actions.len(), 3);
1214
1215 assert_eq!(actions[1].title, "Convert to markdown link");
1217
1218 let edit = actions[1].edit.as_ref().unwrap();
1219 let changes = edit.changes.as_ref().unwrap();
1220 let file_edits = changes.get(&uri).unwrap();
1221
1222 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1224 }
1225
1226 #[test]
1227 fn test_extract_url_from_fix_replacement() {
1228 assert_eq!(
1229 extract_url_from_fix_replacement("<https://example.com>"),
1230 Some("https://example.com")
1231 );
1232 assert_eq!(
1233 extract_url_from_fix_replacement("<user@example.com>"),
1234 Some("user@example.com")
1235 );
1236 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1237 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1238 }
1239
1240 #[test]
1241 fn test_extract_domain_for_placeholder() {
1242 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1243 assert_eq!(
1244 extract_domain_for_placeholder("https://example.com/path/to/page"),
1245 "example.com"
1246 );
1247 assert_eq!(
1248 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1249 "sub.example.com:8080"
1250 );
1251 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1252 assert_eq!(
1253 extract_domain_for_placeholder("ftp://files.example.com"),
1254 "files.example.com"
1255 );
1256 }
1257
1258 #[test]
1259 fn test_byte_range_to_lsp_range_trailing_newlines() {
1260 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1265 assert!(range.is_some());
1266 let range = range.unwrap();
1267
1268 assert_eq!(range.start.line, 2);
1271 assert_eq!(range.start.character, 0);
1272 assert_eq!(range.end.line, 3);
1273 assert_eq!(range.end.character, 0);
1274 }
1275
1276 #[test]
1277 fn test_byte_range_to_lsp_range_at_eof() {
1278 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1283 assert!(range.is_some());
1284 let range = range.unwrap();
1285
1286 assert_eq!(range.start.line, 1);
1288 assert_eq!(range.start.character, 0);
1289 }
1290}