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 let ambiguity_limit = super::code_fence_utils::fence_ambiguity_limit(ctx);
480
481 ctx.code_block_details
482 .iter()
483 .filter(|d| d.is_fenced && !ctx.is_inside_template_code(d.start) && !ctx.is_inside_mdx_code(d.start))
484 .filter(|d| ambiguity_limit.is_none_or(|limit| d.start < limit))
485 .map(|detail| {
486 let line_idx = match line_offsets.binary_search(&detail.start) {
487 Ok(idx) => idx,
488 Err(idx) => idx.saturating_sub(1),
489 };
490
491 let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
493 let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
494 let line = content.get(line_start..line_end).unwrap_or("");
495 let fence_marker =
496 find_fence_marker(line).map_or_else(|| "```".to_string(), |(_, marker)| marker.to_string());
497
498 let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
499
500 FencedCodeBlock {
501 line_idx,
502 language,
503 fence_marker,
504 }
505 })
506 .collect()
507}
508
509fn find_fence_marker(line: &str) -> Option<(usize, &str)> {
517 let bytes = line.as_bytes();
518 let start = bytes.iter().position(|&b| b == b'`' || b == b'~')?;
519 let fence_char = bytes[start];
520 let len = bytes[start..].iter().take_while(|&&b| b == fence_char).count();
521 Some((start, &line[start..start + len]))
522}
523
524fn fence_marker_offset(line: &str, fence_marker: &str) -> Option<usize> {
529 let (start, marker) = find_fence_marker(line)?;
530 (marker == fence_marker).then_some(start)
531}
532
533fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
535 let marker_offset = fence_marker_offset(line, fence_marker)?;
536 let after_fence = &line[marker_offset + fence_marker.len()..];
537
538 let label_start_rel = after_fence
539 .char_indices()
540 .find(|&(_, ch)| !ch.is_whitespace())
541 .map(|(idx, _)| idx)?;
542 let after_label = &after_fence[label_start_rel..];
543 let label_end_rel = after_label
544 .char_indices()
545 .find(|&(_, ch)| ch.is_whitespace())
546 .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
547
548 Some((
549 marker_offset + fence_marker.len() + label_start_rel,
550 marker_offset + fence_marker.len() + label_end_rel,
551 ))
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::lint_context::LintContext;
558
559 fn run_check(content: &str) -> LintResult {
560 let rule = MD040FencedCodeLanguage::default();
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 rule.check(&ctx)
563 }
564
565 fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
566 let rule = MD040FencedCodeLanguage::with_config(config);
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 rule.check(&ctx)
569 }
570
571 fn run_fix(content: &str) -> Result<String, LintError> {
572 let rule = MD040FencedCodeLanguage::default();
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 rule.fix(&ctx)
575 }
576
577 fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
578 let rule = MD040FencedCodeLanguage::with_config(config);
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 rule.fix(&ctx)
581 }
582
583 fn run_check_mkdocs(content: &str) -> LintResult {
584 let rule = MD040FencedCodeLanguage::default();
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
586 rule.check(&ctx)
587 }
588
589 #[test]
594 fn test_code_blocks_with_language_specified() {
595 let content = r#"# Test
596
597```python
598print("Hello, world!")
599```
600
601```javascript
602console.log("Hello!");
603```
604"#;
605 let result = run_check(content).unwrap();
606 assert!(result.is_empty(), "No warnings expected for code blocks with language");
607 }
608
609 #[test]
610 fn test_code_blocks_without_language() {
611 let content = r#"# Test
612
613```
614print("Hello, world!")
615```
616"#;
617 let result = run_check(content).unwrap();
618 assert_eq!(result.len(), 1);
619 assert_eq!(result[0].message, "Code block (```) missing language");
620 assert_eq!(result[0].line, 3);
621 }
622
623 #[test]
624 fn test_fix_method_adds_text_language() {
625 let content = r#"# Test
626
627```
628code without language
629```
630
631```python
632already has language
633```
634
635```
636another block without
637```
638"#;
639 let fixed = run_fix(content).unwrap();
640 assert!(fixed.contains("```text"));
641 assert!(fixed.contains("```python"));
642 assert_eq!(fixed.matches("```text").count(), 2);
643 }
644
645 #[test]
646 fn test_fix_preserves_indentation() {
647 let content = r#"# Test
648
649- List item
650 ```
651 indented code block
652 ```
653"#;
654 let fixed = run_fix(content).unwrap();
655 assert!(fixed.contains(" ```text"));
656 }
657
658 #[test]
659 fn test_fix_blockquote_empty_fence() {
660 let content = "# Title\n\n> ```\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
664 let fixed = run_fix(content).unwrap();
665 let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
666 assert_eq!(fixed, expected);
667 }
668
669 #[test]
670 fn test_fix_blockquote_tilde_and_longer_fences() {
671 let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
674 assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
675
676 let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
677 assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
678
679 let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
680 assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
681 }
682
683 #[test]
684 fn test_fix_nested_blockquote_empty_fence() {
685 let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
688 assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
689
690 let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
691 assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
692 }
693
694 #[test]
695 fn test_fix_list_marker_empty_fence() {
696 let content = "# Title\n\n- ```\n root/\n └── nested/\n └── file.txt\n ```\n";
701 let fixed = run_fix(content).unwrap();
702 let expected = "# Title\n\n- ```text\n root/\n └── nested/\n └── file.txt\n ```\n";
703 assert_eq!(fixed, expected);
704 }
705
706 #[test]
707 fn test_fix_list_marker_fence_across_marker_styles() {
708 for (input, expected) in [
712 ("- ```\n code\n ```\n", "- ```text\n code\n ```\n"),
713 ("* ```\n code\n ```\n", "* ```text\n code\n ```\n"),
714 ("+ ```\n code\n ```\n", "+ ```text\n code\n ```\n"),
715 ("1. ```\n code\n ```\n", "1. ```text\n code\n ```\n"),
716 ("1) ```\n code\n ```\n", "1) ```text\n code\n ```\n"),
717 (" - ```\n code\n ```\n", " - ```text\n code\n ```\n"),
718 ("- - ```\n code\n ```\n", "- - ```text\n code\n ```\n"),
719 ("> - ```\n> code\n> ```\n", "> - ```text\n> code\n> ```\n"),
720 ] {
721 assert_eq!(run_fix(input).unwrap(), expected, "input: {input:?}");
722 }
723 }
724
725 #[test]
726 fn test_fix_list_marker_tilde_and_longer_fences() {
727 let tilde = run_fix("- ~~~\n code\n ~~~\n").unwrap();
731 assert_eq!(tilde, "- ~~~text\n code\n ~~~\n");
732
733 let longer_tilde = run_fix("- ~~~~\n code\n ~~~~\n").unwrap();
734 assert_eq!(longer_tilde, "- ~~~~text\n code\n ~~~~\n");
735
736 let longer_backtick = run_fix("- ````\n code\n ````\n").unwrap();
737 assert_eq!(longer_backtick, "- ````text\n code\n ````\n");
738 }
739
740 #[test]
741 fn test_fix_list_marker_fence_is_idempotent() {
742 let content = "- ```\n root/\n nested\n ```\n";
743 let once = run_fix(content).unwrap();
744 let twice = run_fix(&once).unwrap();
745 assert_eq!(once, twice);
746 assert_eq!(once, "- ```text\n root/\n nested\n ```\n");
747 }
748
749 #[test]
750 fn test_fix_list_marker_fence_with_language_untouched() {
751 let content = "- ```rust\n code\n ```\n";
752 assert!(run_check(content).unwrap().is_empty());
753 assert_eq!(run_fix(content).unwrap(), content);
754 }
755
756 #[test]
757 fn test_fix_blockquote_empty_fence_is_idempotent() {
758 let content = "> ```\n> root/\n> nested\n> ```\n";
760 let once = run_fix(content).unwrap();
761 let twice = run_fix(&once).unwrap();
762 assert_eq!(once, twice);
763 assert_eq!(once, "> ```text\n> root/\n> nested\n> ```\n");
764 }
765
766 #[test]
771 fn test_consistent_mode_detects_inconsistency() {
772 let content = r#"```bash
773echo hi
774```
775
776```sh
777echo there
778```
779
780```bash
781echo again
782```
783"#;
784 let config = MD040Config {
785 style: LanguageStyle::Consistent,
786 ..Default::default()
787 };
788 let result = run_check_with_config(content, config).unwrap();
789 assert_eq!(result.len(), 1);
790 assert!(result[0].message.contains("Inconsistent"));
791 assert!(result[0].message.contains("sh"));
792 assert!(result[0].message.contains("bash"));
793 }
794
795 #[test]
796 fn test_consistent_mode_fix_normalizes() {
797 let content = r#"```bash
798echo hi
799```
800
801```sh
802echo there
803```
804
805```bash
806echo again
807```
808"#;
809 let config = MD040Config {
810 style: LanguageStyle::Consistent,
811 ..Default::default()
812 };
813 let fixed = run_fix_with_config(content, config).unwrap();
814 assert_eq!(fixed.matches("```bash").count(), 3);
815 assert_eq!(fixed.matches("```sh").count(), 0);
816 }
817
818 #[test]
819 fn test_consistent_mode_tie_break_uses_curated_default() {
820 let content = r#"```bash
822echo hi
823```
824
825```sh
826echo there
827```
828"#;
829 let config = MD040Config {
830 style: LanguageStyle::Consistent,
831 ..Default::default()
832 };
833 let fixed = run_fix_with_config(content, config).unwrap();
834 assert_eq!(fixed.matches("```bash").count(), 2);
836 }
837
838 #[test]
839 fn test_consistent_mode_with_preferred_alias() {
840 let content = r#"```bash
841echo hi
842```
843
844```sh
845echo there
846```
847"#;
848 let mut preferred = HashMap::new();
849 preferred.insert("Shell".to_string(), "sh".to_string());
850
851 let config = MD040Config {
852 style: LanguageStyle::Consistent,
853 preferred_aliases: preferred,
854 ..Default::default()
855 };
856 let fixed = run_fix_with_config(content, config).unwrap();
857 assert_eq!(fixed.matches("```sh").count(), 2);
858 assert_eq!(fixed.matches("```bash").count(), 0);
859 }
860
861 #[test]
862 fn test_consistent_mode_fix_inside_blockquote() {
863 let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
866 let config = MD040Config {
867 style: LanguageStyle::Consistent,
868 ..Default::default()
869 };
870 let fixed = run_fix_with_config(content, config).unwrap();
871 assert_eq!(
872 fixed,
873 "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
874 );
875 }
876
877 #[test]
878 fn test_consistent_mode_ignores_disabled_blocks() {
879 let content = r#"```bash
880echo hi
881```
882<!-- rumdl-disable MD040 -->
883```sh
884echo there
885```
886```sh
887echo again
888```
889<!-- rumdl-enable MD040 -->
890"#;
891 let config = MD040Config {
892 style: LanguageStyle::Consistent,
893 ..Default::default()
894 };
895 let result = run_check_with_config(content, config).unwrap();
896 assert!(result.is_empty(), "Disabled blocks should not affect consistency");
897 }
898
899 #[test]
900 fn test_disable_comment_naming_the_rule_by_alias_disables_it() {
901 let content = r#"```bash
902echo hi
903```
904<!-- rumdl-disable fenced-code-language -->
905```sh
906echo there
907```
908```sh
909echo again
910```
911<!-- rumdl-enable fenced-code-language -->
912"#;
913 let config = MD040Config {
914 style: LanguageStyle::Consistent,
915 ..Default::default()
916 };
917 let result = run_check_with_config(content, config.clone()).unwrap();
918 assert!(
919 result.is_empty(),
920 "an alias names the same rule as the ID does: {result:?}"
921 );
922
923 let names_another_rule = content.replace("fenced-code-language", "line-length");
924 let result = run_check_with_config(&names_another_rule, config).unwrap();
925 assert_eq!(
926 result.len(),
927 1,
928 "a directive naming another rule leaves the sh blocks voting: {result:?}"
929 );
930 }
931
932 #[test]
933 fn test_a_line_scoped_directive_takes_the_fence_out_of_the_vote() {
934 let directive = "<!-- rumdl-disable-next-line MD040 -->\n";
935 let content =
936 format!("```bash\necho one\n```\n\n{directive}```sh\necho two\n```\n\n{directive}```sh\necho three\n```\n");
937 let config = MD040Config {
938 style: LanguageStyle::Consistent,
939 ..Default::default()
940 };
941
942 let without_directives = content.replace(directive, "");
943 assert_eq!(
944 run_check_with_config(&without_directives, config.clone())
945 .unwrap()
946 .len(),
947 1,
948 "control: two sh fences outvote the bash one"
949 );
950
951 let result = run_check_with_config(&content, config).unwrap();
954 assert!(result.is_empty(), "a disabled fence casts no vote: {result:?}");
955 }
956
957 #[test]
958 fn test_a_directive_shown_inside_a_code_block_disables_nothing() {
959 let sample = "```text\n<!-- rumdl-disable RULE -->\n```\n";
962 let blocks = "\n```bash\necho one\n```\n\n```sh\necho two\n```\n\n```sh\necho three\n```\n";
963 let config = MD040Config {
964 style: LanguageStyle::Consistent,
965 ..Default::default()
966 };
967
968 let without_sample = run_check_with_config(blocks, config.clone()).unwrap();
969 assert_eq!(without_sample.len(), 1, "control: the bash fence is reported");
970
971 for name in ["MD040", "fenced-code-language"] {
972 let content = format!("{}{blocks}", sample.replace("RULE", name));
973 let result = run_check_with_config(&content, config.clone()).unwrap();
974 assert_eq!(
975 result.len(),
976 1,
977 "`{name}` inside a code block is sample text, not a directive: {result:?}"
978 );
979 }
980 }
981
982 #[test]
983 fn test_fix_preserves_attributes() {
984 let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
985 let config = MD040Config {
986 style: LanguageStyle::Consistent,
987 ..Default::default()
988 };
989 let fixed = run_fix_with_config(content, config).unwrap();
990 assert!(fixed.contains("```bash {.highlight}"));
991 }
992
993 #[test]
994 fn test_fix_preserves_spacing_before_label() {
995 let content = "```bash\ncode\n```\n\n``` sh {.highlight}\ncode\n```";
996 let config = MD040Config {
997 style: LanguageStyle::Consistent,
998 ..Default::default()
999 };
1000 let fixed = run_fix_with_config(content, config).unwrap();
1001 assert!(fixed.contains("``` bash {.highlight}"));
1002 assert!(!fixed.contains("``` sh {.highlight}"));
1003 }
1004
1005 #[test]
1010 fn test_allowlist_blocks_unlisted() {
1011 let content = "```java\ncode\n```";
1012 let config = MD040Config {
1013 allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
1014 ..Default::default()
1015 };
1016 let result = run_check_with_config(content, config).unwrap();
1017 assert_eq!(result.len(), 1);
1018 assert!(result[0].message.contains("not in the allowed list"));
1019 }
1020
1021 #[test]
1022 fn test_allowlist_allows_listed() {
1023 let content = "```python\ncode\n```";
1024 let config = MD040Config {
1025 allowed_languages: vec!["Python".to_string()],
1026 ..Default::default()
1027 };
1028 let result = run_check_with_config(content, config).unwrap();
1029 assert!(result.is_empty());
1030 }
1031
1032 #[test]
1033 fn test_allowlist_blocks_unknown_language() {
1034 let content = "```mysterylang\ncode\n```";
1035 let config = MD040Config {
1036 allowed_languages: vec!["Python".to_string()],
1037 ..Default::default()
1038 };
1039 let result = run_check_with_config(content, config).unwrap();
1040 assert_eq!(result.len(), 1);
1041 assert!(result[0].message.contains("allowed list"));
1042 }
1043
1044 #[test]
1045 fn test_allowlist_case_insensitive() {
1046 let content = "```python\ncode\n```";
1047 let config = MD040Config {
1048 allowed_languages: vec!["PYTHON".to_string()],
1049 ..Default::default()
1050 };
1051 let result = run_check_with_config(content, config).unwrap();
1052 assert!(result.is_empty());
1053 }
1054
1055 #[test]
1056 fn test_denylist_blocks_listed() {
1057 let content = "```java\ncode\n```";
1058 let config = MD040Config {
1059 disallowed_languages: vec!["Java".to_string()],
1060 ..Default::default()
1061 };
1062 let result = run_check_with_config(content, config).unwrap();
1063 assert_eq!(result.len(), 1);
1064 assert!(result[0].message.contains("disallowed"));
1065 }
1066
1067 #[test]
1068 fn test_denylist_allows_unlisted() {
1069 let content = "```python\ncode\n```";
1070 let config = MD040Config {
1071 disallowed_languages: vec!["Java".to_string()],
1072 ..Default::default()
1073 };
1074 let result = run_check_with_config(content, config).unwrap();
1075 assert!(result.is_empty());
1076 }
1077
1078 #[test]
1079 fn test_allowlist_takes_precedence_over_denylist() {
1080 let content = "```python\ncode\n```";
1081 let config = MD040Config {
1082 allowed_languages: vec!["Python".to_string()],
1083 disallowed_languages: vec!["Python".to_string()], ..Default::default()
1085 };
1086 let result = run_check_with_config(content, config).unwrap();
1087 assert!(result.is_empty());
1088 }
1089
1090 #[test]
1095 fn test_unknown_language_ignore_default() {
1096 let content = "```mycustomlang\ncode\n```";
1097 let result = run_check(content).unwrap();
1098 assert!(result.is_empty(), "Unknown languages ignored by default");
1099 }
1100
1101 #[test]
1102 fn test_unknown_language_warn() {
1103 let content = "```mycustomlang\ncode\n```";
1104 let config = MD040Config {
1105 unknown_language_action: UnknownLanguageAction::Warn,
1106 ..Default::default()
1107 };
1108 let result = run_check_with_config(content, config).unwrap();
1109 assert_eq!(result.len(), 1);
1110 assert!(result[0].message.contains("Unknown language"));
1111 assert!(result[0].message.contains("mycustomlang"));
1112 assert_eq!(result[0].severity, Severity::Warning);
1113 }
1114
1115 #[test]
1123 fn test_unknown_language_warn_known_aliases_not_flagged() {
1124 let known_aliases = [
1125 "py",
1126 "python",
1127 "sh",
1128 "bash",
1129 "shell",
1130 "zsh",
1131 "js",
1132 "javascript",
1133 "ts",
1134 "typescript",
1135 "rb",
1136 "ruby",
1137 "rs",
1138 "rust",
1139 "yml",
1140 "yaml",
1141 "cpp",
1142 "c++",
1143 "csharp",
1144 "golang",
1145 "dockerfile",
1146 "jsonc",
1147 "kotlin",
1148 ];
1149 for alias in known_aliases {
1150 let content = format!("```{alias}\ncode\n```");
1151 let config = MD040Config {
1152 unknown_language_action: UnknownLanguageAction::Warn,
1153 ..Default::default()
1154 };
1155 let result = run_check_with_config(&content, config).unwrap();
1156 assert!(
1157 result.is_empty(),
1158 "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1159 );
1160 }
1161 }
1162
1163 #[test]
1168 fn test_unknown_language_extension_labels_not_flagged() {
1169 for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
1170 let content = format!("```{label}\ncode\n```");
1171 let config = MD040Config {
1172 unknown_language_action: UnknownLanguageAction::Error,
1173 ..Default::default()
1174 };
1175 let result = run_check_with_config(&content, config).unwrap();
1176 assert!(
1177 result.is_empty(),
1178 "Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
1179 );
1180 }
1181 }
1182
1183 #[test]
1184 fn test_unknown_language_error() {
1185 let content = "```mycustomlang\ncode\n```";
1186 let config = MD040Config {
1187 unknown_language_action: UnknownLanguageAction::Error,
1188 ..Default::default()
1189 };
1190 let result = run_check_with_config(content, config).unwrap();
1191 assert_eq!(result.len(), 1);
1192 assert!(result[0].message.contains("Unknown language"));
1193 assert_eq!(result[0].severity, Severity::Error);
1194 }
1195
1196 #[test]
1201 fn test_invalid_preferred_alias_detected() {
1202 let mut preferred = HashMap::new();
1203 preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1204
1205 let config = MD040Config {
1206 style: LanguageStyle::Consistent,
1207 preferred_aliases: preferred,
1208 ..Default::default()
1209 };
1210 let rule = MD040FencedCodeLanguage::with_config(config);
1211 let errors = rule.validate_config();
1212 assert_eq!(errors.len(), 1);
1213 assert!(errors[0].contains("Invalid alias"));
1214 assert!(errors[0].contains("invalid_alias"));
1215 }
1216
1217 #[test]
1218 fn test_invalid_preferred_alias_is_not_normalized_to() {
1219 let content = "```sh\necho one\n```\n\n```bash\necho two\n```\n";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223
1224 let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1225 style: LanguageStyle::Consistent,
1226 preferred_aliases: HashMap::from([("Shell".to_string(), "invalid_alias".to_string())]),
1227 ..Default::default()
1228 });
1229 let fixed = rule.fix(&ctx).unwrap();
1230 assert!(
1231 !fixed.contains("invalid_alias"),
1232 "an invalid alias must not reach the document, got:\n{fixed}"
1233 );
1234 assert!(
1235 rule.check(&ctx)
1236 .unwrap()
1237 .iter()
1238 .any(|w| w.message.contains("Invalid alias")),
1239 "the invalid alias is still reported"
1240 );
1241
1242 let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1244 style: LanguageStyle::Consistent,
1245 preferred_aliases: HashMap::from([("Shell".to_string(), "zsh".to_string())]),
1246 ..Default::default()
1247 });
1248 assert_eq!(
1249 rule.fix(&ctx).unwrap(),
1250 "```zsh\necho one\n```\n\n```zsh\necho two\n```\n"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_unknown_language_in_preferred_aliases_detected() {
1256 let mut preferred = HashMap::new();
1257 preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1258
1259 let config = MD040Config {
1260 style: LanguageStyle::Consistent,
1261 preferred_aliases: preferred,
1262 ..Default::default()
1263 };
1264 let rule = MD040FencedCodeLanguage::with_config(config);
1265 let errors = rule.validate_config();
1266 assert_eq!(errors.len(), 1);
1267 assert!(errors[0].contains("Unknown language"));
1268 }
1269
1270 #[test]
1271 fn test_valid_preferred_alias_accepted() {
1272 let mut preferred = HashMap::new();
1273 preferred.insert("Shell".to_string(), "bash".to_string());
1274 preferred.insert("JavaScript".to_string(), "js".to_string());
1275
1276 let config = MD040Config {
1277 style: LanguageStyle::Consistent,
1278 preferred_aliases: preferred,
1279 ..Default::default()
1280 };
1281 let rule = MD040FencedCodeLanguage::with_config(config);
1282 let errors = rule.validate_config();
1283 assert!(errors.is_empty());
1284 }
1285
1286 #[test]
1287 fn test_config_error_uses_valid_line_column() {
1288 let config = md040_config::MD040Config {
1289 preferred_aliases: {
1290 let mut map = std::collections::HashMap::new();
1291 map.insert("Shell".to_string(), "invalid_alias".to_string());
1292 map
1293 },
1294 ..Default::default()
1295 };
1296 let rule = MD040FencedCodeLanguage::with_config(config);
1297
1298 let content = "```shell\necho hello\n```";
1299 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301
1302 let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1304 assert!(config_error.is_some(), "Should have a config error warning");
1305
1306 let warning = config_error.unwrap();
1307 assert!(
1309 warning.line >= 1,
1310 "Config error line should be >= 1, got {}",
1311 warning.line
1312 );
1313 assert!(
1314 warning.column >= 1,
1315 "Config error column should be >= 1, got {}",
1316 warning.column
1317 );
1318 }
1319
1320 fn custom_languages_config(declared: &[&str]) -> MD040Config {
1325 MD040Config {
1326 unknown_language_action: UnknownLanguageAction::Error,
1327 custom_languages: declared.iter().map(|s| (*s).to_string()).collect(),
1328 ..Default::default()
1329 }
1330 }
1331
1332 #[test]
1333 fn test_declared_custom_language_is_not_unknown() {
1334 let content = "```cddl\nfoo = tstr\n```\n";
1335
1336 let flagged = run_check_with_config(content, custom_languages_config(&[])).unwrap();
1337 assert_eq!(flagged.len(), 1, "an undeclared unknown label must still be reported");
1338 assert!(flagged[0].message.contains("Unknown language 'cddl'"));
1339
1340 let accepted = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1341 assert!(accepted.is_empty(), "a declared label must be accepted: {accepted:?}");
1342 }
1343
1344 #[test]
1345 fn test_custom_language_matches_a_label_case_insensitively() {
1346 let content = "```CDDL\nfoo = tstr\n```\n";
1347 let result = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1348 assert!(result.is_empty(), "label case must not matter: {result:?}");
1349 }
1350
1351 #[test]
1352 fn test_custom_language_does_not_shadow_linguist() {
1353 let content = "```sh\necho hi\n```\n\n```bash\necho there\n```\n\n```bash\necho again\n```\n";
1356 let config = MD040Config {
1357 style: LanguageStyle::Consistent,
1358 custom_languages: vec!["sh".to_string()],
1359 ..Default::default()
1360 };
1361 let result = run_check_with_config(content, config.clone()).unwrap();
1362 assert_eq!(result.len(), 1, "sh must still be judged against Shell: {result:?}");
1363 assert!(result[0].message.contains("use 'bash'"));
1364
1365 let fixed = run_fix_with_config(content, config).unwrap();
1366 assert!(!fixed.contains("```sh\n"));
1367 }
1368
1369 #[test]
1370 fn test_custom_language_normalizes_under_consistent_style() {
1371 let content = "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n";
1372 let config = MD040Config {
1373 style: LanguageStyle::Consistent,
1374 custom_languages: vec!["cddl".to_string()],
1375 ..Default::default()
1376 };
1377 let result = run_check_with_config(content, config.clone()).unwrap();
1378 assert_eq!(result.len(), 1, "the two spellings are one language: {result:?}");
1379
1380 let fixed = run_fix_with_config(content, config).unwrap();
1382 assert!(fixed.contains("```cddl"));
1383 assert!(!fixed.contains("```CDDL"));
1384 }
1385
1386 #[test]
1387 fn test_custom_language_participates_in_allowed_and_disallowed_lists() {
1388 let content = "```cddl\nfoo = tstr\n```\n";
1389
1390 let allowed = run_check_with_config(
1391 content,
1392 MD040Config {
1393 allowed_languages: vec!["cddl".to_string()],
1394 custom_languages: vec!["cddl".to_string()],
1395 ..Default::default()
1396 },
1397 )
1398 .unwrap();
1399 assert!(allowed.is_empty(), "an allowed custom language passes: {allowed:?}");
1400
1401 let disallowed = run_check_with_config(
1402 content,
1403 MD040Config {
1404 disallowed_languages: vec!["cddl".to_string()],
1405 custom_languages: vec!["cddl".to_string()],
1406 ..Default::default()
1407 },
1408 )
1409 .unwrap();
1410 assert_eq!(disallowed.len(), 1, "a disallowed custom language is reported");
1411 assert!(disallowed[0].message.contains("is disallowed"));
1412 }
1413
1414 #[test]
1415 fn test_undeclared_language_is_not_allowed_by_the_allowlist() {
1416 let result = run_check_with_config(
1419 "```cddl\nfoo = tstr\n```\n",
1420 MD040Config {
1421 allowed_languages: vec!["cddl".to_string()],
1422 ..Default::default()
1423 },
1424 )
1425 .unwrap();
1426 assert_eq!(result.len(), 1);
1427 assert!(result[0].message.contains("is not in the allowed list"));
1428 }
1429
1430 #[test]
1431 fn test_unusable_custom_language_entries_are_config_errors() {
1432 let rule =
1433 MD040FencedCodeLanguage::with_config(custom_languages_config(&["c ddl", "cddl ", " cddl", " ", "cddl"]));
1434 let errors = rule.validate_config();
1435 assert_eq!(errors.len(), 4, "only the unusable entries are reported: {errors:?}");
1436 assert_eq!(
1437 errors.iter().filter(|e| e.contains("contains whitespace")).count(),
1438 3,
1439 "whitespace around an entry is as unmatchable as whitespace inside it: {errors:?}"
1440 );
1441 assert!(errors.iter().any(|e| e.contains("Empty entry in custom-languages")));
1442 }
1443
1444 #[test]
1445 fn test_custom_language_with_surrounding_whitespace_does_not_match_a_label() {
1446 let content = "```cddl\nfoo = int\n```";
1447 let result = run_check_with_config(content, custom_languages_config(&["cddl "])).unwrap();
1448 assert_eq!(
1449 result.len(),
1450 2,
1451 "the label stays unknown and the entry is reported: {result:?}"
1452 );
1453 assert!(result[0].message.contains("[config error]"));
1454 assert!(result[0].message.contains("contains whitespace"));
1455 assert!(result[1].message.contains("Unknown language 'cddl'"));
1456 }
1457
1458 #[test]
1459 fn test_preferred_alias_for_a_custom_language() {
1460 let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1461 preferred_aliases: HashMap::from([("CDDL".to_string(), "cddl".to_string())]),
1462 custom_languages: vec!["cddl".to_string()],
1463 ..Default::default()
1464 });
1465 assert!(
1466 accepted.validate_config().is_empty(),
1467 "a spelling of the declared name is a valid preference"
1468 );
1469
1470 let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1471 preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1472 custom_languages: vec!["cddl".to_string()],
1473 ..Default::default()
1474 });
1475 let errors = rejected.validate_config();
1476 assert_eq!(errors.len(), 1, "a custom language has no aliases: {errors:?}");
1477 assert!(errors[0].contains("Invalid alias 'cbor-dl' for custom language 'cddl'"));
1478
1479 let ctx = LintContext::new(
1482 "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n",
1483 crate::config::MarkdownFlavor::Standard,
1484 None,
1485 );
1486 let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1487 style: LanguageStyle::Consistent,
1488 preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1489 custom_languages: vec!["cddl".to_string()],
1490 ..Default::default()
1491 });
1492 assert_eq!(
1493 rejected.fix(&ctx).unwrap(),
1494 "```cddl\nfoo = tstr\n```\n\n```cddl\nbar = int\n```\n"
1495 );
1496
1497 let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1499 style: LanguageStyle::Consistent,
1500 preferred_aliases: HashMap::from([("cddl".to_string(), "CDDL".to_string())]),
1501 custom_languages: vec!["cddl".to_string()],
1502 ..Default::default()
1503 });
1504 assert_eq!(
1505 accepted.fix(&ctx).unwrap(),
1506 "```CDDL\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n"
1507 );
1508 }
1509
1510 #[test]
1515 fn test_linguist_resolution() {
1516 assert_eq!(resolve_canonical("bash"), Some("Shell"));
1517 assert_eq!(resolve_canonical("sh"), Some("Shell"));
1518 assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1519 assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1520 assert_eq!(resolve_canonical("python"), Some("Python"));
1521 assert_eq!(resolve_canonical("unknown_lang"), None);
1522 }
1523
1524 #[test]
1525 fn test_linguist_resolution_case_insensitive() {
1526 assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1527 assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1528 assert_eq!(resolve_canonical("Python"), Some("Python"));
1529 assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1530 }
1531
1532 #[test]
1533 fn test_alias_validation() {
1534 use crate::linguist_data::is_valid_alias;
1535
1536 assert!(is_valid_alias("Shell", "bash"));
1537 assert!(is_valid_alias("Shell", "sh"));
1538 assert!(is_valid_alias("Shell", "zsh"));
1539 assert!(!is_valid_alias("Shell", "python"));
1540 assert!(!is_valid_alias("Shell", "invalid"));
1541 }
1542
1543 #[test]
1544 fn test_default_alias() {
1545 assert_eq!(default_alias("Shell"), Some("bash"));
1546 assert_eq!(default_alias("JavaScript"), Some("js"));
1547 assert_eq!(default_alias("Python"), Some("python"));
1548 }
1549
1550 #[test]
1555 fn test_mixed_case_labels_normalized() {
1556 let content = r#"```BASH
1557echo hi
1558```
1559
1560```Bash
1561echo there
1562```
1563
1564```bash
1565echo again
1566```
1567"#;
1568 let config = MD040Config {
1569 style: LanguageStyle::Consistent,
1570 ..Default::default()
1571 };
1572 let result = run_check_with_config(content, config).unwrap();
1574 assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1578 }
1579
1580 #[test]
1581 fn test_multiple_languages_independent() {
1582 let content = r#"```bash
1583shell code
1584```
1585
1586```python
1587python code
1588```
1589
1590```sh
1591more shell
1592```
1593
1594```python3
1595more python
1596```
1597"#;
1598 let config = MD040Config {
1599 style: LanguageStyle::Consistent,
1600 ..Default::default()
1601 };
1602 let result = run_check_with_config(content, config).unwrap();
1603 assert_eq!(result.len(), 2);
1605 }
1606
1607 #[test]
1608 fn test_tilde_fences() {
1609 let content = r#"~~~bash
1610echo hi
1611~~~
1612
1613~~~sh
1614echo there
1615~~~
1616"#;
1617 let config = MD040Config {
1618 style: LanguageStyle::Consistent,
1619 ..Default::default()
1620 };
1621 let result = run_check_with_config(content, config.clone()).unwrap();
1622 assert_eq!(result.len(), 1);
1623
1624 let fixed = run_fix_with_config(content, config).unwrap();
1625 assert!(fixed.contains("~~~bash"));
1626 assert!(!fixed.contains("~~~sh"));
1627 }
1628
1629 #[test]
1630 fn test_longer_fence_markers_preserved() {
1631 let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1632 let config = MD040Config {
1633 style: LanguageStyle::Consistent,
1634 ..Default::default()
1635 };
1636 let fixed = run_fix_with_config(content, config).unwrap();
1637 assert!(fixed.contains("````bash"));
1638 assert!(fixed.contains("```bash"));
1639 }
1640
1641 #[test]
1642 fn test_empty_document() {
1643 let result = run_check("").unwrap();
1644 assert!(result.is_empty());
1645 }
1646
1647 #[test]
1648 fn test_no_code_blocks() {
1649 let content = "# Just a heading\n\nSome text.";
1650 let result = run_check(content).unwrap();
1651 assert!(result.is_empty());
1652 }
1653
1654 #[test]
1655 fn test_single_code_block_no_inconsistency() {
1656 let content = "```bash\necho hi\n```";
1657 let config = MD040Config {
1658 style: LanguageStyle::Consistent,
1659 ..Default::default()
1660 };
1661 let result = run_check_with_config(content, config).unwrap();
1662 assert!(result.is_empty(), "Single block has no inconsistency");
1663 }
1664
1665 #[test]
1666 fn test_idempotent_fix() {
1667 let content = r#"```bash
1668echo hi
1669```
1670
1671```sh
1672echo there
1673```
1674"#;
1675 let config = MD040Config {
1676 style: LanguageStyle::Consistent,
1677 ..Default::default()
1678 };
1679 let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1680 let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1681 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1682 }
1683
1684 #[test]
1689 fn test_mkdocs_superfences_attribute_in_blockquote() {
1690 let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1693 let result = run_check_mkdocs(content).unwrap();
1694 assert!(
1695 result.is_empty(),
1696 "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_mkdocs_superfences_title_only() {
1702 let content = r#"```title="Example"
1704echo hi
1705```
1706"#;
1707 let result = run_check_mkdocs(content).unwrap();
1708 assert!(
1709 result.is_empty(),
1710 "MkDocs superfences with title= should not require language"
1711 );
1712 }
1713
1714 #[test]
1715 fn test_mkdocs_superfences_hl_lines() {
1716 let content = r#"```hl_lines="1 2"
1718line 1
1719line 2
1720```
1721"#;
1722 let result = run_check_mkdocs(content).unwrap();
1723 assert!(
1724 result.is_empty(),
1725 "MkDocs superfences with hl_lines= should not require language"
1726 );
1727 }
1728
1729 #[test]
1730 fn test_mkdocs_superfences_linenums() {
1731 let content = r#"```linenums="1"
1733line 1
1734line 2
1735```
1736"#;
1737 let result = run_check_mkdocs(content).unwrap();
1738 assert!(
1739 result.is_empty(),
1740 "MkDocs superfences with linenums= should not require language"
1741 );
1742 }
1743
1744 #[test]
1745 fn test_mkdocs_superfences_class() {
1746 let content = r#"```.my-class
1748some text
1749```
1750"#;
1751 let result = run_check_mkdocs(content).unwrap();
1752 assert!(
1753 result.is_empty(),
1754 "MkDocs superfences with .class should not require language"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_mkdocs_superfences_id() {
1760 let content = r#"```#my-id
1762some text
1763```
1764"#;
1765 let result = run_check_mkdocs(content).unwrap();
1766 assert!(
1767 result.is_empty(),
1768 "MkDocs superfences with #id should not require language"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_mkdocs_superfences_with_language() {
1774 let content = r#"```python title="Example" hl_lines="1"
1776print("hello")
1777```
1778"#;
1779 let result = run_check_mkdocs(content).unwrap();
1780 assert!(result.is_empty(), "Code block with language and attrs should pass");
1781 }
1782
1783 #[test]
1784 fn test_standard_flavor_no_special_handling() {
1785 let content = r#"```title="Example"
1787echo hi
1788```
1789"#;
1790 let result = run_check(content).unwrap();
1791 assert_eq!(
1792 result.len(),
1793 1,
1794 "Standard flavor should warn about title= without language"
1795 );
1796 }
1797
1798 #[test]
1799 fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1800 let rule = MD040FencedCodeLanguage::default();
1803 let content = "```{=html}\n<div>raw html</div>\n```\n";
1804 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1805 let result = rule.check(&ctx).unwrap();
1806 assert!(
1807 result.is_empty(),
1808 "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1809 );
1810 }
1811
1812 #[test]
1813 fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1814 let rule = MD040FencedCodeLanguage::default();
1816 let content = "```{=html}\n<div>raw html</div>\n```\n";
1817 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1818 let result = rule.check(&ctx).unwrap();
1819 assert!(
1820 result.is_empty(),
1821 "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1822 );
1823 }
1824
1825 #[test]
1828 fn test_pandoc_accepts_raw_html_block() {
1829 use crate::config::MarkdownFlavor;
1830 let rule = MD040FencedCodeLanguage::default();
1831 let content = "```{=html}\n<div>raw</div>\n```\n";
1832 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1833 let result = rule.check(&ctx).unwrap();
1834 assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1835 }
1836
1837 #[test]
1840 fn test_pandoc_rejects_quarto_exec_blocks() {
1841 use crate::config::MarkdownFlavor;
1842 let rule = MD040FencedCodeLanguage::default();
1843 let content = "```{r}\nsummary(data)\n```\n";
1844 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1845 let result = rule.check(&ctx).unwrap();
1846 assert!(
1847 !result.is_empty(),
1848 "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1849 );
1850 }
1851
1852 #[test]
1854 fn test_quarto_still_accepts_exec_block() {
1855 use crate::config::MarkdownFlavor;
1856 let rule = MD040FencedCodeLanguage::default();
1857 let content = "```{r}\nsummary(data)\n```\n";
1858 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1859 let result = rule.check(&ctx).unwrap();
1860 assert!(
1861 result.is_empty(),
1862 "MD040 under Quarto should accept `{{r}}`: {result:?}"
1863 );
1864 }
1865
1866 #[test]
1867 fn test_quarto_exec_block_skipped_under_quarto_only() {
1868 let rule = MD040FencedCodeLanguage::default();
1872 let content = "```{r}\n1 + 1\n```\n";
1873
1874 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1875 let result_quarto = rule.check(&ctx_quarto).unwrap();
1876 assert!(
1877 result_quarto.is_empty(),
1878 "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1879 );
1880
1881 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1884 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1885 assert!(
1886 !result_pandoc.is_empty(),
1887 "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1888 );
1889 }
1890
1891 #[test]
1894 fn test_pandoc_class_attr_accepted_as_language() {
1895 use crate::config::MarkdownFlavor;
1896 let rule = MD040FencedCodeLanguage::default();
1897 let content = "```{.python}\nprint(\"hi\")\n```\n";
1898
1899 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1900 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1901 assert!(
1902 result_pandoc.is_empty(),
1903 "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1904 );
1905
1906 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1907 let result_quarto = rule.check(&ctx_quarto).unwrap();
1908 assert!(
1909 result_quarto.is_empty(),
1910 "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1911 );
1912 }
1913
1914 #[test]
1917 fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1918 use crate::config::MarkdownFlavor;
1919 let rule = MD040FencedCodeLanguage::default();
1920 let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1921
1922 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1923 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1924 assert!(
1925 result_pandoc.is_empty(),
1926 "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1927 );
1928
1929 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1930 let result_quarto = rule.check(&ctx_quarto).unwrap();
1931 assert!(
1932 result_quarto.is_empty(),
1933 "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1934 );
1935 }
1936
1937 #[test]
1940 fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1941 use crate::config::MarkdownFlavor;
1942 let rule = MD040FencedCodeLanguage::default();
1943 let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1944
1945 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1946 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1947 assert!(
1948 result_pandoc.is_empty(),
1949 "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1950 );
1951 }
1952
1953 #[test]
1956 fn test_standard_still_flags_pandoc_class_attr() {
1957 use crate::config::MarkdownFlavor;
1958 let rule = MD040FencedCodeLanguage::default();
1959 let content = "```{.python}\nprint(\"hi\")\n```\n";
1960
1961 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1962 let result_standard = rule.check(&ctx_standard).unwrap();
1963 assert!(
1964 !result_standard.is_empty(),
1965 "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1966 );
1967 }
1968
1969 #[test]
1972 fn test_pandoc_id_only_attr_still_flagged() {
1973 use crate::config::MarkdownFlavor;
1974 let rule = MD040FencedCodeLanguage::default();
1975 let content = "```{#myid}\ncode here\n```\n";
1976
1977 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1978 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1979 assert!(
1980 !result_pandoc.is_empty(),
1981 "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1982 );
1983 }
1984
1985 #[test]
1987 fn test_pandoc_empty_braces_still_flagged() {
1988 use crate::config::MarkdownFlavor;
1989 let rule = MD040FencedCodeLanguage::default();
1990 let content = "```{}\ncode here\n```\n";
1991
1992 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1993 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1994 assert!(
1995 !result_pandoc.is_empty(),
1996 "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1997 );
1998 }
1999
2000 #[test]
2001 fn test_mdg_reports_doc_string_media_type_without_fixing_it() {
2002 use crate::config::MarkdownFlavor;
2003
2004 let rule = MD040FencedCodeLanguage::default();
2008
2009 for content in [
2010 "* Given the following message:\n\n ```\n hello\n ```\n",
2011 "* Given the following message:\n\n ~~~\n hello\n ~~~\n",
2012 ] {
2013 let mdg_ctx = LintContext::new(content, MarkdownFlavor::MDG, None);
2014 let standard_ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2015
2016 let warnings = rule.check(&mdg_ctx).unwrap();
2017 assert_eq!(warnings.len(), 1, "MDG must still flag {content:?}");
2018 assert!(warnings[0].message.contains("missing language"));
2019 assert!(warnings[0].fix.is_none(), "MDG must not offer a media-type fix");
2020 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must preserve {content:?}");
2021
2022 let standard_warnings = rule.check(&standard_ctx).unwrap();
2023 assert_eq!(standard_warnings.len(), 1);
2024 assert!(standard_warnings[0].fix.is_some(), "Standard keeps the existing fix");
2025 assert_eq!(
2026 rule.fix(&standard_ctx).unwrap(),
2027 content
2028 .replacen("```\n", "```text\n", 1)
2029 .replacen("~~~\n", "~~~text\n", 1),
2030 "Standard still adds its default language label"
2031 );
2032 }
2033 }
2034
2035 #[test]
2036 fn test_mdg_reports_inconsistent_media_type_without_normalizing_it() {
2037 use crate::config::MarkdownFlavor;
2038
2039 let config = MD040Config {
2040 style: LanguageStyle::Consistent,
2041 preferred_aliases: HashMap::from([("JavaScript".to_string(), "javascript".to_string())]),
2042 ..MD040Config::default()
2043 };
2044 let rule = MD040FencedCodeLanguage::with_config(config);
2045 let content = "* Given this script:\n\n ```js\n alert('ok')\n ```\n";
2046
2047 let mdg_ctx = LintContext::new(content, MarkdownFlavor::MDG, None);
2048 let mdg = rule.check(&mdg_ctx).unwrap();
2049 assert_eq!(mdg.len(), 1);
2050 assert!(mdg[0].message.contains("Inconsistent language label"));
2051 assert!(mdg[0].fix.is_none());
2052 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
2053
2054 let standard_ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2055 let standard = rule.check(&standard_ctx).unwrap();
2056 assert_eq!(standard.len(), 1);
2057 assert!(standard[0].fix.is_some());
2058 assert!(rule.fix(&standard_ctx).unwrap().contains("```javascript"));
2059 }
2060}