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