use crate::linguist_data::{default_alias, resolve_canonical};
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::load_rule_config;
use crate::utils::range_utils::calculate_line_range;
use std::collections::HashMap;
pub mod md040_config;
const MKDOCS_SUPERFENCES_ATTR_PREFIXES: &[&str] = &[
"title=", "hl_lines=", "linenums=", ".", "#", ];
#[inline]
fn is_superfences_attribute(s: &str) -> bool {
MKDOCS_SUPERFENCES_ATTR_PREFIXES
.iter()
.any(|prefix| s.starts_with(prefix))
}
use md040_config::{LanguageStyle, MD040Config, UnknownLanguageAction};
struct FencedCodeBlock {
line_idx: usize,
language: String,
fence_marker: String,
}
#[derive(Debug, Clone, Default)]
pub struct MD040FencedCodeLanguage {
config: MD040Config,
}
impl MD040FencedCodeLanguage {
pub fn with_config(config: MD040Config) -> Self {
Self { config }
}
fn resolve_language(&self, label: &str) -> Option<&str> {
resolve_canonical(label).or_else(|| self.config.custom_language(label))
}
fn validate_config(&self) -> Vec<String> {
let mut errors = Vec::new();
for declared in &self.config.custom_languages {
if declared.trim().is_empty() {
errors.push("Empty entry in custom-languages.".to_string());
} else if declared.chars().any(char::is_whitespace) {
errors.push(format!(
"Custom language '{declared}' contains whitespace, so no fence label can match it."
));
}
}
errors.extend(
self.config
.preferred_aliases
.iter()
.filter_map(|(language, alias)| self.config.preferred_alias_problem(language, alias)),
);
errors
}
fn is_disabled_at(&self, ctx: &crate::lint_context::LintContext, block: &FencedCodeBlock) -> bool {
ctx.is_rule_disabled(self.name(), block.line_idx + 1)
}
fn compute_preferred_labels(
&self,
ctx: &crate::lint_context::LintContext,
blocks: &[FencedCodeBlock],
) -> HashMap<String, String> {
let mut by_canonical: HashMap<String, Vec<&str>> = HashMap::new();
for block in blocks {
if self.is_disabled_at(ctx, block) {
continue;
}
if block.language.is_empty() {
continue;
}
if let Some(canonical) = self.resolve_language(&block.language) {
by_canonical
.entry(canonical.to_string())
.or_default()
.push(&block.language);
}
}
let mut result = HashMap::new();
for (canonical, labels) in by_canonical {
let winner = if let Some(preferred) = self.config.preferred_label(&canonical) {
preferred.to_string()
} else {
let mut counts: HashMap<&str, usize> = HashMap::new();
for label in &labels {
*counts.entry(*label).or_default() += 1;
}
let max_count = counts.values().max().copied().unwrap_or(0);
let winners: Vec<_> = counts
.iter()
.filter(|(_, c)| **c == max_count)
.map(|(l, _)| *l)
.collect();
if winners.len() == 1 {
winners[0].to_string()
} else {
default_alias(&canonical)
.or_else(|| self.config.custom_language(&canonical))
.filter(|default| winners.contains(default))
.map_or_else(
|| winners.into_iter().min().unwrap().to_string(),
std::string::ToString::to_string,
)
}
};
result.insert(canonical, winner);
}
result
}
fn check_language_allowed(&self, canonical: Option<&str>, original_label: &str) -> Option<String> {
if !self.config.allowed_languages.is_empty() {
let allowed = self.config.allowed_languages.join(", ");
let Some(canonical) = canonical else {
return Some(format!(
"Language '{original_label}' is not in the allowed list: {allowed}"
));
};
if !self
.config
.allowed_languages
.iter()
.any(|a| a.eq_ignore_ascii_case(canonical))
{
return Some(format!(
"Language '{original_label}' ({canonical}) is not in the allowed list: {allowed}"
));
}
} else if !self.config.disallowed_languages.is_empty()
&& canonical.is_some_and(|canonical| {
self.config
.disallowed_languages
.iter()
.any(|d| d.eq_ignore_ascii_case(canonical))
})
{
let canonical = canonical.unwrap_or("unknown");
return Some(format!("Language '{original_label}' ({canonical}) is disallowed"));
}
None
}
fn check_unknown_language(&self, label: &str) -> Option<(String, Severity)> {
if crate::linguist_data::is_known_language(label) || self.config.custom_language(label).is_some() {
return None;
}
match self.config.unknown_language_action {
UnknownLanguageAction::Ignore => None,
UnknownLanguageAction::Warn => Some((
format!(
"Unknown language '{label}' (not in GitHub Linguist). Syntax highlighting may not work. Add it to custom-languages to accept it."
),
Severity::Warning,
)),
UnknownLanguageAction::Error => Some((
format!(
"Unknown language '{label}' (not in GitHub Linguist). Add it to custom-languages to accept it."
),
Severity::Error,
)),
}
}
}
impl Rule for MD040FencedCodeLanguage {
fn name(&self) -> &'static str {
"MD040"
}
fn description(&self) -> &'static str {
"Code blocks should have a language specified"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
for error in self.validate_config() {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: format!("[config error] {error}"),
severity: Severity::Error,
fix: None,
});
}
let fenced_blocks = derive_fenced_code_blocks(ctx);
let preferred_labels = if self.config.style == LanguageStyle::Consistent {
self.compute_preferred_labels(ctx, &fenced_blocks)
} else {
HashMap::new()
};
let lines = ctx.raw_lines();
for block in &fenced_blocks {
if self.is_disabled_at(ctx, block) {
continue;
}
let line = lines.get(block.line_idx).unwrap_or(&"");
let fence_line = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
let after_fence = fence_line.strip_prefix(&block.fence_marker).unwrap_or("").trim();
let has_mkdocs_attrs_only =
ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
let is_myst_directive =
ctx.flavor.supports_myst_directives() && after_fence.starts_with('{') && after_fence.contains('}') && {
let name = after_fence.trim_start_matches('{').split('}').next().unwrap_or("");
!name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
};
let is_pandoc_raw =
ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(after_fence);
let is_pandoc_class_attr =
ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(after_fence);
let is_quarto_exec = ctx.flavor == crate::config::MarkdownFlavor::Quarto
&& after_fence.starts_with('{')
&& after_fence.ends_with('}')
&& !is_pandoc_raw
&& !is_pandoc_class_attr;
let has_pandoc_or_quarto_syntax = is_pandoc_raw || is_pandoc_class_attr || is_quarto_exec;
let is_unrecognized_brace_syntax = after_fence.starts_with('{')
&& after_fence.ends_with('}')
&& !has_pandoc_or_quarto_syntax
&& !is_myst_directive;
let needs_language = !has_mkdocs_attrs_only
&& !is_myst_directive
&& (block.language.is_empty()
|| is_superfences_attribute(&block.language)
|| is_unrecognized_brace_syntax);
if needs_language && !has_pandoc_or_quarto_syntax {
let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
let fix = fence_marker_offset(line, &block.fence_marker).map(|marker_offset| {
let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
let line_end_byte = line_start_byte + line.len();
let after_fence_trimmed = line[marker_offset + block.fence_marker.len()..].trim();
let replacement = if after_fence_trimmed.is_empty() {
"text".to_string()
} else {
format!("text {after_fence_trimmed}")
};
Fix::new(fence_end_byte..line_end_byte, replacement)
});
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: "Code block (```) missing language".to_string(),
severity: Severity::Warning,
fix,
});
continue;
}
if has_pandoc_or_quarto_syntax {
continue;
}
let canonical = self.resolve_language(&block.language);
if let Some(msg) = self.check_language_allowed(canonical, &block.language) {
let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: msg,
severity: Severity::Warning,
fix: None,
});
continue;
}
if canonical.is_none() {
if let Some((msg, severity)) = self.check_unknown_language(&block.language) {
let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: msg,
severity,
fix: None,
});
}
continue;
}
if self.config.style == LanguageStyle::Consistent
&& let Some(preferred) = preferred_labels.get(canonical.unwrap())
&& &block.language != preferred
{
let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
let fix = find_label_span(line, &block.fence_marker).map(|(label_start, label_end)| {
let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
Fix::new(
(line_start_byte + label_start)..(line_start_byte + label_end),
preferred.clone(),
)
});
let lang = &block.language;
let canonical = canonical.unwrap();
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!("Inconsistent language label '{lang}' for {canonical} (use '{preferred}')"),
severity: Severity::Warning,
fix,
});
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
}
fn category(&self) -> RuleCategory {
RuleCategory::CodeBlock
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_sections!(MD040Config);
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config: MD040Config = load_rule_config(config);
Box::new(MD040FencedCodeLanguage::with_config(rule_config))
}
}
fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
let content = ctx.content;
let line_offsets = &ctx.line_offsets;
ctx.code_block_details
.iter()
.filter(|d| d.is_fenced)
.map(|detail| {
let line_idx = match line_offsets.binary_search(&detail.start) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
};
let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
let line = content.get(line_start..line_end).unwrap_or("");
let fence_marker =
find_fence_marker(line).map_or_else(|| "```".to_string(), |(_, marker)| marker.to_string());
let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
FencedCodeBlock {
line_idx,
language,
fence_marker,
}
})
.collect()
}
fn find_fence_marker(line: &str) -> Option<(usize, &str)> {
let bytes = line.as_bytes();
let start = bytes.iter().position(|&b| b == b'`' || b == b'~')?;
let fence_char = bytes[start];
let len = bytes[start..].iter().take_while(|&&b| b == fence_char).count();
Some((start, &line[start..start + len]))
}
fn fence_marker_offset(line: &str, fence_marker: &str) -> Option<usize> {
let (start, marker) = find_fence_marker(line)?;
(marker == fence_marker).then_some(start)
}
fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
let marker_offset = fence_marker_offset(line, fence_marker)?;
let after_fence = &line[marker_offset + fence_marker.len()..];
let label_start_rel = after_fence
.char_indices()
.find(|&(_, ch)| !ch.is_whitespace())
.map(|(idx, _)| idx)?;
let after_label = &after_fence[label_start_rel..];
let label_end_rel = after_label
.char_indices()
.find(|&(_, ch)| ch.is_whitespace())
.map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
Some((
marker_offset + fence_marker.len() + label_start_rel,
marker_offset + fence_marker.len() + label_end_rel,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
fn run_check(content: &str) -> LintResult {
let rule = MD040FencedCodeLanguage::default();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.check(&ctx)
}
fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
let rule = MD040FencedCodeLanguage::with_config(config);
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.check(&ctx)
}
fn run_fix(content: &str) -> Result<String, LintError> {
let rule = MD040FencedCodeLanguage::default();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.fix(&ctx)
}
fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
let rule = MD040FencedCodeLanguage::with_config(config);
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.fix(&ctx)
}
fn run_check_mkdocs(content: &str) -> LintResult {
let rule = MD040FencedCodeLanguage::default();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
rule.check(&ctx)
}
#[test]
fn test_code_blocks_with_language_specified() {
let content = r#"# Test
```python
print("Hello, world!")
```
```javascript
console.log("Hello!");
```
"#;
let result = run_check(content).unwrap();
assert!(result.is_empty(), "No warnings expected for code blocks with language");
}
#[test]
fn test_code_blocks_without_language() {
let content = r#"# Test
```
print("Hello, world!")
```
"#;
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].message, "Code block (```) missing language");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_fix_method_adds_text_language() {
let content = r#"# Test
```
code without language
```
```python
already has language
```
```
another block without
```
"#;
let fixed = run_fix(content).unwrap();
assert!(fixed.contains("```text"));
assert!(fixed.contains("```python"));
assert_eq!(fixed.matches("```text").count(), 2);
}
#[test]
fn test_fix_preserves_indentation() {
let content = r#"# Test
- List item
```
indented code block
```
"#;
let fixed = run_fix(content).unwrap();
assert!(fixed.contains(" ```text"));
}
#[test]
fn test_fix_blockquote_empty_fence() {
let content = "# Title\n\n> ```\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
let fixed = run_fix(content).unwrap();
let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_blockquote_tilde_and_longer_fences() {
let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
}
#[test]
fn test_fix_nested_blockquote_empty_fence() {
let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
}
#[test]
fn test_fix_list_marker_empty_fence() {
let content = "# Title\n\n- ```\n root/\n └── nested/\n └── file.txt\n ```\n";
let fixed = run_fix(content).unwrap();
let expected = "# Title\n\n- ```text\n root/\n └── nested/\n └── file.txt\n ```\n";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_list_marker_fence_across_marker_styles() {
for (input, expected) in [
("- ```\n code\n ```\n", "- ```text\n code\n ```\n"),
("* ```\n code\n ```\n", "* ```text\n code\n ```\n"),
("+ ```\n code\n ```\n", "+ ```text\n code\n ```\n"),
("1. ```\n code\n ```\n", "1. ```text\n code\n ```\n"),
("1) ```\n code\n ```\n", "1) ```text\n code\n ```\n"),
(" - ```\n code\n ```\n", " - ```text\n code\n ```\n"),
("- - ```\n code\n ```\n", "- - ```text\n code\n ```\n"),
("> - ```\n> code\n> ```\n", "> - ```text\n> code\n> ```\n"),
] {
assert_eq!(run_fix(input).unwrap(), expected, "input: {input:?}");
}
}
#[test]
fn test_fix_list_marker_tilde_and_longer_fences() {
let tilde = run_fix("- ~~~\n code\n ~~~\n").unwrap();
assert_eq!(tilde, "- ~~~text\n code\n ~~~\n");
let longer_tilde = run_fix("- ~~~~\n code\n ~~~~\n").unwrap();
assert_eq!(longer_tilde, "- ~~~~text\n code\n ~~~~\n");
let longer_backtick = run_fix("- ````\n code\n ````\n").unwrap();
assert_eq!(longer_backtick, "- ````text\n code\n ````\n");
}
#[test]
fn test_fix_list_marker_fence_is_idempotent() {
let content = "- ```\n root/\n nested\n ```\n";
let once = run_fix(content).unwrap();
let twice = run_fix(&once).unwrap();
assert_eq!(once, twice);
assert_eq!(once, "- ```text\n root/\n nested\n ```\n");
}
#[test]
fn test_fix_list_marker_fence_with_language_untouched() {
let content = "- ```rust\n code\n ```\n";
assert!(run_check(content).unwrap().is_empty());
assert_eq!(run_fix(content).unwrap(), content);
}
#[test]
fn test_fix_blockquote_empty_fence_is_idempotent() {
let content = "> ```\n> root/\n> nested\n> ```\n";
let once = run_fix(content).unwrap();
let twice = run_fix(&once).unwrap();
assert_eq!(once, twice);
assert_eq!(once, "> ```text\n> root/\n> nested\n> ```\n");
}
#[test]
fn test_consistent_mode_detects_inconsistency() {
let content = r#"```bash
echo hi
```
```sh
echo there
```
```bash
echo again
```
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Inconsistent"));
assert!(result[0].message.contains("sh"));
assert!(result[0].message.contains("bash"));
}
#[test]
fn test_consistent_mode_fix_normalizes() {
let content = r#"```bash
echo hi
```
```sh
echo there
```
```bash
echo again
```
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert_eq!(fixed.matches("```bash").count(), 3);
assert_eq!(fixed.matches("```sh").count(), 0);
}
#[test]
fn test_consistent_mode_tie_break_uses_curated_default() {
let content = r#"```bash
echo hi
```
```sh
echo there
```
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert_eq!(fixed.matches("```bash").count(), 2);
}
#[test]
fn test_consistent_mode_with_preferred_alias() {
let content = r#"```bash
echo hi
```
```sh
echo there
```
"#;
let mut preferred = HashMap::new();
preferred.insert("Shell".to_string(), "sh".to_string());
let config = MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: preferred,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert_eq!(fixed.matches("```sh").count(), 2);
assert_eq!(fixed.matches("```bash").count(), 0);
}
#[test]
fn test_consistent_mode_fix_inside_blockquote() {
let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert_eq!(
fixed,
"> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
);
}
#[test]
fn test_consistent_mode_ignores_disabled_blocks() {
let content = r#"```bash
echo hi
```
<!-- rumdl-disable MD040 -->
```sh
echo there
```
```sh
echo again
```
<!-- rumdl-enable MD040 -->
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.is_empty(), "Disabled blocks should not affect consistency");
}
#[test]
fn test_disable_comment_naming_the_rule_by_alias_disables_it() {
let content = r#"```bash
echo hi
```
<!-- rumdl-disable fenced-code-language -->
```sh
echo there
```
```sh
echo again
```
<!-- rumdl-enable fenced-code-language -->
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config.clone()).unwrap();
assert!(
result.is_empty(),
"an alias names the same rule as the ID does: {result:?}"
);
let names_another_rule = content.replace("fenced-code-language", "line-length");
let result = run_check_with_config(&names_another_rule, config).unwrap();
assert_eq!(
result.len(),
1,
"a directive naming another rule leaves the sh blocks voting: {result:?}"
);
}
#[test]
fn test_a_line_scoped_directive_takes_the_fence_out_of_the_vote() {
let directive = "<!-- rumdl-disable-next-line MD040 -->\n";
let content =
format!("```bash\necho one\n```\n\n{directive}```sh\necho two\n```\n\n{directive}```sh\necho three\n```\n");
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let without_directives = content.replace(directive, "");
assert_eq!(
run_check_with_config(&without_directives, config.clone())
.unwrap()
.len(),
1,
"control: two sh fences outvote the bash one"
);
let result = run_check_with_config(&content, config).unwrap();
assert!(result.is_empty(), "a disabled fence casts no vote: {result:?}");
}
#[test]
fn test_a_directive_shown_inside_a_code_block_disables_nothing() {
let sample = "```text\n<!-- rumdl-disable RULE -->\n```\n";
let blocks = "\n```bash\necho one\n```\n\n```sh\necho two\n```\n\n```sh\necho three\n```\n";
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let without_sample = run_check_with_config(blocks, config.clone()).unwrap();
assert_eq!(without_sample.len(), 1, "control: the bash fence is reported");
for name in ["MD040", "fenced-code-language"] {
let content = format!("{}{blocks}", sample.replace("RULE", name));
let result = run_check_with_config(&content, config.clone()).unwrap();
assert_eq!(
result.len(),
1,
"`{name}` inside a code block is sample text, not a directive: {result:?}"
);
}
}
#[test]
fn test_fix_preserves_attributes() {
let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert!(fixed.contains("```bash {.highlight}"));
}
#[test]
fn test_fix_preserves_spacing_before_label() {
let content = "```bash\ncode\n```\n\n``` sh {.highlight}\ncode\n```";
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert!(fixed.contains("``` bash {.highlight}"));
assert!(!fixed.contains("``` sh {.highlight}"));
}
#[test]
fn test_allowlist_blocks_unlisted() {
let content = "```java\ncode\n```";
let config = MD040Config {
allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("not in the allowed list"));
}
#[test]
fn test_allowlist_allows_listed() {
let content = "```python\ncode\n```";
let config = MD040Config {
allowed_languages: vec!["Python".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_allowlist_blocks_unknown_language() {
let content = "```mysterylang\ncode\n```";
let config = MD040Config {
allowed_languages: vec!["Python".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("allowed list"));
}
#[test]
fn test_allowlist_case_insensitive() {
let content = "```python\ncode\n```";
let config = MD040Config {
allowed_languages: vec!["PYTHON".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_denylist_blocks_listed() {
let content = "```java\ncode\n```";
let config = MD040Config {
disallowed_languages: vec!["Java".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("disallowed"));
}
#[test]
fn test_denylist_allows_unlisted() {
let content = "```python\ncode\n```";
let config = MD040Config {
disallowed_languages: vec!["Java".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_allowlist_takes_precedence_over_denylist() {
let content = "```python\ncode\n```";
let config = MD040Config {
allowed_languages: vec!["Python".to_string()],
disallowed_languages: vec!["Python".to_string()], ..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_unknown_language_ignore_default() {
let content = "```mycustomlang\ncode\n```";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Unknown languages ignored by default");
}
#[test]
fn test_unknown_language_warn() {
let content = "```mycustomlang\ncode\n```";
let config = MD040Config {
unknown_language_action: UnknownLanguageAction::Warn,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Unknown language"));
assert!(result[0].message.contains("mycustomlang"));
assert_eq!(result[0].severity, Severity::Warning);
}
#[test]
fn test_unknown_language_warn_known_aliases_not_flagged() {
let known_aliases = [
"py",
"python",
"sh",
"bash",
"shell",
"zsh",
"js",
"javascript",
"ts",
"typescript",
"rb",
"ruby",
"rs",
"rust",
"yml",
"yaml",
"cpp",
"c++",
"csharp",
"golang",
"dockerfile",
"jsonc",
"kotlin",
];
for alias in known_aliases {
let content = format!("```{alias}\ncode\n```");
let config = MD040Config {
unknown_language_action: UnknownLanguageAction::Warn,
..Default::default()
};
let result = run_check_with_config(&content, config).unwrap();
assert!(
result.is_empty(),
"known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
);
}
}
#[test]
fn test_unknown_language_extension_labels_not_flagged() {
for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
let content = format!("```{label}\ncode\n```");
let config = MD040Config {
unknown_language_action: UnknownLanguageAction::Error,
..Default::default()
};
let result = run_check_with_config(&content, config).unwrap();
assert!(
result.is_empty(),
"Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
);
}
}
#[test]
fn test_unknown_language_error() {
let content = "```mycustomlang\ncode\n```";
let config = MD040Config {
unknown_language_action: UnknownLanguageAction::Error,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Unknown language"));
assert_eq!(result[0].severity, Severity::Error);
}
#[test]
fn test_invalid_preferred_alias_detected() {
let mut preferred = HashMap::new();
preferred.insert("Shell".to_string(), "invalid_alias".to_string());
let config = MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: preferred,
..Default::default()
};
let rule = MD040FencedCodeLanguage::with_config(config);
let errors = rule.validate_config();
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Invalid alias"));
assert!(errors[0].contains("invalid_alias"));
}
#[test]
fn test_invalid_preferred_alias_is_not_normalized_to() {
let content = "```sh\necho one\n```\n\n```bash\necho two\n```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD040FencedCodeLanguage::with_config(MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: HashMap::from([("Shell".to_string(), "invalid_alias".to_string())]),
..Default::default()
});
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("invalid_alias"),
"an invalid alias must not reach the document, got:\n{fixed}"
);
assert!(
rule.check(&ctx)
.unwrap()
.iter()
.any(|w| w.message.contains("Invalid alias")),
"the invalid alias is still reported"
);
let rule = MD040FencedCodeLanguage::with_config(MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: HashMap::from([("Shell".to_string(), "zsh".to_string())]),
..Default::default()
});
assert_eq!(
rule.fix(&ctx).unwrap(),
"```zsh\necho one\n```\n\n```zsh\necho two\n```\n"
);
}
#[test]
fn test_unknown_language_in_preferred_aliases_detected() {
let mut preferred = HashMap::new();
preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
let config = MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: preferred,
..Default::default()
};
let rule = MD040FencedCodeLanguage::with_config(config);
let errors = rule.validate_config();
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Unknown language"));
}
#[test]
fn test_valid_preferred_alias_accepted() {
let mut preferred = HashMap::new();
preferred.insert("Shell".to_string(), "bash".to_string());
preferred.insert("JavaScript".to_string(), "js".to_string());
let config = MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: preferred,
..Default::default()
};
let rule = MD040FencedCodeLanguage::with_config(config);
let errors = rule.validate_config();
assert!(errors.is_empty());
}
#[test]
fn test_config_error_uses_valid_line_column() {
let config = md040_config::MD040Config {
preferred_aliases: {
let mut map = std::collections::HashMap::new();
map.insert("Shell".to_string(), "invalid_alias".to_string());
map
},
..Default::default()
};
let rule = MD040FencedCodeLanguage::with_config(config);
let content = "```shell\necho hello\n```";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let config_error = result.iter().find(|w| w.message.contains("[config error]"));
assert!(config_error.is_some(), "Should have a config error warning");
let warning = config_error.unwrap();
assert!(
warning.line >= 1,
"Config error line should be >= 1, got {}",
warning.line
);
assert!(
warning.column >= 1,
"Config error column should be >= 1, got {}",
warning.column
);
}
fn custom_languages_config(declared: &[&str]) -> MD040Config {
MD040Config {
unknown_language_action: UnknownLanguageAction::Error,
custom_languages: declared.iter().map(|s| (*s).to_string()).collect(),
..Default::default()
}
}
#[test]
fn test_declared_custom_language_is_not_unknown() {
let content = "```cddl\nfoo = tstr\n```\n";
let flagged = run_check_with_config(content, custom_languages_config(&[])).unwrap();
assert_eq!(flagged.len(), 1, "an undeclared unknown label must still be reported");
assert!(flagged[0].message.contains("Unknown language 'cddl'"));
let accepted = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
assert!(accepted.is_empty(), "a declared label must be accepted: {accepted:?}");
}
#[test]
fn test_custom_language_matches_a_label_case_insensitively() {
let content = "```CDDL\nfoo = tstr\n```\n";
let result = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
assert!(result.is_empty(), "label case must not matter: {result:?}");
}
#[test]
fn test_custom_language_does_not_shadow_linguist() {
let content = "```sh\necho hi\n```\n\n```bash\necho there\n```\n\n```bash\necho again\n```\n";
let config = MD040Config {
style: LanguageStyle::Consistent,
custom_languages: vec!["sh".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config.clone()).unwrap();
assert_eq!(result.len(), 1, "sh must still be judged against Shell: {result:?}");
assert!(result[0].message.contains("use 'bash'"));
let fixed = run_fix_with_config(content, config).unwrap();
assert!(!fixed.contains("```sh\n"));
}
#[test]
fn test_custom_language_normalizes_under_consistent_style() {
let content = "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n";
let config = MD040Config {
style: LanguageStyle::Consistent,
custom_languages: vec!["cddl".to_string()],
..Default::default()
};
let result = run_check_with_config(content, config.clone()).unwrap();
assert_eq!(result.len(), 1, "the two spellings are one language: {result:?}");
let fixed = run_fix_with_config(content, config).unwrap();
assert!(fixed.contains("```cddl"));
assert!(!fixed.contains("```CDDL"));
}
#[test]
fn test_custom_language_participates_in_allowed_and_disallowed_lists() {
let content = "```cddl\nfoo = tstr\n```\n";
let allowed = run_check_with_config(
content,
MD040Config {
allowed_languages: vec!["cddl".to_string()],
custom_languages: vec!["cddl".to_string()],
..Default::default()
},
)
.unwrap();
assert!(allowed.is_empty(), "an allowed custom language passes: {allowed:?}");
let disallowed = run_check_with_config(
content,
MD040Config {
disallowed_languages: vec!["cddl".to_string()],
custom_languages: vec!["cddl".to_string()],
..Default::default()
},
)
.unwrap();
assert_eq!(disallowed.len(), 1, "a disallowed custom language is reported");
assert!(disallowed[0].message.contains("is disallowed"));
}
#[test]
fn test_undeclared_language_is_not_allowed_by_the_allowlist() {
let result = run_check_with_config(
"```cddl\nfoo = tstr\n```\n",
MD040Config {
allowed_languages: vec!["cddl".to_string()],
..Default::default()
},
)
.unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("is not in the allowed list"));
}
#[test]
fn test_unusable_custom_language_entries_are_config_errors() {
let rule =
MD040FencedCodeLanguage::with_config(custom_languages_config(&["c ddl", "cddl ", " cddl", " ", "cddl"]));
let errors = rule.validate_config();
assert_eq!(errors.len(), 4, "only the unusable entries are reported: {errors:?}");
assert_eq!(
errors.iter().filter(|e| e.contains("contains whitespace")).count(),
3,
"whitespace around an entry is as unmatchable as whitespace inside it: {errors:?}"
);
assert!(errors.iter().any(|e| e.contains("Empty entry in custom-languages")));
}
#[test]
fn test_custom_language_with_surrounding_whitespace_does_not_match_a_label() {
let content = "```cddl\nfoo = int\n```";
let result = run_check_with_config(content, custom_languages_config(&["cddl "])).unwrap();
assert_eq!(
result.len(),
2,
"the label stays unknown and the entry is reported: {result:?}"
);
assert!(result[0].message.contains("[config error]"));
assert!(result[0].message.contains("contains whitespace"));
assert!(result[1].message.contains("Unknown language 'cddl'"));
}
#[test]
fn test_preferred_alias_for_a_custom_language() {
let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
preferred_aliases: HashMap::from([("CDDL".to_string(), "cddl".to_string())]),
custom_languages: vec!["cddl".to_string()],
..Default::default()
});
assert!(
accepted.validate_config().is_empty(),
"a spelling of the declared name is a valid preference"
);
let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
custom_languages: vec!["cddl".to_string()],
..Default::default()
});
let errors = rejected.validate_config();
assert_eq!(errors.len(), 1, "a custom language has no aliases: {errors:?}");
assert!(errors[0].contains("Invalid alias 'cbor-dl' for custom language 'cddl'"));
let ctx = LintContext::new(
"```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n",
crate::config::MarkdownFlavor::Standard,
None,
);
let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
custom_languages: vec!["cddl".to_string()],
..Default::default()
});
assert_eq!(
rejected.fix(&ctx).unwrap(),
"```cddl\nfoo = tstr\n```\n\n```cddl\nbar = int\n```\n"
);
let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
style: LanguageStyle::Consistent,
preferred_aliases: HashMap::from([("cddl".to_string(), "CDDL".to_string())]),
custom_languages: vec!["cddl".to_string()],
..Default::default()
});
assert_eq!(
accepted.fix(&ctx).unwrap(),
"```CDDL\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n"
);
}
#[test]
fn test_linguist_resolution() {
assert_eq!(resolve_canonical("bash"), Some("Shell"));
assert_eq!(resolve_canonical("sh"), Some("Shell"));
assert_eq!(resolve_canonical("zsh"), Some("Shell"));
assert_eq!(resolve_canonical("js"), Some("JavaScript"));
assert_eq!(resolve_canonical("python"), Some("Python"));
assert_eq!(resolve_canonical("unknown_lang"), None);
}
#[test]
fn test_linguist_resolution_case_insensitive() {
assert_eq!(resolve_canonical("BASH"), Some("Shell"));
assert_eq!(resolve_canonical("Bash"), Some("Shell"));
assert_eq!(resolve_canonical("Python"), Some("Python"));
assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
}
#[test]
fn test_alias_validation() {
use crate::linguist_data::is_valid_alias;
assert!(is_valid_alias("Shell", "bash"));
assert!(is_valid_alias("Shell", "sh"));
assert!(is_valid_alias("Shell", "zsh"));
assert!(!is_valid_alias("Shell", "python"));
assert!(!is_valid_alias("Shell", "invalid"));
}
#[test]
fn test_default_alias() {
assert_eq!(default_alias("Shell"), Some("bash"));
assert_eq!(default_alias("JavaScript"), Some("js"));
assert_eq!(default_alias("Python"), Some("python"));
}
#[test]
fn test_mixed_case_labels_normalized() {
let content = r#"```BASH
echo hi
```
```Bash
echo there
```
```bash
echo again
```
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
}
#[test]
fn test_multiple_languages_independent() {
let content = r#"```bash
shell code
```
```python
python code
```
```sh
more shell
```
```python3
more python
```
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_tilde_fences() {
let content = r#"~~~bash
echo hi
~~~
~~~sh
echo there
~~~
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config.clone()).unwrap();
assert_eq!(result.len(), 1);
let fixed = run_fix_with_config(content, config).unwrap();
assert!(fixed.contains("~~~bash"));
assert!(!fixed.contains("~~~sh"));
}
#[test]
fn test_longer_fence_markers_preserved() {
let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed = run_fix_with_config(content, config).unwrap();
assert!(fixed.contains("````bash"));
assert!(fixed.contains("```bash"));
}
#[test]
fn test_empty_document() {
let result = run_check("").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_no_code_blocks() {
let content = "# Just a heading\n\nSome text.";
let result = run_check(content).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_single_code_block_no_inconsistency() {
let content = "```bash\necho hi\n```";
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let result = run_check_with_config(content, config).unwrap();
assert!(result.is_empty(), "Single block has no inconsistency");
}
#[test]
fn test_idempotent_fix() {
let content = r#"```bash
echo hi
```
```sh
echo there
```
"#;
let config = MD040Config {
style: LanguageStyle::Consistent,
..Default::default()
};
let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
assert_eq!(fixed1, fixed2, "Fix should be idempotent");
}
#[test]
fn test_mkdocs_superfences_attribute_in_blockquote() {
let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
let result = run_check_mkdocs(content).unwrap();
assert!(
result.is_empty(),
"MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
);
}
#[test]
fn test_mkdocs_superfences_title_only() {
let content = r#"```title="Example"
echo hi
```
"#;
let result = run_check_mkdocs(content).unwrap();
assert!(
result.is_empty(),
"MkDocs superfences with title= should not require language"
);
}
#[test]
fn test_mkdocs_superfences_hl_lines() {
let content = r#"```hl_lines="1 2"
line 1
line 2
```
"#;
let result = run_check_mkdocs(content).unwrap();
assert!(
result.is_empty(),
"MkDocs superfences with hl_lines= should not require language"
);
}
#[test]
fn test_mkdocs_superfences_linenums() {
let content = r#"```linenums="1"
line 1
line 2
```
"#;
let result = run_check_mkdocs(content).unwrap();
assert!(
result.is_empty(),
"MkDocs superfences with linenums= should not require language"
);
}
#[test]
fn test_mkdocs_superfences_class() {
let content = r#"```.my-class
some text
```
"#;
let result = run_check_mkdocs(content).unwrap();
assert!(
result.is_empty(),
"MkDocs superfences with .class should not require language"
);
}
#[test]
fn test_mkdocs_superfences_id() {
let content = r#"```#my-id
some text
```
"#;
let result = run_check_mkdocs(content).unwrap();
assert!(
result.is_empty(),
"MkDocs superfences with #id should not require language"
);
}
#[test]
fn test_mkdocs_superfences_with_language() {
let content = r#"```python title="Example" hl_lines="1"
print("hello")
```
"#;
let result = run_check_mkdocs(content).unwrap();
assert!(result.is_empty(), "Code block with language and attrs should pass");
}
#[test]
fn test_standard_flavor_no_special_handling() {
let content = r#"```title="Example"
echo hi
```
"#;
let result = run_check(content).unwrap();
assert_eq!(
result.len(),
1,
"Standard flavor should warn about title= without language"
);
}
#[test]
fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
let rule = MD040FencedCodeLanguage::default();
let content = "```{=html}\n<div>raw html</div>\n```\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
);
}
#[test]
fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
let rule = MD040FencedCodeLanguage::default();
let content = "```{=html}\n<div>raw html</div>\n```\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
);
}
#[test]
fn test_pandoc_accepts_raw_html_block() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{=html}\n<div>raw</div>\n```\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
}
#[test]
fn test_pandoc_rejects_quarto_exec_blocks() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{r}\nsummary(data)\n```\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
);
}
#[test]
fn test_quarto_still_accepts_exec_block() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{r}\nsummary(data)\n```\n";
let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD040 under Quarto should accept `{{r}}`: {result:?}"
);
}
#[test]
fn test_quarto_exec_block_skipped_under_quarto_only() {
let rule = MD040FencedCodeLanguage::default();
let content = "```{r}\n1 + 1\n```\n";
let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result_quarto = rule.check(&ctx_quarto).unwrap();
assert!(
result_quarto.is_empty(),
"MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
);
let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result_pandoc = rule.check(&ctx_pandoc).unwrap();
assert!(
!result_pandoc.is_empty(),
"MD040 should flag `{{r}}` under Pandoc as missing a real language"
);
}
#[test]
fn test_pandoc_class_attr_accepted_as_language() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{.python}\nprint(\"hi\")\n```\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result_pandoc = rule.check(&ctx_pandoc).unwrap();
assert!(
result_pandoc.is_empty(),
"MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
);
let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
let result_quarto = rule.check(&ctx_quarto).unwrap();
assert!(
result_quarto.is_empty(),
"MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
);
}
#[test]
fn test_pandoc_class_attr_with_extra_attributes_accepted() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result_pandoc = rule.check(&ctx_pandoc).unwrap();
assert!(
result_pandoc.is_empty(),
"MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
);
let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
let result_quarto = rule.check(&ctx_quarto).unwrap();
assert!(
result_quarto.is_empty(),
"MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
);
}
#[test]
fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result_pandoc = rule.check(&ctx_pandoc).unwrap();
assert!(
result_pandoc.is_empty(),
"MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
);
}
#[test]
fn test_standard_still_flags_pandoc_class_attr() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{.python}\nprint(\"hi\")\n```\n";
let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
let result_standard = rule.check(&ctx_standard).unwrap();
assert!(
!result_standard.is_empty(),
"MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
);
}
#[test]
fn test_pandoc_id_only_attr_still_flagged() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{#myid}\ncode here\n```\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result_pandoc = rule.check(&ctx_pandoc).unwrap();
assert!(
!result_pandoc.is_empty(),
"MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
);
}
#[test]
fn test_pandoc_empty_braces_still_flagged() {
use crate::config::MarkdownFlavor;
let rule = MD040FencedCodeLanguage::default();
let content = "```{}\ncode here\n```\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result_pandoc = rule.check(&ctx_pandoc).unwrap();
assert!(
!result_pandoc.is_empty(),
"MD040 under Pandoc should flag ```{{}}``` (no language declared)"
);
}
}