Skip to main content

rumdl_lib/rules/
md040_fenced_code_language.rs

1use crate::linguist_data::{default_alias, resolve_canonical};
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::load_rule_config;
4use crate::utils::range_utils::calculate_line_range;
5use std::collections::HashMap;
6
7/// Rule MD040: Fenced code blocks should have a language
8///
9/// See [docs/md040.md](../../docs/md040.md) for full documentation, configuration, and examples.
10pub mod md040_config;
11
12// ============================================================================
13// MkDocs Superfences Attribute Detection
14// ============================================================================
15
16/// Prefixes that indicate MkDocs superfences attributes rather than language identifiers.
17/// These are valid in MkDocs flavor without a language specification.
18/// See: https://facelessuser.github.io/pymdown-extensions/extensions/superfences/
19const MKDOCS_SUPERFENCES_ATTR_PREFIXES: &[&str] = &[
20    "title=",    // Block title
21    "hl_lines=", // Highlighted lines
22    "linenums=", // Line numbers
23    ".",         // CSS class (e.g., .annotate)
24    "#",         // CSS id
25];
26
27/// Check if a string starts with a MkDocs superfences attribute prefix
28#[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    /// 0-indexed line number where the code block starts
38    line_idx: usize,
39    /// The language/info string (empty if no language specified)
40    language: String,
41    /// The fence marker used (``` or ~~~)
42    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    /// The language a fence label names, or `None` when nothing recognizes it.
56    ///
57    /// Linguist stays authoritative, so a label it resolves keeps its canonical
58    /// name and its aliases. `custom-languages` answers for the labels Linguist
59    /// has no entry for, which lets a project name the languages it actually
60    /// uses instead of accepting every unknown label.
61    fn resolve_language(&self, label: &str) -> Option<&str> {
62        resolve_canonical(label).or_else(|| self.config.custom_language(label))
63    }
64
65    /// Validate the configuration and return any errors
66    fn validate_config(&self) -> Vec<String> {
67        let mut errors = Vec::new();
68
69        // A fence label is the first whitespace-separated word of the info
70        // string, so an entry holding whitespace anywhere, surrounding it
71        // included, could never match one.
72        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    /// Whether an inline comment turns this rule off for the block's fence line.
93    ///
94    /// A fence the rule is disabled for takes no part in the document's choice of
95    /// label, so this is asked while counting labels as well as while reporting.
96    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    /// Determine the preferred label for each canonical language in the document
101    fn compute_preferred_labels(
102        &self,
103        ctx: &crate::lint_context::LintContext,
104        blocks: &[FencedCodeBlock],
105    ) -> HashMap<String, String> {
106        // Group labels by canonical language
107        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        // Determine winning label for each canonical language
125        let mut result = HashMap::new();
126
127        for (canonical, labels) in by_canonical {
128            // Check for user override first (case-insensitive lookup)
129            let winner = if let Some(preferred) = self.config.preferred_label(&canonical) {
130                preferred.to_string()
131            } else {
132                // Find most prevalent label
133                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                    // Tie-break: use the curated default (or, for a custom
149                    // language, its declared spelling), otherwise alphabetically first
150                    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    /// Check if a language is allowed based on config
167    fn check_language_allowed(&self, canonical: Option<&str>, original_label: &str) -> Option<String> {
168        // Allowlist takes precedence
169        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    /// Check for unknown language based on config
201    fn check_unknown_language(&self, label: &str) -> Option<(String, Severity)> {
202        // GitHub accepts names, aliases, AND file extensions as fence labels
203        // (```pytb highlights via the .pytb extension), so the unknown check
204        // consults the full accept-set, not just resolvable aliases.
205        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        // Validate config and emit warnings for invalid configuration
240        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        // Derive fenced code blocks from pre-computed context
254        let fenced_blocks = derive_fenced_code_blocks(ctx);
255
256        // Compute preferred labels for consistent mode
257        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            // Get the actual line content for additional checks. Strip any
271            // blockquote prefix so the info string after the fence is recognized
272            // inside blockquotes the same way it is at the top level.
273            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            // Check if fence has MkDocs superfences attributes but no language
278            let has_mkdocs_attrs_only =
279                ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
280
281            // MyST directives use {name} as the info string (e.g., {note}, {code-cell} python).
282            // These are valid MyST syntax and should not trigger missing-language warnings.
283            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            // Pandoc/Quarto brace-syntax code chunks fall into three forms:
290            //   1. `{=html}` raw blocks — accepted under any Pandoc-compatible flavor.
291            //      Validated by `is_pandoc_raw_block_lang` (non-empty ASCII format name).
292            //   2. `{.python}` / `{.haskell .numberLines}` code-attribute syntax — the
293            //      first `.class` declares the language. Accepted under any
294            //      Pandoc-compatible flavor.
295            //   3. `{r}` / `{python}` exec chunks — accepted under Quarto only.
296            // Anything else wrapped in braces (e.g. `{r}` under pure Pandoc, or
297            // `{#myid}` with no class) is not a real language identifier and must be
298            // flagged as missing-language.
299            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                    // Replace from after fence marker to end of line content,
327                    // so trailing whitespace is cleaned up while any existing
328                    // info string / attributes are preserved via the replacement.
329                    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            // Skip further checks for Pandoc raw blocks and Quarto exec chunks
353            if has_pandoc_or_quarto_syntax {
354                continue;
355            }
356
357            let canonical = self.resolve_language(&block.language);
358
359            // Check language restrictions (allowlist/denylist)
360            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            // Check for unknown language (only if not handled by allowlist)
377            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            // Check consistency
396            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    /// Get the category of this rule for selective processing
442    fn category(&self) -> RuleCategory {
443        RuleCategory::CodeBlock
444    }
445
446    /// Check if this rule should be skipped
447    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
448        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
449    }
450
451    fn as_any(&self) -> &dyn std::any::Any {
452        self
453    }
454
455    crate::impl_rule_config_sections!(MD040Config);
456
457    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
458    where
459        Self: Sized,
460    {
461        let rule_config: MD040Config = load_rule_config(config);
462        Box::new(MD040FencedCodeLanguage::with_config(rule_config))
463    }
464}
465
466/// Derive fenced code blocks from pre-computed CodeBlockDetail data
467fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
468    let content = ctx.content;
469    let line_offsets = &ctx.line_offsets;
470
471    ctx.code_block_details
472        .iter()
473        .filter(|d| d.is_fenced)
474        .map(|detail| {
475            let line_idx = match line_offsets.binary_search(&detail.start) {
476                Ok(idx) => idx,
477                Err(idx) => idx.saturating_sub(1),
478            };
479
480            // Determine fence marker from the actual line content
481            let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
482            let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
483            let line = content.get(line_start..line_end).unwrap_or("");
484            let fence_marker =
485                find_fence_marker(line).map_or_else(|| "```".to_string(), |(_, marker)| marker.to_string());
486
487            let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
488
489            FencedCodeBlock {
490                line_idx,
491                language,
492                fence_marker,
493            }
494        })
495        .collect()
496}
497
498/// Locate the fence marker on a fence-opening line: its byte offset and the run
499/// of fence characters itself.
500///
501/// A fence opener can carry a blockquote prefix, indentation and one or more
502/// list markers (`- `, `1. `, and nested combinations). Rather than enumerating
503/// those prefixes, locate the marker itself: none of them can hold a backtick or
504/// a tilde, so the first run of either is the fence.
505fn find_fence_marker(line: &str) -> Option<(usize, &str)> {
506    let bytes = line.as_bytes();
507    let start = bytes.iter().position(|&b| b == b'`' || b == b'~')?;
508    let fence_char = bytes[start];
509    let len = bytes[start..].iter().take_while(|&&b| b == fence_char).count();
510    Some((start, &line[start..start + len]))
511}
512
513/// Byte offset within `line` where `fence_marker` begins.
514///
515/// Returns `None` when the line's fence run is not the expected marker, so
516/// callers offer no fix rather than one anchored at a guessed position.
517fn fence_marker_offset(line: &str, fence_marker: &str) -> Option<usize> {
518    let (start, marker) = find_fence_marker(line)?;
519    (marker == fence_marker).then_some(start)
520}
521
522/// Find the byte span of the language label in a fence line.
523fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
524    let marker_offset = fence_marker_offset(line, fence_marker)?;
525    let after_fence = &line[marker_offset + fence_marker.len()..];
526
527    let label_start_rel = after_fence
528        .char_indices()
529        .find(|&(_, ch)| !ch.is_whitespace())
530        .map(|(idx, _)| idx)?;
531    let after_label = &after_fence[label_start_rel..];
532    let label_end_rel = after_label
533        .char_indices()
534        .find(|&(_, ch)| ch.is_whitespace())
535        .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
536
537    Some((
538        marker_offset + fence_marker.len() + label_start_rel,
539        marker_offset + fence_marker.len() + label_end_rel,
540    ))
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::lint_context::LintContext;
547
548    fn run_check(content: &str) -> LintResult {
549        let rule = MD040FencedCodeLanguage::default();
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        rule.check(&ctx)
552    }
553
554    fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
555        let rule = MD040FencedCodeLanguage::with_config(config);
556        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557        rule.check(&ctx)
558    }
559
560    fn run_fix(content: &str) -> Result<String, LintError> {
561        let rule = MD040FencedCodeLanguage::default();
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563        rule.fix(&ctx)
564    }
565
566    fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
567        let rule = MD040FencedCodeLanguage::with_config(config);
568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569        rule.fix(&ctx)
570    }
571
572    fn run_check_mkdocs(content: &str) -> LintResult {
573        let rule = MD040FencedCodeLanguage::default();
574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
575        rule.check(&ctx)
576    }
577
578    // =========================================================================
579    // Basic functionality tests
580    // =========================================================================
581
582    #[test]
583    fn test_code_blocks_with_language_specified() {
584        let content = r#"# Test
585
586```python
587print("Hello, world!")
588```
589
590```javascript
591console.log("Hello!");
592```
593"#;
594        let result = run_check(content).unwrap();
595        assert!(result.is_empty(), "No warnings expected for code blocks with language");
596    }
597
598    #[test]
599    fn test_code_blocks_without_language() {
600        let content = r#"# Test
601
602```
603print("Hello, world!")
604```
605"#;
606        let result = run_check(content).unwrap();
607        assert_eq!(result.len(), 1);
608        assert_eq!(result[0].message, "Code block (```) missing language");
609        assert_eq!(result[0].line, 3);
610    }
611
612    #[test]
613    fn test_fix_method_adds_text_language() {
614        let content = r#"# Test
615
616```
617code without language
618```
619
620```python
621already has language
622```
623
624```
625another block without
626```
627"#;
628        let fixed = run_fix(content).unwrap();
629        assert!(fixed.contains("```text"));
630        assert!(fixed.contains("```python"));
631        assert_eq!(fixed.matches("```text").count(), 2);
632    }
633
634    #[test]
635    fn test_fix_preserves_indentation() {
636        let content = r#"# Test
637
638- List item
639  ```
640  indented code block
641  ```
642"#;
643        let fixed = run_fix(content).unwrap();
644        assert!(fixed.contains("  ```text"));
645    }
646
647    #[test]
648    fn test_fix_blockquote_empty_fence() {
649        // An empty fence inside a blockquote must become a valid `> ```text`
650        // fence, not a corrupted `> `text `` inline span. MD040 only touches the
651        // fence lines, so the indented content is preserved verbatim.
652        let content = "# Title\n\n> ```\n> root/\n> └── nested/\n>     └── file.txt\n> ```\n";
653        let fixed = run_fix(content).unwrap();
654        let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n>     └── file.txt\n> ```\n";
655        assert_eq!(fixed, expected);
656    }
657
658    #[test]
659    fn test_fix_blockquote_tilde_and_longer_fences() {
660        // Tilde fences and fences longer than three characters inside a
661        // blockquote must be detected by their actual marker, not the default.
662        let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
663        assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
664
665        let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
666        assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
667
668        let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
669        assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
670    }
671
672    #[test]
673    fn test_fix_nested_blockquote_empty_fence() {
674        // Compact and spaced nested blockquotes both carry their prefix into the
675        // fence line; the fix must place `text` after the real fence marker.
676        let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
677        assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
678
679        let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
680        assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
681    }
682
683    #[test]
684    fn test_fix_list_marker_empty_fence() {
685        // A fence opened on a list marker line must become `- ```text`, not a
686        // corrupted `` - `text `` `` inline span. The marker sits after the list
687        // bullet, so the fix has to locate it rather than assume it starts at the
688        // first non-whitespace byte.
689        let content = "# Title\n\n- ```\n  root/\n  └── nested/\n      └── file.txt\n  ```\n";
690        let fixed = run_fix(content).unwrap();
691        let expected = "# Title\n\n- ```text\n  root/\n  └── nested/\n      └── file.txt\n  ```\n";
692        assert_eq!(fixed, expected);
693    }
694
695    #[test]
696    fn test_fix_list_marker_fence_across_marker_styles() {
697        // Every list marker form pushes the fence a different distance into the
698        // line, including nested markers on one line and a marker inside a
699        // blockquote.
700        for (input, expected) in [
701            ("- ```\n  code\n  ```\n", "- ```text\n  code\n  ```\n"),
702            ("* ```\n  code\n  ```\n", "* ```text\n  code\n  ```\n"),
703            ("+ ```\n  code\n  ```\n", "+ ```text\n  code\n  ```\n"),
704            ("1. ```\n   code\n   ```\n", "1. ```text\n   code\n   ```\n"),
705            ("1) ```\n   code\n   ```\n", "1) ```text\n   code\n   ```\n"),
706            ("  - ```\n    code\n    ```\n", "  - ```text\n    code\n    ```\n"),
707            ("- - ```\n    code\n    ```\n", "- - ```text\n    code\n    ```\n"),
708            ("> - ```\n>   code\n>   ```\n", "> - ```text\n>   code\n>   ```\n"),
709        ] {
710            assert_eq!(run_fix(input).unwrap(), expected, "input: {input:?}");
711        }
712    }
713
714    #[test]
715    fn test_fix_list_marker_tilde_and_longer_fences() {
716        // The marker is derived from the line, so a tilde fence or a run longer
717        // than three characters must be measured at its real position instead of
718        // falling back to a three-backtick default.
719        let tilde = run_fix("- ~~~\n  code\n  ~~~\n").unwrap();
720        assert_eq!(tilde, "- ~~~text\n  code\n  ~~~\n");
721
722        let longer_tilde = run_fix("- ~~~~\n  code\n  ~~~~\n").unwrap();
723        assert_eq!(longer_tilde, "- ~~~~text\n  code\n  ~~~~\n");
724
725        let longer_backtick = run_fix("- ````\n  code\n  ````\n").unwrap();
726        assert_eq!(longer_backtick, "- ````text\n  code\n  ````\n");
727    }
728
729    #[test]
730    fn test_fix_list_marker_fence_is_idempotent() {
731        let content = "- ```\n  root/\n      nested\n  ```\n";
732        let once = run_fix(content).unwrap();
733        let twice = run_fix(&once).unwrap();
734        assert_eq!(once, twice);
735        assert_eq!(once, "- ```text\n  root/\n      nested\n  ```\n");
736    }
737
738    #[test]
739    fn test_fix_list_marker_fence_with_language_untouched() {
740        let content = "- ```rust\n  code\n  ```\n";
741        assert!(run_check(content).unwrap().is_empty());
742        assert_eq!(run_fix(content).unwrap(), content);
743    }
744
745    #[test]
746    fn test_fix_blockquote_empty_fence_is_idempotent() {
747        // Re-running the fix on its own output must be a no-op.
748        let content = "> ```\n> root/\n>     nested\n> ```\n";
749        let once = run_fix(content).unwrap();
750        let twice = run_fix(&once).unwrap();
751        assert_eq!(once, twice);
752        assert_eq!(once, "> ```text\n> root/\n>     nested\n> ```\n");
753    }
754
755    // =========================================================================
756    // Consistent mode tests
757    // =========================================================================
758
759    #[test]
760    fn test_consistent_mode_detects_inconsistency() {
761        let content = r#"```bash
762echo hi
763```
764
765```sh
766echo there
767```
768
769```bash
770echo again
771```
772"#;
773        let config = MD040Config {
774            style: LanguageStyle::Consistent,
775            ..Default::default()
776        };
777        let result = run_check_with_config(content, config).unwrap();
778        assert_eq!(result.len(), 1);
779        assert!(result[0].message.contains("Inconsistent"));
780        assert!(result[0].message.contains("sh"));
781        assert!(result[0].message.contains("bash"));
782    }
783
784    #[test]
785    fn test_consistent_mode_fix_normalizes() {
786        let content = r#"```bash
787echo hi
788```
789
790```sh
791echo there
792```
793
794```bash
795echo again
796```
797"#;
798        let config = MD040Config {
799            style: LanguageStyle::Consistent,
800            ..Default::default()
801        };
802        let fixed = run_fix_with_config(content, config).unwrap();
803        assert_eq!(fixed.matches("```bash").count(), 3);
804        assert_eq!(fixed.matches("```sh").count(), 0);
805    }
806
807    #[test]
808    fn test_consistent_mode_tie_break_uses_curated_default() {
809        // When there's a tie (1 bash, 1 sh), should use curated default (bash)
810        let content = r#"```bash
811echo hi
812```
813
814```sh
815echo there
816```
817"#;
818        let config = MD040Config {
819            style: LanguageStyle::Consistent,
820            ..Default::default()
821        };
822        let fixed = run_fix_with_config(content, config).unwrap();
823        // bash is the curated default for Shell
824        assert_eq!(fixed.matches("```bash").count(), 2);
825    }
826
827    #[test]
828    fn test_consistent_mode_with_preferred_alias() {
829        let content = r#"```bash
830echo hi
831```
832
833```sh
834echo there
835```
836"#;
837        let mut preferred = HashMap::new();
838        preferred.insert("Shell".to_string(), "sh".to_string());
839
840        let config = MD040Config {
841            style: LanguageStyle::Consistent,
842            preferred_aliases: preferred,
843            ..Default::default()
844        };
845        let fixed = run_fix_with_config(content, config).unwrap();
846        assert_eq!(fixed.matches("```sh").count(), 2);
847        assert_eq!(fixed.matches("```bash").count(), 0);
848    }
849
850    #[test]
851    fn test_consistent_mode_fix_inside_blockquote() {
852        // Consistent-mode normalization must reach fences inside blockquotes.
853        // With one `bash` and one `sh`, the curated default `bash` wins.
854        let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
855        let config = MD040Config {
856            style: LanguageStyle::Consistent,
857            ..Default::default()
858        };
859        let fixed = run_fix_with_config(content, config).unwrap();
860        assert_eq!(
861            fixed,
862            "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
863        );
864    }
865
866    #[test]
867    fn test_consistent_mode_ignores_disabled_blocks() {
868        let content = r#"```bash
869echo hi
870```
871<!-- rumdl-disable MD040 -->
872```sh
873echo there
874```
875```sh
876echo again
877```
878<!-- rumdl-enable MD040 -->
879"#;
880        let config = MD040Config {
881            style: LanguageStyle::Consistent,
882            ..Default::default()
883        };
884        let result = run_check_with_config(content, config).unwrap();
885        assert!(result.is_empty(), "Disabled blocks should not affect consistency");
886    }
887
888    #[test]
889    fn test_disable_comment_naming_the_rule_by_alias_disables_it() {
890        let content = r#"```bash
891echo hi
892```
893<!-- rumdl-disable fenced-code-language -->
894```sh
895echo there
896```
897```sh
898echo again
899```
900<!-- rumdl-enable fenced-code-language -->
901"#;
902        let config = MD040Config {
903            style: LanguageStyle::Consistent,
904            ..Default::default()
905        };
906        let result = run_check_with_config(content, config.clone()).unwrap();
907        assert!(
908            result.is_empty(),
909            "an alias names the same rule as the ID does: {result:?}"
910        );
911
912        let names_another_rule = content.replace("fenced-code-language", "line-length");
913        let result = run_check_with_config(&names_another_rule, config).unwrap();
914        assert_eq!(
915            result.len(),
916            1,
917            "a directive naming another rule leaves the sh blocks voting: {result:?}"
918        );
919    }
920
921    #[test]
922    fn test_a_line_scoped_directive_takes_the_fence_out_of_the_vote() {
923        let directive = "<!-- rumdl-disable-next-line MD040 -->\n";
924        let content =
925            format!("```bash\necho one\n```\n\n{directive}```sh\necho two\n```\n\n{directive}```sh\necho three\n```\n");
926        let config = MD040Config {
927            style: LanguageStyle::Consistent,
928            ..Default::default()
929        };
930
931        let without_directives = content.replace(directive, "");
932        assert_eq!(
933            run_check_with_config(&without_directives, config.clone())
934                .unwrap()
935                .len(),
936            1,
937            "control: two sh fences outvote the bash one"
938        );
939
940        // A fence the rule is disabled for cannot decide the label for the fences
941        // that are still checked, so bash stands alone and is left as it is.
942        let result = run_check_with_config(&content, config).unwrap();
943        assert!(result.is_empty(), "a disabled fence casts no vote: {result:?}");
944    }
945
946    #[test]
947    fn test_a_directive_shown_inside_a_code_block_disables_nothing() {
948        // A document explaining the directive quotes it as sample text. Quoted or
949        // not, the sh blocks outvote the bash one, so the bash fence is reported.
950        let sample = "```text\n<!-- rumdl-disable RULE -->\n```\n";
951        let blocks = "\n```bash\necho one\n```\n\n```sh\necho two\n```\n\n```sh\necho three\n```\n";
952        let config = MD040Config {
953            style: LanguageStyle::Consistent,
954            ..Default::default()
955        };
956
957        let without_sample = run_check_with_config(blocks, config.clone()).unwrap();
958        assert_eq!(without_sample.len(), 1, "control: the bash fence is reported");
959
960        for name in ["MD040", "fenced-code-language"] {
961            let content = format!("{}{blocks}", sample.replace("RULE", name));
962            let result = run_check_with_config(&content, config.clone()).unwrap();
963            assert_eq!(
964                result.len(),
965                1,
966                "`{name}` inside a code block is sample text, not a directive: {result:?}"
967            );
968        }
969    }
970
971    #[test]
972    fn test_fix_preserves_attributes() {
973        let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
974        let config = MD040Config {
975            style: LanguageStyle::Consistent,
976            ..Default::default()
977        };
978        let fixed = run_fix_with_config(content, config).unwrap();
979        assert!(fixed.contains("```bash {.highlight}"));
980    }
981
982    #[test]
983    fn test_fix_preserves_spacing_before_label() {
984        let content = "```bash\ncode\n```\n\n```  sh {.highlight}\ncode\n```";
985        let config = MD040Config {
986            style: LanguageStyle::Consistent,
987            ..Default::default()
988        };
989        let fixed = run_fix_with_config(content, config).unwrap();
990        assert!(fixed.contains("```  bash {.highlight}"));
991        assert!(!fixed.contains("```  sh {.highlight}"));
992    }
993
994    // =========================================================================
995    // Allowlist/denylist tests
996    // =========================================================================
997
998    #[test]
999    fn test_allowlist_blocks_unlisted() {
1000        let content = "```java\ncode\n```";
1001        let config = MD040Config {
1002            allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
1003            ..Default::default()
1004        };
1005        let result = run_check_with_config(content, config).unwrap();
1006        assert_eq!(result.len(), 1);
1007        assert!(result[0].message.contains("not in the allowed list"));
1008    }
1009
1010    #[test]
1011    fn test_allowlist_allows_listed() {
1012        let content = "```python\ncode\n```";
1013        let config = MD040Config {
1014            allowed_languages: vec!["Python".to_string()],
1015            ..Default::default()
1016        };
1017        let result = run_check_with_config(content, config).unwrap();
1018        assert!(result.is_empty());
1019    }
1020
1021    #[test]
1022    fn test_allowlist_blocks_unknown_language() {
1023        let content = "```mysterylang\ncode\n```";
1024        let config = MD040Config {
1025            allowed_languages: vec!["Python".to_string()],
1026            ..Default::default()
1027        };
1028        let result = run_check_with_config(content, config).unwrap();
1029        assert_eq!(result.len(), 1);
1030        assert!(result[0].message.contains("allowed list"));
1031    }
1032
1033    #[test]
1034    fn test_allowlist_case_insensitive() {
1035        let content = "```python\ncode\n```";
1036        let config = MD040Config {
1037            allowed_languages: vec!["PYTHON".to_string()],
1038            ..Default::default()
1039        };
1040        let result = run_check_with_config(content, config).unwrap();
1041        assert!(result.is_empty());
1042    }
1043
1044    #[test]
1045    fn test_denylist_blocks_listed() {
1046        let content = "```java\ncode\n```";
1047        let config = MD040Config {
1048            disallowed_languages: vec!["Java".to_string()],
1049            ..Default::default()
1050        };
1051        let result = run_check_with_config(content, config).unwrap();
1052        assert_eq!(result.len(), 1);
1053        assert!(result[0].message.contains("disallowed"));
1054    }
1055
1056    #[test]
1057    fn test_denylist_allows_unlisted() {
1058        let content = "```python\ncode\n```";
1059        let config = MD040Config {
1060            disallowed_languages: vec!["Java".to_string()],
1061            ..Default::default()
1062        };
1063        let result = run_check_with_config(content, config).unwrap();
1064        assert!(result.is_empty());
1065    }
1066
1067    #[test]
1068    fn test_allowlist_takes_precedence_over_denylist() {
1069        let content = "```python\ncode\n```";
1070        let config = MD040Config {
1071            allowed_languages: vec!["Python".to_string()],
1072            disallowed_languages: vec!["Python".to_string()], // Should be ignored
1073            ..Default::default()
1074        };
1075        let result = run_check_with_config(content, config).unwrap();
1076        assert!(result.is_empty());
1077    }
1078
1079    // =========================================================================
1080    // Unknown language tests
1081    // =========================================================================
1082
1083    #[test]
1084    fn test_unknown_language_ignore_default() {
1085        let content = "```mycustomlang\ncode\n```";
1086        let result = run_check(content).unwrap();
1087        assert!(result.is_empty(), "Unknown languages ignored by default");
1088    }
1089
1090    #[test]
1091    fn test_unknown_language_warn() {
1092        let content = "```mycustomlang\ncode\n```";
1093        let config = MD040Config {
1094            unknown_language_action: UnknownLanguageAction::Warn,
1095            ..Default::default()
1096        };
1097        let result = run_check_with_config(content, config).unwrap();
1098        assert_eq!(result.len(), 1);
1099        assert!(result[0].message.contains("Unknown language"));
1100        assert!(result[0].message.contains("mycustomlang"));
1101        assert_eq!(result[0].severity, Severity::Warning);
1102    }
1103
1104    /// Regression test for a category of bugs, not one instance: rumdl's generated
1105    /// Linguist alias table must recognize every high-traffic language alias, not
1106    /// just the exact one reported (`py`). Each alias below is confirmed present in
1107    /// GitHub Linguist's current `aliases:` list for its language (`py` and `py3`
1108    /// were added upstream after the `e51c2270` generation pin, see the header
1109    /// comment in `src/linguist_data.rs`); none of them should ever trigger an
1110    /// unknown-language warning.
1111    #[test]
1112    fn test_unknown_language_warn_known_aliases_not_flagged() {
1113        let known_aliases = [
1114            "py",
1115            "python",
1116            "sh",
1117            "bash",
1118            "shell",
1119            "zsh",
1120            "js",
1121            "javascript",
1122            "ts",
1123            "typescript",
1124            "rb",
1125            "ruby",
1126            "rs",
1127            "rust",
1128            "yml",
1129            "yaml",
1130            "cpp",
1131            "c++",
1132            "csharp",
1133            "golang",
1134            "dockerfile",
1135            "jsonc",
1136            "kotlin",
1137        ];
1138        for alias in known_aliases {
1139            let content = format!("```{alias}\ncode\n```");
1140            let config = MD040Config {
1141                unknown_language_action: UnknownLanguageAction::Warn,
1142                ..Default::default()
1143            };
1144            let result = run_check_with_config(&content, config).unwrap();
1145            assert!(
1146                result.is_empty(),
1147                "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1148            );
1149        }
1150    }
1151
1152    /// GitHub also accepts file extensions as fence labels (a ```pytb block
1153    /// renders with the Python-traceback grammar even though `pytb` appears
1154    /// only in Linguist's `extensions:`, never `aliases:`), so extension
1155    /// labels must not be flagged as unknown either.
1156    #[test]
1157    fn test_unknown_language_extension_labels_not_flagged() {
1158        for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
1159            let content = format!("```{label}\ncode\n```");
1160            let config = MD040Config {
1161                unknown_language_action: UnknownLanguageAction::Error,
1162                ..Default::default()
1163            };
1164            let result = run_check_with_config(&content, config).unwrap();
1165            assert!(
1166                result.is_empty(),
1167                "Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
1168            );
1169        }
1170    }
1171
1172    #[test]
1173    fn test_unknown_language_error() {
1174        let content = "```mycustomlang\ncode\n```";
1175        let config = MD040Config {
1176            unknown_language_action: UnknownLanguageAction::Error,
1177            ..Default::default()
1178        };
1179        let result = run_check_with_config(content, config).unwrap();
1180        assert_eq!(result.len(), 1);
1181        assert!(result[0].message.contains("Unknown language"));
1182        assert_eq!(result[0].severity, Severity::Error);
1183    }
1184
1185    // =========================================================================
1186    // Config validation tests
1187    // =========================================================================
1188
1189    #[test]
1190    fn test_invalid_preferred_alias_detected() {
1191        let mut preferred = HashMap::new();
1192        preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1193
1194        let config = MD040Config {
1195            style: LanguageStyle::Consistent,
1196            preferred_aliases: preferred,
1197            ..Default::default()
1198        };
1199        let rule = MD040FencedCodeLanguage::with_config(config);
1200        let errors = rule.validate_config();
1201        assert_eq!(errors.len(), 1);
1202        assert!(errors[0].contains("Invalid alias"));
1203        assert!(errors[0].contains("invalid_alias"));
1204    }
1205
1206    #[test]
1207    fn test_invalid_preferred_alias_is_not_normalized_to() {
1208        // An alias the language does not have is a configuration error, so
1209        // fixing to it would rewrite valid labels into an invalid one.
1210        let content = "```sh\necho one\n```\n\n```bash\necho two\n```\n";
1211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212
1213        let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1214            style: LanguageStyle::Consistent,
1215            preferred_aliases: HashMap::from([("Shell".to_string(), "invalid_alias".to_string())]),
1216            ..Default::default()
1217        });
1218        let fixed = rule.fix(&ctx).unwrap();
1219        assert!(
1220            !fixed.contains("invalid_alias"),
1221            "an invalid alias must not reach the document, got:\n{fixed}"
1222        );
1223        assert!(
1224            rule.check(&ctx)
1225                .unwrap()
1226                .iter()
1227                .any(|w| w.message.contains("Invalid alias")),
1228            "the invalid alias is still reported"
1229        );
1230
1231        // Control: a valid alias for the same language is still normalized to.
1232        let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1233            style: LanguageStyle::Consistent,
1234            preferred_aliases: HashMap::from([("Shell".to_string(), "zsh".to_string())]),
1235            ..Default::default()
1236        });
1237        assert_eq!(
1238            rule.fix(&ctx).unwrap(),
1239            "```zsh\necho one\n```\n\n```zsh\necho two\n```\n"
1240        );
1241    }
1242
1243    #[test]
1244    fn test_unknown_language_in_preferred_aliases_detected() {
1245        let mut preferred = HashMap::new();
1246        preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1247
1248        let config = MD040Config {
1249            style: LanguageStyle::Consistent,
1250            preferred_aliases: preferred,
1251            ..Default::default()
1252        };
1253        let rule = MD040FencedCodeLanguage::with_config(config);
1254        let errors = rule.validate_config();
1255        assert_eq!(errors.len(), 1);
1256        assert!(errors[0].contains("Unknown language"));
1257    }
1258
1259    #[test]
1260    fn test_valid_preferred_alias_accepted() {
1261        let mut preferred = HashMap::new();
1262        preferred.insert("Shell".to_string(), "bash".to_string());
1263        preferred.insert("JavaScript".to_string(), "js".to_string());
1264
1265        let config = MD040Config {
1266            style: LanguageStyle::Consistent,
1267            preferred_aliases: preferred,
1268            ..Default::default()
1269        };
1270        let rule = MD040FencedCodeLanguage::with_config(config);
1271        let errors = rule.validate_config();
1272        assert!(errors.is_empty());
1273    }
1274
1275    #[test]
1276    fn test_config_error_uses_valid_line_column() {
1277        let config = md040_config::MD040Config {
1278            preferred_aliases: {
1279                let mut map = std::collections::HashMap::new();
1280                map.insert("Shell".to_string(), "invalid_alias".to_string());
1281                map
1282            },
1283            ..Default::default()
1284        };
1285        let rule = MD040FencedCodeLanguage::with_config(config);
1286
1287        let content = "```shell\necho hello\n```";
1288        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289        let result = rule.check(&ctx).unwrap();
1290
1291        // Find the config error warning
1292        let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1293        assert!(config_error.is_some(), "Should have a config error warning");
1294
1295        let warning = config_error.unwrap();
1296        // Line and column should be 1-indexed (not 0)
1297        assert!(
1298            warning.line >= 1,
1299            "Config error line should be >= 1, got {}",
1300            warning.line
1301        );
1302        assert!(
1303            warning.column >= 1,
1304            "Config error column should be >= 1, got {}",
1305            warning.column
1306        );
1307    }
1308
1309    // =========================================================================
1310    // custom-languages tests
1311    // =========================================================================
1312
1313    fn custom_languages_config(declared: &[&str]) -> MD040Config {
1314        MD040Config {
1315            unknown_language_action: UnknownLanguageAction::Error,
1316            custom_languages: declared.iter().map(|s| (*s).to_string()).collect(),
1317            ..Default::default()
1318        }
1319    }
1320
1321    #[test]
1322    fn test_declared_custom_language_is_not_unknown() {
1323        let content = "```cddl\nfoo = tstr\n```\n";
1324
1325        let flagged = run_check_with_config(content, custom_languages_config(&[])).unwrap();
1326        assert_eq!(flagged.len(), 1, "an undeclared unknown label must still be reported");
1327        assert!(flagged[0].message.contains("Unknown language 'cddl'"));
1328
1329        let accepted = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1330        assert!(accepted.is_empty(), "a declared label must be accepted: {accepted:?}");
1331    }
1332
1333    #[test]
1334    fn test_custom_language_matches_a_label_case_insensitively() {
1335        let content = "```CDDL\nfoo = tstr\n```\n";
1336        let result = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1337        assert!(result.is_empty(), "label case must not matter: {result:?}");
1338    }
1339
1340    #[test]
1341    fn test_custom_language_does_not_shadow_linguist() {
1342        // Declaring a label Linguist knows leaves Linguist's answer in place, so
1343        // `sh` still resolves to Shell and normalizes with the rest of that language.
1344        let content = "```sh\necho hi\n```\n\n```bash\necho there\n```\n\n```bash\necho again\n```\n";
1345        let config = MD040Config {
1346            style: LanguageStyle::Consistent,
1347            custom_languages: vec!["sh".to_string()],
1348            ..Default::default()
1349        };
1350        let result = run_check_with_config(content, config.clone()).unwrap();
1351        assert_eq!(result.len(), 1, "sh must still be judged against Shell: {result:?}");
1352        assert!(result[0].message.contains("use 'bash'"));
1353
1354        let fixed = run_fix_with_config(content, config).unwrap();
1355        assert!(!fixed.contains("```sh\n"));
1356    }
1357
1358    #[test]
1359    fn test_custom_language_normalizes_under_consistent_style() {
1360        let content = "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n";
1361        let config = MD040Config {
1362            style: LanguageStyle::Consistent,
1363            custom_languages: vec!["cddl".to_string()],
1364            ..Default::default()
1365        };
1366        let result = run_check_with_config(content, config.clone()).unwrap();
1367        assert_eq!(result.len(), 1, "the two spellings are one language: {result:?}");
1368
1369        // Both spellings appear once, and the declared spelling breaks the tie.
1370        let fixed = run_fix_with_config(content, config).unwrap();
1371        assert!(fixed.contains("```cddl"));
1372        assert!(!fixed.contains("```CDDL"));
1373    }
1374
1375    #[test]
1376    fn test_custom_language_participates_in_allowed_and_disallowed_lists() {
1377        let content = "```cddl\nfoo = tstr\n```\n";
1378
1379        let allowed = run_check_with_config(
1380            content,
1381            MD040Config {
1382                allowed_languages: vec!["cddl".to_string()],
1383                custom_languages: vec!["cddl".to_string()],
1384                ..Default::default()
1385            },
1386        )
1387        .unwrap();
1388        assert!(allowed.is_empty(), "an allowed custom language passes: {allowed:?}");
1389
1390        let disallowed = run_check_with_config(
1391            content,
1392            MD040Config {
1393                disallowed_languages: vec!["cddl".to_string()],
1394                custom_languages: vec!["cddl".to_string()],
1395                ..Default::default()
1396            },
1397        )
1398        .unwrap();
1399        assert_eq!(disallowed.len(), 1, "a disallowed custom language is reported");
1400        assert!(disallowed[0].message.contains("is disallowed"));
1401    }
1402
1403    #[test]
1404    fn test_undeclared_language_is_not_allowed_by_the_allowlist() {
1405        // Without a declaration the label resolves to nothing, so the allowlist
1406        // cannot admit it even when its own name is on the list.
1407        let result = run_check_with_config(
1408            "```cddl\nfoo = tstr\n```\n",
1409            MD040Config {
1410                allowed_languages: vec!["cddl".to_string()],
1411                ..Default::default()
1412            },
1413        )
1414        .unwrap();
1415        assert_eq!(result.len(), 1);
1416        assert!(result[0].message.contains("is not in the allowed list"));
1417    }
1418
1419    #[test]
1420    fn test_unusable_custom_language_entries_are_config_errors() {
1421        let rule =
1422            MD040FencedCodeLanguage::with_config(custom_languages_config(&["c ddl", "cddl ", " cddl", "   ", "cddl"]));
1423        let errors = rule.validate_config();
1424        assert_eq!(errors.len(), 4, "only the unusable entries are reported: {errors:?}");
1425        assert_eq!(
1426            errors.iter().filter(|e| e.contains("contains whitespace")).count(),
1427            3,
1428            "whitespace around an entry is as unmatchable as whitespace inside it: {errors:?}"
1429        );
1430        assert!(errors.iter().any(|e| e.contains("Empty entry in custom-languages")));
1431    }
1432
1433    #[test]
1434    fn test_custom_language_with_surrounding_whitespace_does_not_match_a_label() {
1435        let content = "```cddl\nfoo = int\n```";
1436        let result = run_check_with_config(content, custom_languages_config(&["cddl "])).unwrap();
1437        assert_eq!(
1438            result.len(),
1439            2,
1440            "the label stays unknown and the entry is reported: {result:?}"
1441        );
1442        assert!(result[0].message.contains("[config error]"));
1443        assert!(result[0].message.contains("contains whitespace"));
1444        assert!(result[1].message.contains("Unknown language 'cddl'"));
1445    }
1446
1447    #[test]
1448    fn test_preferred_alias_for_a_custom_language() {
1449        let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1450            preferred_aliases: HashMap::from([("CDDL".to_string(), "cddl".to_string())]),
1451            custom_languages: vec!["cddl".to_string()],
1452            ..Default::default()
1453        });
1454        assert!(
1455            accepted.validate_config().is_empty(),
1456            "a spelling of the declared name is a valid preference"
1457        );
1458
1459        let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1460            preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1461            custom_languages: vec!["cddl".to_string()],
1462            ..Default::default()
1463        });
1464        let errors = rejected.validate_config();
1465        assert_eq!(errors.len(), 1, "a custom language has no aliases: {errors:?}");
1466        assert!(errors[0].contains("Invalid alias 'cbor-dl' for custom language 'cddl'"));
1467
1468        // The rejected preference does not reach the document either: labels
1469        // normalize to the declared spelling, not to the invalid alias.
1470        let ctx = LintContext::new(
1471            "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n",
1472            crate::config::MarkdownFlavor::Standard,
1473            None,
1474        );
1475        let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1476            style: LanguageStyle::Consistent,
1477            preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1478            custom_languages: vec!["cddl".to_string()],
1479            ..Default::default()
1480        });
1481        assert_eq!(
1482            rejected.fix(&ctx).unwrap(),
1483            "```cddl\nfoo = tstr\n```\n\n```cddl\nbar = int\n```\n"
1484        );
1485
1486        // Control: an accepted preference does drive normalization.
1487        let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1488            style: LanguageStyle::Consistent,
1489            preferred_aliases: HashMap::from([("cddl".to_string(), "CDDL".to_string())]),
1490            custom_languages: vec!["cddl".to_string()],
1491            ..Default::default()
1492        });
1493        assert_eq!(
1494            accepted.fix(&ctx).unwrap(),
1495            "```CDDL\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n"
1496        );
1497    }
1498
1499    // =========================================================================
1500    // Linguist resolution tests
1501    // =========================================================================
1502
1503    #[test]
1504    fn test_linguist_resolution() {
1505        assert_eq!(resolve_canonical("bash"), Some("Shell"));
1506        assert_eq!(resolve_canonical("sh"), Some("Shell"));
1507        assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1508        assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1509        assert_eq!(resolve_canonical("python"), Some("Python"));
1510        assert_eq!(resolve_canonical("unknown_lang"), None);
1511    }
1512
1513    #[test]
1514    fn test_linguist_resolution_case_insensitive() {
1515        assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1516        assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1517        assert_eq!(resolve_canonical("Python"), Some("Python"));
1518        assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1519    }
1520
1521    #[test]
1522    fn test_alias_validation() {
1523        use crate::linguist_data::is_valid_alias;
1524
1525        assert!(is_valid_alias("Shell", "bash"));
1526        assert!(is_valid_alias("Shell", "sh"));
1527        assert!(is_valid_alias("Shell", "zsh"));
1528        assert!(!is_valid_alias("Shell", "python"));
1529        assert!(!is_valid_alias("Shell", "invalid"));
1530    }
1531
1532    #[test]
1533    fn test_default_alias() {
1534        assert_eq!(default_alias("Shell"), Some("bash"));
1535        assert_eq!(default_alias("JavaScript"), Some("js"));
1536        assert_eq!(default_alias("Python"), Some("python"));
1537    }
1538
1539    // =========================================================================
1540    // Edge case tests
1541    // =========================================================================
1542
1543    #[test]
1544    fn test_mixed_case_labels_normalized() {
1545        let content = r#"```BASH
1546echo hi
1547```
1548
1549```Bash
1550echo there
1551```
1552
1553```bash
1554echo again
1555```
1556"#;
1557        let config = MD040Config {
1558            style: LanguageStyle::Consistent,
1559            ..Default::default()
1560        };
1561        // All should resolve to Shell, most prevalent should win
1562        let result = run_check_with_config(content, config).unwrap();
1563        // "bash" appears 1x, "Bash" appears 1x, "BASH" appears 1x
1564        // All are different strings, so there's a 3-way tie
1565        // Should pick curated default "bash" or alphabetically first
1566        assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1567    }
1568
1569    #[test]
1570    fn test_multiple_languages_independent() {
1571        let content = r#"```bash
1572shell code
1573```
1574
1575```python
1576python code
1577```
1578
1579```sh
1580more shell
1581```
1582
1583```python3
1584more python
1585```
1586"#;
1587        let config = MD040Config {
1588            style: LanguageStyle::Consistent,
1589            ..Default::default()
1590        };
1591        let result = run_check_with_config(content, config).unwrap();
1592        // Should have 2 warnings: one for sh (inconsistent with bash) and one for python3 (inconsistent with python)
1593        assert_eq!(result.len(), 2);
1594    }
1595
1596    #[test]
1597    fn test_tilde_fences() {
1598        let content = r#"~~~bash
1599echo hi
1600~~~
1601
1602~~~sh
1603echo there
1604~~~
1605"#;
1606        let config = MD040Config {
1607            style: LanguageStyle::Consistent,
1608            ..Default::default()
1609        };
1610        let result = run_check_with_config(content, config.clone()).unwrap();
1611        assert_eq!(result.len(), 1);
1612
1613        let fixed = run_fix_with_config(content, config).unwrap();
1614        assert!(fixed.contains("~~~bash"));
1615        assert!(!fixed.contains("~~~sh"));
1616    }
1617
1618    #[test]
1619    fn test_longer_fence_markers_preserved() {
1620        let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1621        let config = MD040Config {
1622            style: LanguageStyle::Consistent,
1623            ..Default::default()
1624        };
1625        let fixed = run_fix_with_config(content, config).unwrap();
1626        assert!(fixed.contains("````bash"));
1627        assert!(fixed.contains("```bash"));
1628    }
1629
1630    #[test]
1631    fn test_empty_document() {
1632        let result = run_check("").unwrap();
1633        assert!(result.is_empty());
1634    }
1635
1636    #[test]
1637    fn test_no_code_blocks() {
1638        let content = "# Just a heading\n\nSome text.";
1639        let result = run_check(content).unwrap();
1640        assert!(result.is_empty());
1641    }
1642
1643    #[test]
1644    fn test_single_code_block_no_inconsistency() {
1645        let content = "```bash\necho hi\n```";
1646        let config = MD040Config {
1647            style: LanguageStyle::Consistent,
1648            ..Default::default()
1649        };
1650        let result = run_check_with_config(content, config).unwrap();
1651        assert!(result.is_empty(), "Single block has no inconsistency");
1652    }
1653
1654    #[test]
1655    fn test_idempotent_fix() {
1656        let content = r#"```bash
1657echo hi
1658```
1659
1660```sh
1661echo there
1662```
1663"#;
1664        let config = MD040Config {
1665            style: LanguageStyle::Consistent,
1666            ..Default::default()
1667        };
1668        let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1669        let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1670        assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1671    }
1672
1673    // =========================================================================
1674    // MkDocs superfences tests
1675    // =========================================================================
1676
1677    #[test]
1678    fn test_mkdocs_superfences_attribute_in_blockquote() {
1679        // A superfences attribute fence (no language) inside a blockquote must be
1680        // recognized just like a top-level one and not flagged as missing language.
1681        let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1682        let result = run_check_mkdocs(content).unwrap();
1683        assert!(
1684            result.is_empty(),
1685            "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1686        );
1687    }
1688
1689    #[test]
1690    fn test_mkdocs_superfences_title_only() {
1691        // title= attribute without language should not warn in MkDocs flavor
1692        let content = r#"```title="Example"
1693echo hi
1694```
1695"#;
1696        let result = run_check_mkdocs(content).unwrap();
1697        assert!(
1698            result.is_empty(),
1699            "MkDocs superfences with title= should not require language"
1700        );
1701    }
1702
1703    #[test]
1704    fn test_mkdocs_superfences_hl_lines() {
1705        // hl_lines= attribute without language should not warn
1706        let content = r#"```hl_lines="1 2"
1707line 1
1708line 2
1709```
1710"#;
1711        let result = run_check_mkdocs(content).unwrap();
1712        assert!(
1713            result.is_empty(),
1714            "MkDocs superfences with hl_lines= should not require language"
1715        );
1716    }
1717
1718    #[test]
1719    fn test_mkdocs_superfences_linenums() {
1720        // linenums= attribute without language should not warn
1721        let content = r#"```linenums="1"
1722line 1
1723line 2
1724```
1725"#;
1726        let result = run_check_mkdocs(content).unwrap();
1727        assert!(
1728            result.is_empty(),
1729            "MkDocs superfences with linenums= should not require language"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_mkdocs_superfences_class() {
1735        // Custom class (starting with .) should not warn
1736        let content = r#"```.my-class
1737some text
1738```
1739"#;
1740        let result = run_check_mkdocs(content).unwrap();
1741        assert!(
1742            result.is_empty(),
1743            "MkDocs superfences with .class should not require language"
1744        );
1745    }
1746
1747    #[test]
1748    fn test_mkdocs_superfences_id() {
1749        // Custom ID (starting with #) should not warn
1750        let content = r#"```#my-id
1751some text
1752```
1753"#;
1754        let result = run_check_mkdocs(content).unwrap();
1755        assert!(
1756            result.is_empty(),
1757            "MkDocs superfences with #id should not require language"
1758        );
1759    }
1760
1761    #[test]
1762    fn test_mkdocs_superfences_with_language() {
1763        // Language with superfences attributes should work fine
1764        let content = r#"```python title="Example" hl_lines="1"
1765print("hello")
1766```
1767"#;
1768        let result = run_check_mkdocs(content).unwrap();
1769        assert!(result.is_empty(), "Code block with language and attrs should pass");
1770    }
1771
1772    #[test]
1773    fn test_standard_flavor_no_special_handling() {
1774        // In Standard flavor, title= should still warn
1775        let content = r#"```title="Example"
1776echo hi
1777```
1778"#;
1779        let result = run_check(content).unwrap();
1780        assert_eq!(
1781            result.len(),
1782            1,
1783            "Standard flavor should warn about title= without language"
1784        );
1785    }
1786
1787    #[test]
1788    fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1789        // ```{=html} raw blocks are valid Pandoc syntax and should not trigger MD040
1790        // under Pandoc flavor.
1791        let rule = MD040FencedCodeLanguage::default();
1792        let content = "```{=html}\n<div>raw html</div>\n```\n";
1793        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1794        let result = rule.check(&ctx).unwrap();
1795        assert!(
1796            result.is_empty(),
1797            "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1803        // ```{=html} raw blocks are also valid under Quarto (which is Pandoc-compatible).
1804        let rule = MD040FencedCodeLanguage::default();
1805        let content = "```{=html}\n<div>raw html</div>\n```\n";
1806        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1807        let result = rule.check(&ctx).unwrap();
1808        assert!(
1809            result.is_empty(),
1810            "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1811        );
1812    }
1813
1814    /// Pandoc raw blocks like ```` ```{=html} ```` declare an output target,
1815    /// not a missing language. MD040 must accept them under Pandoc.
1816    #[test]
1817    fn test_pandoc_accepts_raw_html_block() {
1818        use crate::config::MarkdownFlavor;
1819        let rule = MD040FencedCodeLanguage::default();
1820        let content = "```{=html}\n<div>raw</div>\n```\n";
1821        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1822        let result = rule.check(&ctx).unwrap();
1823        assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1824    }
1825
1826    /// Under Pandoc (not Quarto), `{r}` is NOT a valid raw-format declaration —
1827    /// it's a Quarto-only execution syntax that should be flagged as missing language.
1828    #[test]
1829    fn test_pandoc_rejects_quarto_exec_blocks() {
1830        use crate::config::MarkdownFlavor;
1831        let rule = MD040FencedCodeLanguage::default();
1832        let content = "```{r}\nsummary(data)\n```\n";
1833        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1834        let result = rule.check(&ctx).unwrap();
1835        assert!(
1836            !result.is_empty(),
1837            "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1838        );
1839    }
1840
1841    /// Under Quarto, `{r}` IS valid — Quarto exec syntax. Must not be flagged.
1842    #[test]
1843    fn test_quarto_still_accepts_exec_block() {
1844        use crate::config::MarkdownFlavor;
1845        let rule = MD040FencedCodeLanguage::default();
1846        let content = "```{r}\nsummary(data)\n```\n";
1847        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1848        let result = rule.check(&ctx).unwrap();
1849        assert!(
1850            result.is_empty(),
1851            "MD040 under Quarto should accept `{{r}}`: {result:?}"
1852        );
1853    }
1854
1855    #[test]
1856    fn test_quarto_exec_block_skipped_under_quarto_only() {
1857        // ```{r} exec chunks are Quarto-specific syntax accepted only under the Quarto flavor.
1858        // Under Pandoc flavor, `{r}` is not a valid Pandoc raw-format declaration (those use
1859        // `{=format}` syntax), so MD040 flags it as missing a real language identifier.
1860        let rule = MD040FencedCodeLanguage::default();
1861        let content = "```{r}\n1 + 1\n```\n";
1862
1863        let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1864        let result_quarto = rule.check(&ctx_quarto).unwrap();
1865        assert!(
1866            result_quarto.is_empty(),
1867            "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1868        );
1869
1870        // Under Pandoc, `{r}` is unrecognized brace syntax — not a valid Pandoc raw block.
1871        // MD040 treats it as a missing language.
1872        let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1873        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1874        assert!(
1875            !result_pandoc.is_empty(),
1876            "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1877        );
1878    }
1879
1880    /// Pandoc code-attribute syntax `{.lang}` declares the language and is valid under
1881    /// both Pandoc and Quarto. MD040 must accept it.
1882    #[test]
1883    fn test_pandoc_class_attr_accepted_as_language() {
1884        use crate::config::MarkdownFlavor;
1885        let rule = MD040FencedCodeLanguage::default();
1886        let content = "```{.python}\nprint(\"hi\")\n```\n";
1887
1888        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1889        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1890        assert!(
1891            result_pandoc.is_empty(),
1892            "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1893        );
1894
1895        let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1896        let result_quarto = rule.check(&ctx_quarto).unwrap();
1897        assert!(
1898            result_quarto.is_empty(),
1899            "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1900        );
1901    }
1902
1903    /// Pandoc code attributes can include multiple classes plus key=value pairs.
1904    /// The first class is the language; trailing attributes (e.g. `.numberLines`) are decoration.
1905    #[test]
1906    fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1907        use crate::config::MarkdownFlavor;
1908        let rule = MD040FencedCodeLanguage::default();
1909        let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1910
1911        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1912        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1913        assert!(
1914            result_pandoc.is_empty(),
1915            "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1916        );
1917
1918        let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1919        let result_quarto = rule.check(&ctx_quarto).unwrap();
1920        assert!(
1921            result_quarto.is_empty(),
1922            "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1923        );
1924    }
1925
1926    /// Pandoc code attributes can include id (`#myid`) and key=value attributes.
1927    /// As long as a `.class` is present, the block declares a language.
1928    #[test]
1929    fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1930        use crate::config::MarkdownFlavor;
1931        let rule = MD040FencedCodeLanguage::default();
1932        let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1933
1934        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1935        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1936        assert!(
1937            result_pandoc.is_empty(),
1938            "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1939        );
1940    }
1941
1942    /// Standard flavor knows nothing about Pandoc code attributes — they remain
1943    /// unrecognized brace syntax and must still be flagged as missing-language.
1944    #[test]
1945    fn test_standard_still_flags_pandoc_class_attr() {
1946        use crate::config::MarkdownFlavor;
1947        let rule = MD040FencedCodeLanguage::default();
1948        let content = "```{.python}\nprint(\"hi\")\n```\n";
1949
1950        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1951        let result_standard = rule.check(&ctx_standard).unwrap();
1952        assert!(
1953            !result_standard.is_empty(),
1954            "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1955        );
1956    }
1957
1958    /// A brace block with only an id (`{#myid}`) and no class declares no language.
1959    /// Even under Pandoc this must remain flagged.
1960    #[test]
1961    fn test_pandoc_id_only_attr_still_flagged() {
1962        use crate::config::MarkdownFlavor;
1963        let rule = MD040FencedCodeLanguage::default();
1964        let content = "```{#myid}\ncode here\n```\n";
1965
1966        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1967        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1968        assert!(
1969            !result_pandoc.is_empty(),
1970            "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1971        );
1972    }
1973
1974    /// Empty `{}` braces declare nothing and must still be flagged under any flavor.
1975    #[test]
1976    fn test_pandoc_empty_braces_still_flagged() {
1977        use crate::config::MarkdownFlavor;
1978        let rule = MD040FencedCodeLanguage::default();
1979        let content = "```{}\ncode here\n```\n";
1980
1981        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1982        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1983        assert!(
1984            !result_pandoc.is_empty(),
1985            "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1986        );
1987    }
1988}