1use crate::linguist_data::{default_alias, resolve_canonical};
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::load_rule_config;
4use crate::utils::range_utils::calculate_line_range;
5use std::collections::HashMap;
6
7pub mod md040_config;
11
12const MKDOCS_SUPERFENCES_ATTR_PREFIXES: &[&str] = &[
20 "title=", "hl_lines=", "linenums=", ".", "#", ];
26
27#[inline]
29fn is_superfences_attribute(s: &str) -> bool {
30 MKDOCS_SUPERFENCES_ATTR_PREFIXES
31 .iter()
32 .any(|prefix| s.starts_with(prefix))
33}
34use md040_config::{LanguageStyle, MD040Config, UnknownLanguageAction};
35
36struct FencedCodeBlock {
37 line_idx: usize,
39 language: String,
41 fence_marker: String,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct MD040FencedCodeLanguage {
47 config: MD040Config,
48}
49
50impl MD040FencedCodeLanguage {
51 pub fn with_config(config: MD040Config) -> Self {
52 Self { config }
53 }
54
55 fn resolve_language(&self, label: &str) -> Option<&str> {
62 resolve_canonical(label).or_else(|| self.config.custom_language(label))
63 }
64
65 fn validate_config(&self) -> Vec<String> {
67 let mut errors = Vec::new();
68
69 for declared in &self.config.custom_languages {
73 if declared.trim().is_empty() {
74 errors.push("Empty entry in custom-languages.".to_string());
75 } else if declared.chars().any(char::is_whitespace) {
76 errors.push(format!(
77 "Custom language '{declared}' contains whitespace, so no fence label can match it."
78 ));
79 }
80 }
81
82 errors.extend(
83 self.config
84 .preferred_aliases
85 .iter()
86 .filter_map(|(language, alias)| self.config.preferred_alias_problem(language, alias)),
87 );
88
89 errors
90 }
91
92 fn is_disabled_at(&self, ctx: &crate::lint_context::LintContext, block: &FencedCodeBlock) -> bool {
97 ctx.is_rule_disabled(self.name(), block.line_idx + 1)
98 }
99
100 fn compute_preferred_labels(
102 &self,
103 ctx: &crate::lint_context::LintContext,
104 blocks: &[FencedCodeBlock],
105 ) -> HashMap<String, String> {
106 let mut by_canonical: HashMap<String, Vec<&str>> = HashMap::new();
108
109 for block in blocks {
110 if self.is_disabled_at(ctx, block) {
111 continue;
112 }
113 if block.language.is_empty() {
114 continue;
115 }
116 if let Some(canonical) = self.resolve_language(&block.language) {
117 by_canonical
118 .entry(canonical.to_string())
119 .or_default()
120 .push(&block.language);
121 }
122 }
123
124 let mut result = HashMap::new();
126
127 for (canonical, labels) in by_canonical {
128 let winner = if let Some(preferred) = self.config.preferred_label(&canonical) {
130 preferred.to_string()
131 } else {
132 let mut counts: HashMap<&str, usize> = HashMap::new();
134 for label in &labels {
135 *counts.entry(*label).or_default() += 1;
136 }
137
138 let max_count = counts.values().max().copied().unwrap_or(0);
139 let winners: Vec<_> = counts
140 .iter()
141 .filter(|(_, c)| **c == max_count)
142 .map(|(l, _)| *l)
143 .collect();
144
145 if winners.len() == 1 {
146 winners[0].to_string()
147 } else {
148 default_alias(&canonical)
151 .or_else(|| self.config.custom_language(&canonical))
152 .filter(|default| winners.contains(default))
153 .map_or_else(
154 || winners.into_iter().min().unwrap().to_string(),
155 std::string::ToString::to_string,
156 )
157 }
158 };
159
160 result.insert(canonical, winner);
161 }
162
163 result
164 }
165
166 fn check_language_allowed(&self, canonical: Option<&str>, original_label: &str) -> Option<String> {
168 if !self.config.allowed_languages.is_empty() {
170 let allowed = self.config.allowed_languages.join(", ");
171 let Some(canonical) = canonical else {
172 return Some(format!(
173 "Language '{original_label}' is not in the allowed list: {allowed}"
174 ));
175 };
176 if !self
177 .config
178 .allowed_languages
179 .iter()
180 .any(|a| a.eq_ignore_ascii_case(canonical))
181 {
182 return Some(format!(
183 "Language '{original_label}' ({canonical}) is not in the allowed list: {allowed}"
184 ));
185 }
186 } else if !self.config.disallowed_languages.is_empty()
187 && canonical.is_some_and(|canonical| {
188 self.config
189 .disallowed_languages
190 .iter()
191 .any(|d| d.eq_ignore_ascii_case(canonical))
192 })
193 {
194 let canonical = canonical.unwrap_or("unknown");
195 return Some(format!("Language '{original_label}' ({canonical}) is disallowed"));
196 }
197 None
198 }
199
200 fn check_unknown_language(&self, label: &str) -> Option<(String, Severity)> {
202 if crate::linguist_data::is_known_language(label) || self.config.custom_language(label).is_some() {
206 return None;
207 }
208
209 match self.config.unknown_language_action {
210 UnknownLanguageAction::Ignore => None,
211 UnknownLanguageAction::Warn => Some((
212 format!(
213 "Unknown language '{label}' (not in GitHub Linguist). Syntax highlighting may not work. Add it to custom-languages to accept it."
214 ),
215 Severity::Warning,
216 )),
217 UnknownLanguageAction::Error => Some((
218 format!(
219 "Unknown language '{label}' (not in GitHub Linguist). Add it to custom-languages to accept it."
220 ),
221 Severity::Error,
222 )),
223 }
224 }
225}
226
227impl Rule for MD040FencedCodeLanguage {
228 fn name(&self) -> &'static str {
229 "MD040"
230 }
231
232 fn description(&self) -> &'static str {
233 "Code blocks should have a language specified"
234 }
235
236 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
237 let mut warnings = Vec::new();
238
239 for error in self.validate_config() {
241 warnings.push(LintWarning {
242 rule_name: Some(self.name().to_string()),
243 line: 1,
244 column: 1,
245 end_line: 1,
246 end_column: 1,
247 message: format!("[config error] {error}"),
248 severity: Severity::Error,
249 fix: None,
250 });
251 }
252
253 let fenced_blocks = derive_fenced_code_blocks(ctx);
255
256 let preferred_labels = if self.config.style == LanguageStyle::Consistent {
258 self.compute_preferred_labels(ctx, &fenced_blocks)
259 } else {
260 HashMap::new()
261 };
262
263 let lines = ctx.raw_lines();
264
265 for block in &fenced_blocks {
266 if self.is_disabled_at(ctx, block) {
267 continue;
268 }
269
270 let line = lines.get(block.line_idx).unwrap_or(&"");
274 let fence_line = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
275 let after_fence = fence_line.strip_prefix(&block.fence_marker).unwrap_or("").trim();
276
277 let has_mkdocs_attrs_only =
279 ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
280
281 let is_myst_directive =
284 ctx.flavor.supports_myst_directives() && after_fence.starts_with('{') && after_fence.contains('}') && {
285 let name = after_fence.trim_start_matches('{').split('}').next().unwrap_or("");
286 !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
287 };
288
289 let is_pandoc_raw =
300 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(after_fence);
301 let is_pandoc_class_attr =
302 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(after_fence);
303 let is_quarto_exec = ctx.flavor == crate::config::MarkdownFlavor::Quarto
304 && after_fence.starts_with('{')
305 && after_fence.ends_with('}')
306 && !is_pandoc_raw
307 && !is_pandoc_class_attr;
308 let has_pandoc_or_quarto_syntax = is_pandoc_raw || is_pandoc_class_attr || is_quarto_exec;
309 let is_unrecognized_brace_syntax = after_fence.starts_with('{')
310 && after_fence.ends_with('}')
311 && !has_pandoc_or_quarto_syntax
312 && !is_myst_directive;
313
314 let needs_language = !has_mkdocs_attrs_only
315 && !is_myst_directive
316 && (block.language.is_empty()
317 || is_superfences_attribute(&block.language)
318 || is_unrecognized_brace_syntax);
319
320 if needs_language && !has_pandoc_or_quarto_syntax {
321 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
322
323 let fix = fence_marker_offset(line, &block.fence_marker).map(|marker_offset| {
324 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
325 let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
326 let line_end_byte = line_start_byte + line.len();
330 let after_fence_trimmed = line[marker_offset + block.fence_marker.len()..].trim();
331 let replacement = if after_fence_trimmed.is_empty() {
332 "text".to_string()
333 } else {
334 format!("text {after_fence_trimmed}")
335 };
336 Fix::new(fence_end_byte..line_end_byte, replacement)
337 });
338
339 warnings.push(LintWarning {
340 rule_name: Some(self.name().to_string()),
341 line: start_line,
342 column: start_col,
343 end_line,
344 end_column: end_col,
345 message: "Code block (```) missing language".to_string(),
346 severity: Severity::Warning,
347 fix,
348 });
349 continue;
350 }
351
352 if has_pandoc_or_quarto_syntax {
354 continue;
355 }
356
357 let canonical = self.resolve_language(&block.language);
358
359 if let Some(msg) = self.check_language_allowed(canonical, &block.language) {
361 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
362
363 warnings.push(LintWarning {
364 rule_name: Some(self.name().to_string()),
365 line: start_line,
366 column: start_col,
367 end_line,
368 end_column: end_col,
369 message: msg,
370 severity: Severity::Warning,
371 fix: None,
372 });
373 continue;
374 }
375
376 if canonical.is_none() {
378 if let Some((msg, severity)) = self.check_unknown_language(&block.language) {
379 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
380
381 warnings.push(LintWarning {
382 rule_name: Some(self.name().to_string()),
383 line: start_line,
384 column: start_col,
385 end_line,
386 end_column: end_col,
387 message: msg,
388 severity,
389 fix: None,
390 });
391 }
392 continue;
393 }
394
395 if self.config.style == LanguageStyle::Consistent
397 && let Some(preferred) = preferred_labels.get(canonical.unwrap())
398 && &block.language != preferred
399 {
400 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
401
402 let fix = find_label_span(line, &block.fence_marker).map(|(label_start, label_end)| {
403 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
404 Fix::new(
405 (line_start_byte + label_start)..(line_start_byte + label_end),
406 preferred.clone(),
407 )
408 });
409 let lang = &block.language;
410 let canonical = canonical.unwrap();
411
412 warnings.push(LintWarning {
413 rule_name: Some(self.name().to_string()),
414 line: start_line,
415 column: start_col,
416 end_line,
417 end_column: end_col,
418 message: format!("Inconsistent language label '{lang}' for {canonical} (use '{preferred}')"),
419 severity: Severity::Warning,
420 fix,
421 });
422 }
423 }
424
425 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
429 for warning in &mut warnings {
430 warning.fix = None;
431 }
432 }
433
434 Ok(warnings)
435 }
436
437 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
438 if self.should_skip(ctx) {
439 return Ok(ctx.content.to_string());
440 }
441 let warnings = self.check(ctx)?;
442 if warnings.is_empty() {
443 return Ok(ctx.content.to_string());
444 }
445 let warnings =
446 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
447 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
448 }
449
450 fn category(&self) -> RuleCategory {
452 RuleCategory::CodeBlock
453 }
454
455 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
457 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
458 }
459
460 fn as_any(&self) -> &dyn std::any::Any {
461 self
462 }
463
464 crate::impl_rule_config_sections!(MD040Config);
465
466 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
467 where
468 Self: Sized,
469 {
470 let rule_config: MD040Config = load_rule_config(config);
471 Box::new(MD040FencedCodeLanguage::with_config(rule_config))
472 }
473}
474
475fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
477 let content = ctx.content;
478 let line_offsets = &ctx.line_offsets;
479
480 ctx.code_block_details
481 .iter()
482 .filter(|d| d.is_fenced)
483 .map(|detail| {
484 let line_idx = match line_offsets.binary_search(&detail.start) {
485 Ok(idx) => idx,
486 Err(idx) => idx.saturating_sub(1),
487 };
488
489 let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
491 let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
492 let line = content.get(line_start..line_end).unwrap_or("");
493 let fence_marker =
494 find_fence_marker(line).map_or_else(|| "```".to_string(), |(_, marker)| marker.to_string());
495
496 let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
497
498 FencedCodeBlock {
499 line_idx,
500 language,
501 fence_marker,
502 }
503 })
504 .collect()
505}
506
507fn find_fence_marker(line: &str) -> Option<(usize, &str)> {
515 let bytes = line.as_bytes();
516 let start = bytes.iter().position(|&b| b == b'`' || b == b'~')?;
517 let fence_char = bytes[start];
518 let len = bytes[start..].iter().take_while(|&&b| b == fence_char).count();
519 Some((start, &line[start..start + len]))
520}
521
522fn fence_marker_offset(line: &str, fence_marker: &str) -> Option<usize> {
527 let (start, marker) = find_fence_marker(line)?;
528 (marker == fence_marker).then_some(start)
529}
530
531fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
533 let marker_offset = fence_marker_offset(line, fence_marker)?;
534 let after_fence = &line[marker_offset + fence_marker.len()..];
535
536 let label_start_rel = after_fence
537 .char_indices()
538 .find(|&(_, ch)| !ch.is_whitespace())
539 .map(|(idx, _)| idx)?;
540 let after_label = &after_fence[label_start_rel..];
541 let label_end_rel = after_label
542 .char_indices()
543 .find(|&(_, ch)| ch.is_whitespace())
544 .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
545
546 Some((
547 marker_offset + fence_marker.len() + label_start_rel,
548 marker_offset + fence_marker.len() + label_end_rel,
549 ))
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::lint_context::LintContext;
556
557 fn run_check(content: &str) -> LintResult {
558 let rule = MD040FencedCodeLanguage::default();
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 rule.check(&ctx)
561 }
562
563 fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
564 let rule = MD040FencedCodeLanguage::with_config(config);
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566 rule.check(&ctx)
567 }
568
569 fn run_fix(content: &str) -> Result<String, LintError> {
570 let rule = MD040FencedCodeLanguage::default();
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 rule.fix(&ctx)
573 }
574
575 fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
576 let rule = MD040FencedCodeLanguage::with_config(config);
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 rule.fix(&ctx)
579 }
580
581 fn run_check_mkdocs(content: &str) -> LintResult {
582 let rule = MD040FencedCodeLanguage::default();
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
584 rule.check(&ctx)
585 }
586
587 #[test]
592 fn test_code_blocks_with_language_specified() {
593 let content = r#"# Test
594
595```python
596print("Hello, world!")
597```
598
599```javascript
600console.log("Hello!");
601```
602"#;
603 let result = run_check(content).unwrap();
604 assert!(result.is_empty(), "No warnings expected for code blocks with language");
605 }
606
607 #[test]
608 fn test_code_blocks_without_language() {
609 let content = r#"# Test
610
611```
612print("Hello, world!")
613```
614"#;
615 let result = run_check(content).unwrap();
616 assert_eq!(result.len(), 1);
617 assert_eq!(result[0].message, "Code block (```) missing language");
618 assert_eq!(result[0].line, 3);
619 }
620
621 #[test]
622 fn test_fix_method_adds_text_language() {
623 let content = r#"# Test
624
625```
626code without language
627```
628
629```python
630already has language
631```
632
633```
634another block without
635```
636"#;
637 let fixed = run_fix(content).unwrap();
638 assert!(fixed.contains("```text"));
639 assert!(fixed.contains("```python"));
640 assert_eq!(fixed.matches("```text").count(), 2);
641 }
642
643 #[test]
644 fn test_fix_preserves_indentation() {
645 let content = r#"# Test
646
647- List item
648 ```
649 indented code block
650 ```
651"#;
652 let fixed = run_fix(content).unwrap();
653 assert!(fixed.contains(" ```text"));
654 }
655
656 #[test]
657 fn test_fix_blockquote_empty_fence() {
658 let content = "# Title\n\n> ```\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
662 let fixed = run_fix(content).unwrap();
663 let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
664 assert_eq!(fixed, expected);
665 }
666
667 #[test]
668 fn test_fix_blockquote_tilde_and_longer_fences() {
669 let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
672 assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
673
674 let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
675 assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
676
677 let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
678 assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
679 }
680
681 #[test]
682 fn test_fix_nested_blockquote_empty_fence() {
683 let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
686 assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
687
688 let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
689 assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
690 }
691
692 #[test]
693 fn test_fix_list_marker_empty_fence() {
694 let content = "# Title\n\n- ```\n root/\n └── nested/\n └── file.txt\n ```\n";
699 let fixed = run_fix(content).unwrap();
700 let expected = "# Title\n\n- ```text\n root/\n └── nested/\n └── file.txt\n ```\n";
701 assert_eq!(fixed, expected);
702 }
703
704 #[test]
705 fn test_fix_list_marker_fence_across_marker_styles() {
706 for (input, expected) in [
710 ("- ```\n code\n ```\n", "- ```text\n code\n ```\n"),
711 ("* ```\n code\n ```\n", "* ```text\n code\n ```\n"),
712 ("+ ```\n code\n ```\n", "+ ```text\n code\n ```\n"),
713 ("1. ```\n code\n ```\n", "1. ```text\n code\n ```\n"),
714 ("1) ```\n code\n ```\n", "1) ```text\n code\n ```\n"),
715 (" - ```\n code\n ```\n", " - ```text\n code\n ```\n"),
716 ("- - ```\n code\n ```\n", "- - ```text\n code\n ```\n"),
717 ("> - ```\n> code\n> ```\n", "> - ```text\n> code\n> ```\n"),
718 ] {
719 assert_eq!(run_fix(input).unwrap(), expected, "input: {input:?}");
720 }
721 }
722
723 #[test]
724 fn test_fix_list_marker_tilde_and_longer_fences() {
725 let tilde = run_fix("- ~~~\n code\n ~~~\n").unwrap();
729 assert_eq!(tilde, "- ~~~text\n code\n ~~~\n");
730
731 let longer_tilde = run_fix("- ~~~~\n code\n ~~~~\n").unwrap();
732 assert_eq!(longer_tilde, "- ~~~~text\n code\n ~~~~\n");
733
734 let longer_backtick = run_fix("- ````\n code\n ````\n").unwrap();
735 assert_eq!(longer_backtick, "- ````text\n code\n ````\n");
736 }
737
738 #[test]
739 fn test_fix_list_marker_fence_is_idempotent() {
740 let content = "- ```\n root/\n nested\n ```\n";
741 let once = run_fix(content).unwrap();
742 let twice = run_fix(&once).unwrap();
743 assert_eq!(once, twice);
744 assert_eq!(once, "- ```text\n root/\n nested\n ```\n");
745 }
746
747 #[test]
748 fn test_fix_list_marker_fence_with_language_untouched() {
749 let content = "- ```rust\n code\n ```\n";
750 assert!(run_check(content).unwrap().is_empty());
751 assert_eq!(run_fix(content).unwrap(), content);
752 }
753
754 #[test]
755 fn test_fix_blockquote_empty_fence_is_idempotent() {
756 let content = "> ```\n> root/\n> nested\n> ```\n";
758 let once = run_fix(content).unwrap();
759 let twice = run_fix(&once).unwrap();
760 assert_eq!(once, twice);
761 assert_eq!(once, "> ```text\n> root/\n> nested\n> ```\n");
762 }
763
764 #[test]
769 fn test_consistent_mode_detects_inconsistency() {
770 let content = r#"```bash
771echo hi
772```
773
774```sh
775echo there
776```
777
778```bash
779echo again
780```
781"#;
782 let config = MD040Config {
783 style: LanguageStyle::Consistent,
784 ..Default::default()
785 };
786 let result = run_check_with_config(content, config).unwrap();
787 assert_eq!(result.len(), 1);
788 assert!(result[0].message.contains("Inconsistent"));
789 assert!(result[0].message.contains("sh"));
790 assert!(result[0].message.contains("bash"));
791 }
792
793 #[test]
794 fn test_consistent_mode_fix_normalizes() {
795 let content = r#"```bash
796echo hi
797```
798
799```sh
800echo there
801```
802
803```bash
804echo again
805```
806"#;
807 let config = MD040Config {
808 style: LanguageStyle::Consistent,
809 ..Default::default()
810 };
811 let fixed = run_fix_with_config(content, config).unwrap();
812 assert_eq!(fixed.matches("```bash").count(), 3);
813 assert_eq!(fixed.matches("```sh").count(), 0);
814 }
815
816 #[test]
817 fn test_consistent_mode_tie_break_uses_curated_default() {
818 let content = r#"```bash
820echo hi
821```
822
823```sh
824echo there
825```
826"#;
827 let config = MD040Config {
828 style: LanguageStyle::Consistent,
829 ..Default::default()
830 };
831 let fixed = run_fix_with_config(content, config).unwrap();
832 assert_eq!(fixed.matches("```bash").count(), 2);
834 }
835
836 #[test]
837 fn test_consistent_mode_with_preferred_alias() {
838 let content = r#"```bash
839echo hi
840```
841
842```sh
843echo there
844```
845"#;
846 let mut preferred = HashMap::new();
847 preferred.insert("Shell".to_string(), "sh".to_string());
848
849 let config = MD040Config {
850 style: LanguageStyle::Consistent,
851 preferred_aliases: preferred,
852 ..Default::default()
853 };
854 let fixed = run_fix_with_config(content, config).unwrap();
855 assert_eq!(fixed.matches("```sh").count(), 2);
856 assert_eq!(fixed.matches("```bash").count(), 0);
857 }
858
859 #[test]
860 fn test_consistent_mode_fix_inside_blockquote() {
861 let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
864 let config = MD040Config {
865 style: LanguageStyle::Consistent,
866 ..Default::default()
867 };
868 let fixed = run_fix_with_config(content, config).unwrap();
869 assert_eq!(
870 fixed,
871 "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
872 );
873 }
874
875 #[test]
876 fn test_consistent_mode_ignores_disabled_blocks() {
877 let content = r#"```bash
878echo hi
879```
880<!-- rumdl-disable MD040 -->
881```sh
882echo there
883```
884```sh
885echo again
886```
887<!-- rumdl-enable MD040 -->
888"#;
889 let config = MD040Config {
890 style: LanguageStyle::Consistent,
891 ..Default::default()
892 };
893 let result = run_check_with_config(content, config).unwrap();
894 assert!(result.is_empty(), "Disabled blocks should not affect consistency");
895 }
896
897 #[test]
898 fn test_disable_comment_naming_the_rule_by_alias_disables_it() {
899 let content = r#"```bash
900echo hi
901```
902<!-- rumdl-disable fenced-code-language -->
903```sh
904echo there
905```
906```sh
907echo again
908```
909<!-- rumdl-enable fenced-code-language -->
910"#;
911 let config = MD040Config {
912 style: LanguageStyle::Consistent,
913 ..Default::default()
914 };
915 let result = run_check_with_config(content, config.clone()).unwrap();
916 assert!(
917 result.is_empty(),
918 "an alias names the same rule as the ID does: {result:?}"
919 );
920
921 let names_another_rule = content.replace("fenced-code-language", "line-length");
922 let result = run_check_with_config(&names_another_rule, config).unwrap();
923 assert_eq!(
924 result.len(),
925 1,
926 "a directive naming another rule leaves the sh blocks voting: {result:?}"
927 );
928 }
929
930 #[test]
931 fn test_a_line_scoped_directive_takes_the_fence_out_of_the_vote() {
932 let directive = "<!-- rumdl-disable-next-line MD040 -->\n";
933 let content =
934 format!("```bash\necho one\n```\n\n{directive}```sh\necho two\n```\n\n{directive}```sh\necho three\n```\n");
935 let config = MD040Config {
936 style: LanguageStyle::Consistent,
937 ..Default::default()
938 };
939
940 let without_directives = content.replace(directive, "");
941 assert_eq!(
942 run_check_with_config(&without_directives, config.clone())
943 .unwrap()
944 .len(),
945 1,
946 "control: two sh fences outvote the bash one"
947 );
948
949 let result = run_check_with_config(&content, config).unwrap();
952 assert!(result.is_empty(), "a disabled fence casts no vote: {result:?}");
953 }
954
955 #[test]
956 fn test_a_directive_shown_inside_a_code_block_disables_nothing() {
957 let sample = "```text\n<!-- rumdl-disable RULE -->\n```\n";
960 let blocks = "\n```bash\necho one\n```\n\n```sh\necho two\n```\n\n```sh\necho three\n```\n";
961 let config = MD040Config {
962 style: LanguageStyle::Consistent,
963 ..Default::default()
964 };
965
966 let without_sample = run_check_with_config(blocks, config.clone()).unwrap();
967 assert_eq!(without_sample.len(), 1, "control: the bash fence is reported");
968
969 for name in ["MD040", "fenced-code-language"] {
970 let content = format!("{}{blocks}", sample.replace("RULE", name));
971 let result = run_check_with_config(&content, config.clone()).unwrap();
972 assert_eq!(
973 result.len(),
974 1,
975 "`{name}` inside a code block is sample text, not a directive: {result:?}"
976 );
977 }
978 }
979
980 #[test]
981 fn test_fix_preserves_attributes() {
982 let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
983 let config = MD040Config {
984 style: LanguageStyle::Consistent,
985 ..Default::default()
986 };
987 let fixed = run_fix_with_config(content, config).unwrap();
988 assert!(fixed.contains("```bash {.highlight}"));
989 }
990
991 #[test]
992 fn test_fix_preserves_spacing_before_label() {
993 let content = "```bash\ncode\n```\n\n``` sh {.highlight}\ncode\n```";
994 let config = MD040Config {
995 style: LanguageStyle::Consistent,
996 ..Default::default()
997 };
998 let fixed = run_fix_with_config(content, config).unwrap();
999 assert!(fixed.contains("``` bash {.highlight}"));
1000 assert!(!fixed.contains("``` sh {.highlight}"));
1001 }
1002
1003 #[test]
1008 fn test_allowlist_blocks_unlisted() {
1009 let content = "```java\ncode\n```";
1010 let config = MD040Config {
1011 allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
1012 ..Default::default()
1013 };
1014 let result = run_check_with_config(content, config).unwrap();
1015 assert_eq!(result.len(), 1);
1016 assert!(result[0].message.contains("not in the allowed list"));
1017 }
1018
1019 #[test]
1020 fn test_allowlist_allows_listed() {
1021 let content = "```python\ncode\n```";
1022 let config = MD040Config {
1023 allowed_languages: vec!["Python".to_string()],
1024 ..Default::default()
1025 };
1026 let result = run_check_with_config(content, config).unwrap();
1027 assert!(result.is_empty());
1028 }
1029
1030 #[test]
1031 fn test_allowlist_blocks_unknown_language() {
1032 let content = "```mysterylang\ncode\n```";
1033 let config = MD040Config {
1034 allowed_languages: vec!["Python".to_string()],
1035 ..Default::default()
1036 };
1037 let result = run_check_with_config(content, config).unwrap();
1038 assert_eq!(result.len(), 1);
1039 assert!(result[0].message.contains("allowed list"));
1040 }
1041
1042 #[test]
1043 fn test_allowlist_case_insensitive() {
1044 let content = "```python\ncode\n```";
1045 let config = MD040Config {
1046 allowed_languages: vec!["PYTHON".to_string()],
1047 ..Default::default()
1048 };
1049 let result = run_check_with_config(content, config).unwrap();
1050 assert!(result.is_empty());
1051 }
1052
1053 #[test]
1054 fn test_denylist_blocks_listed() {
1055 let content = "```java\ncode\n```";
1056 let config = MD040Config {
1057 disallowed_languages: vec!["Java".to_string()],
1058 ..Default::default()
1059 };
1060 let result = run_check_with_config(content, config).unwrap();
1061 assert_eq!(result.len(), 1);
1062 assert!(result[0].message.contains("disallowed"));
1063 }
1064
1065 #[test]
1066 fn test_denylist_allows_unlisted() {
1067 let content = "```python\ncode\n```";
1068 let config = MD040Config {
1069 disallowed_languages: vec!["Java".to_string()],
1070 ..Default::default()
1071 };
1072 let result = run_check_with_config(content, config).unwrap();
1073 assert!(result.is_empty());
1074 }
1075
1076 #[test]
1077 fn test_allowlist_takes_precedence_over_denylist() {
1078 let content = "```python\ncode\n```";
1079 let config = MD040Config {
1080 allowed_languages: vec!["Python".to_string()],
1081 disallowed_languages: vec!["Python".to_string()], ..Default::default()
1083 };
1084 let result = run_check_with_config(content, config).unwrap();
1085 assert!(result.is_empty());
1086 }
1087
1088 #[test]
1093 fn test_unknown_language_ignore_default() {
1094 let content = "```mycustomlang\ncode\n```";
1095 let result = run_check(content).unwrap();
1096 assert!(result.is_empty(), "Unknown languages ignored by default");
1097 }
1098
1099 #[test]
1100 fn test_unknown_language_warn() {
1101 let content = "```mycustomlang\ncode\n```";
1102 let config = MD040Config {
1103 unknown_language_action: UnknownLanguageAction::Warn,
1104 ..Default::default()
1105 };
1106 let result = run_check_with_config(content, config).unwrap();
1107 assert_eq!(result.len(), 1);
1108 assert!(result[0].message.contains("Unknown language"));
1109 assert!(result[0].message.contains("mycustomlang"));
1110 assert_eq!(result[0].severity, Severity::Warning);
1111 }
1112
1113 #[test]
1121 fn test_unknown_language_warn_known_aliases_not_flagged() {
1122 let known_aliases = [
1123 "py",
1124 "python",
1125 "sh",
1126 "bash",
1127 "shell",
1128 "zsh",
1129 "js",
1130 "javascript",
1131 "ts",
1132 "typescript",
1133 "rb",
1134 "ruby",
1135 "rs",
1136 "rust",
1137 "yml",
1138 "yaml",
1139 "cpp",
1140 "c++",
1141 "csharp",
1142 "golang",
1143 "dockerfile",
1144 "jsonc",
1145 "kotlin",
1146 ];
1147 for alias in known_aliases {
1148 let content = format!("```{alias}\ncode\n```");
1149 let config = MD040Config {
1150 unknown_language_action: UnknownLanguageAction::Warn,
1151 ..Default::default()
1152 };
1153 let result = run_check_with_config(&content, config).unwrap();
1154 assert!(
1155 result.is_empty(),
1156 "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1157 );
1158 }
1159 }
1160
1161 #[test]
1166 fn test_unknown_language_extension_labels_not_flagged() {
1167 for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
1168 let content = format!("```{label}\ncode\n```");
1169 let config = MD040Config {
1170 unknown_language_action: UnknownLanguageAction::Error,
1171 ..Default::default()
1172 };
1173 let result = run_check_with_config(&content, config).unwrap();
1174 assert!(
1175 result.is_empty(),
1176 "Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
1177 );
1178 }
1179 }
1180
1181 #[test]
1182 fn test_unknown_language_error() {
1183 let content = "```mycustomlang\ncode\n```";
1184 let config = MD040Config {
1185 unknown_language_action: UnknownLanguageAction::Error,
1186 ..Default::default()
1187 };
1188 let result = run_check_with_config(content, config).unwrap();
1189 assert_eq!(result.len(), 1);
1190 assert!(result[0].message.contains("Unknown language"));
1191 assert_eq!(result[0].severity, Severity::Error);
1192 }
1193
1194 #[test]
1199 fn test_invalid_preferred_alias_detected() {
1200 let mut preferred = HashMap::new();
1201 preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1202
1203 let config = MD040Config {
1204 style: LanguageStyle::Consistent,
1205 preferred_aliases: preferred,
1206 ..Default::default()
1207 };
1208 let rule = MD040FencedCodeLanguage::with_config(config);
1209 let errors = rule.validate_config();
1210 assert_eq!(errors.len(), 1);
1211 assert!(errors[0].contains("Invalid alias"));
1212 assert!(errors[0].contains("invalid_alias"));
1213 }
1214
1215 #[test]
1216 fn test_invalid_preferred_alias_is_not_normalized_to() {
1217 let content = "```sh\necho one\n```\n\n```bash\necho two\n```\n";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221
1222 let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1223 style: LanguageStyle::Consistent,
1224 preferred_aliases: HashMap::from([("Shell".to_string(), "invalid_alias".to_string())]),
1225 ..Default::default()
1226 });
1227 let fixed = rule.fix(&ctx).unwrap();
1228 assert!(
1229 !fixed.contains("invalid_alias"),
1230 "an invalid alias must not reach the document, got:\n{fixed}"
1231 );
1232 assert!(
1233 rule.check(&ctx)
1234 .unwrap()
1235 .iter()
1236 .any(|w| w.message.contains("Invalid alias")),
1237 "the invalid alias is still reported"
1238 );
1239
1240 let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1242 style: LanguageStyle::Consistent,
1243 preferred_aliases: HashMap::from([("Shell".to_string(), "zsh".to_string())]),
1244 ..Default::default()
1245 });
1246 assert_eq!(
1247 rule.fix(&ctx).unwrap(),
1248 "```zsh\necho one\n```\n\n```zsh\necho two\n```\n"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_unknown_language_in_preferred_aliases_detected() {
1254 let mut preferred = HashMap::new();
1255 preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1256
1257 let config = MD040Config {
1258 style: LanguageStyle::Consistent,
1259 preferred_aliases: preferred,
1260 ..Default::default()
1261 };
1262 let rule = MD040FencedCodeLanguage::with_config(config);
1263 let errors = rule.validate_config();
1264 assert_eq!(errors.len(), 1);
1265 assert!(errors[0].contains("Unknown language"));
1266 }
1267
1268 #[test]
1269 fn test_valid_preferred_alias_accepted() {
1270 let mut preferred = HashMap::new();
1271 preferred.insert("Shell".to_string(), "bash".to_string());
1272 preferred.insert("JavaScript".to_string(), "js".to_string());
1273
1274 let config = MD040Config {
1275 style: LanguageStyle::Consistent,
1276 preferred_aliases: preferred,
1277 ..Default::default()
1278 };
1279 let rule = MD040FencedCodeLanguage::with_config(config);
1280 let errors = rule.validate_config();
1281 assert!(errors.is_empty());
1282 }
1283
1284 #[test]
1285 fn test_config_error_uses_valid_line_column() {
1286 let config = md040_config::MD040Config {
1287 preferred_aliases: {
1288 let mut map = std::collections::HashMap::new();
1289 map.insert("Shell".to_string(), "invalid_alias".to_string());
1290 map
1291 },
1292 ..Default::default()
1293 };
1294 let rule = MD040FencedCodeLanguage::with_config(config);
1295
1296 let content = "```shell\necho hello\n```";
1297 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let result = rule.check(&ctx).unwrap();
1299
1300 let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1302 assert!(config_error.is_some(), "Should have a config error warning");
1303
1304 let warning = config_error.unwrap();
1305 assert!(
1307 warning.line >= 1,
1308 "Config error line should be >= 1, got {}",
1309 warning.line
1310 );
1311 assert!(
1312 warning.column >= 1,
1313 "Config error column should be >= 1, got {}",
1314 warning.column
1315 );
1316 }
1317
1318 fn custom_languages_config(declared: &[&str]) -> MD040Config {
1323 MD040Config {
1324 unknown_language_action: UnknownLanguageAction::Error,
1325 custom_languages: declared.iter().map(|s| (*s).to_string()).collect(),
1326 ..Default::default()
1327 }
1328 }
1329
1330 #[test]
1331 fn test_declared_custom_language_is_not_unknown() {
1332 let content = "```cddl\nfoo = tstr\n```\n";
1333
1334 let flagged = run_check_with_config(content, custom_languages_config(&[])).unwrap();
1335 assert_eq!(flagged.len(), 1, "an undeclared unknown label must still be reported");
1336 assert!(flagged[0].message.contains("Unknown language 'cddl'"));
1337
1338 let accepted = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1339 assert!(accepted.is_empty(), "a declared label must be accepted: {accepted:?}");
1340 }
1341
1342 #[test]
1343 fn test_custom_language_matches_a_label_case_insensitively() {
1344 let content = "```CDDL\nfoo = tstr\n```\n";
1345 let result = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1346 assert!(result.is_empty(), "label case must not matter: {result:?}");
1347 }
1348
1349 #[test]
1350 fn test_custom_language_does_not_shadow_linguist() {
1351 let content = "```sh\necho hi\n```\n\n```bash\necho there\n```\n\n```bash\necho again\n```\n";
1354 let config = MD040Config {
1355 style: LanguageStyle::Consistent,
1356 custom_languages: vec!["sh".to_string()],
1357 ..Default::default()
1358 };
1359 let result = run_check_with_config(content, config.clone()).unwrap();
1360 assert_eq!(result.len(), 1, "sh must still be judged against Shell: {result:?}");
1361 assert!(result[0].message.contains("use 'bash'"));
1362
1363 let fixed = run_fix_with_config(content, config).unwrap();
1364 assert!(!fixed.contains("```sh\n"));
1365 }
1366
1367 #[test]
1368 fn test_custom_language_normalizes_under_consistent_style() {
1369 let content = "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n";
1370 let config = MD040Config {
1371 style: LanguageStyle::Consistent,
1372 custom_languages: vec!["cddl".to_string()],
1373 ..Default::default()
1374 };
1375 let result = run_check_with_config(content, config.clone()).unwrap();
1376 assert_eq!(result.len(), 1, "the two spellings are one language: {result:?}");
1377
1378 let fixed = run_fix_with_config(content, config).unwrap();
1380 assert!(fixed.contains("```cddl"));
1381 assert!(!fixed.contains("```CDDL"));
1382 }
1383
1384 #[test]
1385 fn test_custom_language_participates_in_allowed_and_disallowed_lists() {
1386 let content = "```cddl\nfoo = tstr\n```\n";
1387
1388 let allowed = run_check_with_config(
1389 content,
1390 MD040Config {
1391 allowed_languages: vec!["cddl".to_string()],
1392 custom_languages: vec!["cddl".to_string()],
1393 ..Default::default()
1394 },
1395 )
1396 .unwrap();
1397 assert!(allowed.is_empty(), "an allowed custom language passes: {allowed:?}");
1398
1399 let disallowed = run_check_with_config(
1400 content,
1401 MD040Config {
1402 disallowed_languages: vec!["cddl".to_string()],
1403 custom_languages: vec!["cddl".to_string()],
1404 ..Default::default()
1405 },
1406 )
1407 .unwrap();
1408 assert_eq!(disallowed.len(), 1, "a disallowed custom language is reported");
1409 assert!(disallowed[0].message.contains("is disallowed"));
1410 }
1411
1412 #[test]
1413 fn test_undeclared_language_is_not_allowed_by_the_allowlist() {
1414 let result = run_check_with_config(
1417 "```cddl\nfoo = tstr\n```\n",
1418 MD040Config {
1419 allowed_languages: vec!["cddl".to_string()],
1420 ..Default::default()
1421 },
1422 )
1423 .unwrap();
1424 assert_eq!(result.len(), 1);
1425 assert!(result[0].message.contains("is not in the allowed list"));
1426 }
1427
1428 #[test]
1429 fn test_unusable_custom_language_entries_are_config_errors() {
1430 let rule =
1431 MD040FencedCodeLanguage::with_config(custom_languages_config(&["c ddl", "cddl ", " cddl", " ", "cddl"]));
1432 let errors = rule.validate_config();
1433 assert_eq!(errors.len(), 4, "only the unusable entries are reported: {errors:?}");
1434 assert_eq!(
1435 errors.iter().filter(|e| e.contains("contains whitespace")).count(),
1436 3,
1437 "whitespace around an entry is as unmatchable as whitespace inside it: {errors:?}"
1438 );
1439 assert!(errors.iter().any(|e| e.contains("Empty entry in custom-languages")));
1440 }
1441
1442 #[test]
1443 fn test_custom_language_with_surrounding_whitespace_does_not_match_a_label() {
1444 let content = "```cddl\nfoo = int\n```";
1445 let result = run_check_with_config(content, custom_languages_config(&["cddl "])).unwrap();
1446 assert_eq!(
1447 result.len(),
1448 2,
1449 "the label stays unknown and the entry is reported: {result:?}"
1450 );
1451 assert!(result[0].message.contains("[config error]"));
1452 assert!(result[0].message.contains("contains whitespace"));
1453 assert!(result[1].message.contains("Unknown language 'cddl'"));
1454 }
1455
1456 #[test]
1457 fn test_preferred_alias_for_a_custom_language() {
1458 let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1459 preferred_aliases: HashMap::from([("CDDL".to_string(), "cddl".to_string())]),
1460 custom_languages: vec!["cddl".to_string()],
1461 ..Default::default()
1462 });
1463 assert!(
1464 accepted.validate_config().is_empty(),
1465 "a spelling of the declared name is a valid preference"
1466 );
1467
1468 let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1469 preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1470 custom_languages: vec!["cddl".to_string()],
1471 ..Default::default()
1472 });
1473 let errors = rejected.validate_config();
1474 assert_eq!(errors.len(), 1, "a custom language has no aliases: {errors:?}");
1475 assert!(errors[0].contains("Invalid alias 'cbor-dl' for custom language 'cddl'"));
1476
1477 let ctx = LintContext::new(
1480 "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n",
1481 crate::config::MarkdownFlavor::Standard,
1482 None,
1483 );
1484 let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1485 style: LanguageStyle::Consistent,
1486 preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1487 custom_languages: vec!["cddl".to_string()],
1488 ..Default::default()
1489 });
1490 assert_eq!(
1491 rejected.fix(&ctx).unwrap(),
1492 "```cddl\nfoo = tstr\n```\n\n```cddl\nbar = int\n```\n"
1493 );
1494
1495 let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1497 style: LanguageStyle::Consistent,
1498 preferred_aliases: HashMap::from([("cddl".to_string(), "CDDL".to_string())]),
1499 custom_languages: vec!["cddl".to_string()],
1500 ..Default::default()
1501 });
1502 assert_eq!(
1503 accepted.fix(&ctx).unwrap(),
1504 "```CDDL\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n"
1505 );
1506 }
1507
1508 #[test]
1513 fn test_linguist_resolution() {
1514 assert_eq!(resolve_canonical("bash"), Some("Shell"));
1515 assert_eq!(resolve_canonical("sh"), Some("Shell"));
1516 assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1517 assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1518 assert_eq!(resolve_canonical("python"), Some("Python"));
1519 assert_eq!(resolve_canonical("unknown_lang"), None);
1520 }
1521
1522 #[test]
1523 fn test_linguist_resolution_case_insensitive() {
1524 assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1525 assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1526 assert_eq!(resolve_canonical("Python"), Some("Python"));
1527 assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1528 }
1529
1530 #[test]
1531 fn test_alias_validation() {
1532 use crate::linguist_data::is_valid_alias;
1533
1534 assert!(is_valid_alias("Shell", "bash"));
1535 assert!(is_valid_alias("Shell", "sh"));
1536 assert!(is_valid_alias("Shell", "zsh"));
1537 assert!(!is_valid_alias("Shell", "python"));
1538 assert!(!is_valid_alias("Shell", "invalid"));
1539 }
1540
1541 #[test]
1542 fn test_default_alias() {
1543 assert_eq!(default_alias("Shell"), Some("bash"));
1544 assert_eq!(default_alias("JavaScript"), Some("js"));
1545 assert_eq!(default_alias("Python"), Some("python"));
1546 }
1547
1548 #[test]
1553 fn test_mixed_case_labels_normalized() {
1554 let content = r#"```BASH
1555echo hi
1556```
1557
1558```Bash
1559echo there
1560```
1561
1562```bash
1563echo again
1564```
1565"#;
1566 let config = MD040Config {
1567 style: LanguageStyle::Consistent,
1568 ..Default::default()
1569 };
1570 let result = run_check_with_config(content, config).unwrap();
1572 assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1576 }
1577
1578 #[test]
1579 fn test_multiple_languages_independent() {
1580 let content = r#"```bash
1581shell code
1582```
1583
1584```python
1585python code
1586```
1587
1588```sh
1589more shell
1590```
1591
1592```python3
1593more python
1594```
1595"#;
1596 let config = MD040Config {
1597 style: LanguageStyle::Consistent,
1598 ..Default::default()
1599 };
1600 let result = run_check_with_config(content, config).unwrap();
1601 assert_eq!(result.len(), 2);
1603 }
1604
1605 #[test]
1606 fn test_tilde_fences() {
1607 let content = r#"~~~bash
1608echo hi
1609~~~
1610
1611~~~sh
1612echo there
1613~~~
1614"#;
1615 let config = MD040Config {
1616 style: LanguageStyle::Consistent,
1617 ..Default::default()
1618 };
1619 let result = run_check_with_config(content, config.clone()).unwrap();
1620 assert_eq!(result.len(), 1);
1621
1622 let fixed = run_fix_with_config(content, config).unwrap();
1623 assert!(fixed.contains("~~~bash"));
1624 assert!(!fixed.contains("~~~sh"));
1625 }
1626
1627 #[test]
1628 fn test_longer_fence_markers_preserved() {
1629 let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1630 let config = MD040Config {
1631 style: LanguageStyle::Consistent,
1632 ..Default::default()
1633 };
1634 let fixed = run_fix_with_config(content, config).unwrap();
1635 assert!(fixed.contains("````bash"));
1636 assert!(fixed.contains("```bash"));
1637 }
1638
1639 #[test]
1640 fn test_empty_document() {
1641 let result = run_check("").unwrap();
1642 assert!(result.is_empty());
1643 }
1644
1645 #[test]
1646 fn test_no_code_blocks() {
1647 let content = "# Just a heading\n\nSome text.";
1648 let result = run_check(content).unwrap();
1649 assert!(result.is_empty());
1650 }
1651
1652 #[test]
1653 fn test_single_code_block_no_inconsistency() {
1654 let content = "```bash\necho hi\n```";
1655 let config = MD040Config {
1656 style: LanguageStyle::Consistent,
1657 ..Default::default()
1658 };
1659 let result = run_check_with_config(content, config).unwrap();
1660 assert!(result.is_empty(), "Single block has no inconsistency");
1661 }
1662
1663 #[test]
1664 fn test_idempotent_fix() {
1665 let content = r#"```bash
1666echo hi
1667```
1668
1669```sh
1670echo there
1671```
1672"#;
1673 let config = MD040Config {
1674 style: LanguageStyle::Consistent,
1675 ..Default::default()
1676 };
1677 let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1678 let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1679 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1680 }
1681
1682 #[test]
1687 fn test_mkdocs_superfences_attribute_in_blockquote() {
1688 let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1691 let result = run_check_mkdocs(content).unwrap();
1692 assert!(
1693 result.is_empty(),
1694 "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1695 );
1696 }
1697
1698 #[test]
1699 fn test_mkdocs_superfences_title_only() {
1700 let content = r#"```title="Example"
1702echo hi
1703```
1704"#;
1705 let result = run_check_mkdocs(content).unwrap();
1706 assert!(
1707 result.is_empty(),
1708 "MkDocs superfences with title= should not require language"
1709 );
1710 }
1711
1712 #[test]
1713 fn test_mkdocs_superfences_hl_lines() {
1714 let content = r#"```hl_lines="1 2"
1716line 1
1717line 2
1718```
1719"#;
1720 let result = run_check_mkdocs(content).unwrap();
1721 assert!(
1722 result.is_empty(),
1723 "MkDocs superfences with hl_lines= should not require language"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_mkdocs_superfences_linenums() {
1729 let content = r#"```linenums="1"
1731line 1
1732line 2
1733```
1734"#;
1735 let result = run_check_mkdocs(content).unwrap();
1736 assert!(
1737 result.is_empty(),
1738 "MkDocs superfences with linenums= should not require language"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_mkdocs_superfences_class() {
1744 let content = r#"```.my-class
1746some text
1747```
1748"#;
1749 let result = run_check_mkdocs(content).unwrap();
1750 assert!(
1751 result.is_empty(),
1752 "MkDocs superfences with .class should not require language"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_mkdocs_superfences_id() {
1758 let content = r#"```#my-id
1760some text
1761```
1762"#;
1763 let result = run_check_mkdocs(content).unwrap();
1764 assert!(
1765 result.is_empty(),
1766 "MkDocs superfences with #id should not require language"
1767 );
1768 }
1769
1770 #[test]
1771 fn test_mkdocs_superfences_with_language() {
1772 let content = r#"```python title="Example" hl_lines="1"
1774print("hello")
1775```
1776"#;
1777 let result = run_check_mkdocs(content).unwrap();
1778 assert!(result.is_empty(), "Code block with language and attrs should pass");
1779 }
1780
1781 #[test]
1782 fn test_standard_flavor_no_special_handling() {
1783 let content = r#"```title="Example"
1785echo hi
1786```
1787"#;
1788 let result = run_check(content).unwrap();
1789 assert_eq!(
1790 result.len(),
1791 1,
1792 "Standard flavor should warn about title= without language"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1798 let rule = MD040FencedCodeLanguage::default();
1801 let content = "```{=html}\n<div>raw html</div>\n```\n";
1802 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1803 let result = rule.check(&ctx).unwrap();
1804 assert!(
1805 result.is_empty(),
1806 "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1812 let rule = MD040FencedCodeLanguage::default();
1814 let content = "```{=html}\n<div>raw html</div>\n```\n";
1815 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1816 let result = rule.check(&ctx).unwrap();
1817 assert!(
1818 result.is_empty(),
1819 "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1820 );
1821 }
1822
1823 #[test]
1826 fn test_pandoc_accepts_raw_html_block() {
1827 use crate::config::MarkdownFlavor;
1828 let rule = MD040FencedCodeLanguage::default();
1829 let content = "```{=html}\n<div>raw</div>\n```\n";
1830 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1831 let result = rule.check(&ctx).unwrap();
1832 assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1833 }
1834
1835 #[test]
1838 fn test_pandoc_rejects_quarto_exec_blocks() {
1839 use crate::config::MarkdownFlavor;
1840 let rule = MD040FencedCodeLanguage::default();
1841 let content = "```{r}\nsummary(data)\n```\n";
1842 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1843 let result = rule.check(&ctx).unwrap();
1844 assert!(
1845 !result.is_empty(),
1846 "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1847 );
1848 }
1849
1850 #[test]
1852 fn test_quarto_still_accepts_exec_block() {
1853 use crate::config::MarkdownFlavor;
1854 let rule = MD040FencedCodeLanguage::default();
1855 let content = "```{r}\nsummary(data)\n```\n";
1856 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1857 let result = rule.check(&ctx).unwrap();
1858 assert!(
1859 result.is_empty(),
1860 "MD040 under Quarto should accept `{{r}}`: {result:?}"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_quarto_exec_block_skipped_under_quarto_only() {
1866 let rule = MD040FencedCodeLanguage::default();
1870 let content = "```{r}\n1 + 1\n```\n";
1871
1872 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1873 let result_quarto = rule.check(&ctx_quarto).unwrap();
1874 assert!(
1875 result_quarto.is_empty(),
1876 "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1877 );
1878
1879 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1882 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1883 assert!(
1884 !result_pandoc.is_empty(),
1885 "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1886 );
1887 }
1888
1889 #[test]
1892 fn test_pandoc_class_attr_accepted_as_language() {
1893 use crate::config::MarkdownFlavor;
1894 let rule = MD040FencedCodeLanguage::default();
1895 let content = "```{.python}\nprint(\"hi\")\n```\n";
1896
1897 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1898 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1899 assert!(
1900 result_pandoc.is_empty(),
1901 "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1902 );
1903
1904 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1905 let result_quarto = rule.check(&ctx_quarto).unwrap();
1906 assert!(
1907 result_quarto.is_empty(),
1908 "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1909 );
1910 }
1911
1912 #[test]
1915 fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1916 use crate::config::MarkdownFlavor;
1917 let rule = MD040FencedCodeLanguage::default();
1918 let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1919
1920 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1921 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1922 assert!(
1923 result_pandoc.is_empty(),
1924 "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1925 );
1926
1927 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1928 let result_quarto = rule.check(&ctx_quarto).unwrap();
1929 assert!(
1930 result_quarto.is_empty(),
1931 "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1932 );
1933 }
1934
1935 #[test]
1938 fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1939 use crate::config::MarkdownFlavor;
1940 let rule = MD040FencedCodeLanguage::default();
1941 let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1942
1943 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1944 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1945 assert!(
1946 result_pandoc.is_empty(),
1947 "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1948 );
1949 }
1950
1951 #[test]
1954 fn test_standard_still_flags_pandoc_class_attr() {
1955 use crate::config::MarkdownFlavor;
1956 let rule = MD040FencedCodeLanguage::default();
1957 let content = "```{.python}\nprint(\"hi\")\n```\n";
1958
1959 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1960 let result_standard = rule.check(&ctx_standard).unwrap();
1961 assert!(
1962 !result_standard.is_empty(),
1963 "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1964 );
1965 }
1966
1967 #[test]
1970 fn test_pandoc_id_only_attr_still_flagged() {
1971 use crate::config::MarkdownFlavor;
1972 let rule = MD040FencedCodeLanguage::default();
1973 let content = "```{#myid}\ncode here\n```\n";
1974
1975 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1976 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1977 assert!(
1978 !result_pandoc.is_empty(),
1979 "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1980 );
1981 }
1982
1983 #[test]
1985 fn test_pandoc_empty_braces_still_flagged() {
1986 use crate::config::MarkdownFlavor;
1987 let rule = MD040FencedCodeLanguage::default();
1988 let content = "```{}\ncode here\n```\n";
1989
1990 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1991 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1992 assert!(
1993 !result_pandoc.is_empty(),
1994 "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1995 );
1996 }
1997
1998 #[test]
1999 fn test_mdg_reports_doc_string_media_type_without_fixing_it() {
2000 use crate::config::MarkdownFlavor;
2001
2002 let rule = MD040FencedCodeLanguage::default();
2006
2007 for content in [
2008 "* Given the following message:\n\n ```\n hello\n ```\n",
2009 "* Given the following message:\n\n ~~~\n hello\n ~~~\n",
2010 ] {
2011 let mdg_ctx = LintContext::new(content, MarkdownFlavor::MDG, None);
2012 let standard_ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2013
2014 let warnings = rule.check(&mdg_ctx).unwrap();
2015 assert_eq!(warnings.len(), 1, "MDG must still flag {content:?}");
2016 assert!(warnings[0].message.contains("missing language"));
2017 assert!(warnings[0].fix.is_none(), "MDG must not offer a media-type fix");
2018 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must preserve {content:?}");
2019
2020 let standard_warnings = rule.check(&standard_ctx).unwrap();
2021 assert_eq!(standard_warnings.len(), 1);
2022 assert!(standard_warnings[0].fix.is_some(), "Standard keeps the existing fix");
2023 assert_eq!(
2024 rule.fix(&standard_ctx).unwrap(),
2025 content
2026 .replacen("```\n", "```text\n", 1)
2027 .replacen("~~~\n", "~~~text\n", 1),
2028 "Standard still adds its default language label"
2029 );
2030 }
2031 }
2032
2033 #[test]
2034 fn test_mdg_reports_inconsistent_media_type_without_normalizing_it() {
2035 use crate::config::MarkdownFlavor;
2036
2037 let config = MD040Config {
2038 style: LanguageStyle::Consistent,
2039 preferred_aliases: HashMap::from([("JavaScript".to_string(), "javascript".to_string())]),
2040 ..MD040Config::default()
2041 };
2042 let rule = MD040FencedCodeLanguage::with_config(config);
2043 let content = "* Given this script:\n\n ```js\n alert('ok')\n ```\n";
2044
2045 let mdg_ctx = LintContext::new(content, MarkdownFlavor::MDG, None);
2046 let mdg = rule.check(&mdg_ctx).unwrap();
2047 assert_eq!(mdg.len(), 1);
2048 assert!(mdg[0].message.contains("Inconsistent language label"));
2049 assert!(mdg[0].fix.is_none());
2050 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
2051
2052 let standard_ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2053 let standard = rule.check(&standard_ctx).unwrap();
2054 assert_eq!(standard.len(), 1);
2055 assert!(standard[0].fix.is_some());
2056 assert!(rule.fix(&standard_ctx).unwrap().contains("```javascript"));
2057 }
2058}