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