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