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