Skip to main content

rumdl_lib/rules/
md040_fenced_code_language.rs

1use crate::linguist_data::{default_alias, get_aliases, is_valid_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    /// Validate the configuration and return any errors
56    fn validate_config(&self) -> Vec<String> {
57        let mut errors = Vec::new();
58
59        // Validate preferred-aliases: check that each alias is valid for its language
60        for (canonical, alias) in &self.config.preferred_aliases {
61            // Find the actual canonical name (case-insensitive)
62            if let Some(actual_canonical) = resolve_canonical(canonical) {
63                if !is_valid_alias(actual_canonical, alias)
64                    && let Some(valid_aliases) = get_aliases(actual_canonical)
65                {
66                    let valid_list: Vec<_> = valid_aliases.iter().take(5).collect();
67                    let valid_str = valid_list
68                        .iter()
69                        .map(|s| format!("'{s}'"))
70                        .collect::<Vec<_>>()
71                        .join(", ");
72                    let suffix = if valid_aliases.len() > 5 { ", ..." } else { "" };
73                    errors.push(format!(
74                        "Invalid alias '{alias}' for language '{actual_canonical}'. Valid aliases include: {valid_str}{suffix}"
75                    ));
76                }
77            } else {
78                errors.push(format!(
79                    "Unknown language '{canonical}' in preferred-aliases. Use GitHub Linguist canonical names."
80                ));
81            }
82        }
83
84        errors
85    }
86
87    /// Determine the preferred label for each canonical language in the document
88    fn compute_preferred_labels(
89        &self,
90        blocks: &[FencedCodeBlock],
91        disabled_ranges: &[(usize, usize)],
92    ) -> HashMap<String, String> {
93        // Group labels by canonical language
94        let mut by_canonical: HashMap<String, Vec<&str>> = HashMap::new();
95
96        for block in blocks {
97            if is_line_disabled(disabled_ranges, block.line_idx) {
98                continue;
99            }
100            if block.language.is_empty() {
101                continue;
102            }
103            if let Some(canonical) = resolve_canonical(&block.language) {
104                by_canonical
105                    .entry(canonical.to_string())
106                    .or_default()
107                    .push(&block.language);
108            }
109        }
110
111        // Determine winning label for each canonical language
112        let mut result = HashMap::new();
113
114        for (canonical, labels) in by_canonical {
115            // Check for user override first (case-insensitive lookup)
116            let winner = if let Some(preferred) = self
117                .config
118                .preferred_aliases
119                .iter()
120                .find(|(k, _)| k.eq_ignore_ascii_case(&canonical))
121                .map(|(_, v)| v.clone())
122            {
123                preferred
124            } else {
125                // Find most prevalent label
126                let mut counts: HashMap<&str, usize> = HashMap::new();
127                for label in &labels {
128                    *counts.entry(*label).or_default() += 1;
129                }
130
131                let max_count = counts.values().max().copied().unwrap_or(0);
132                let winners: Vec<_> = counts
133                    .iter()
134                    .filter(|(_, c)| **c == max_count)
135                    .map(|(l, _)| *l)
136                    .collect();
137
138                if winners.len() == 1 {
139                    winners[0].to_string()
140                } else {
141                    // Tie-break: use curated default if available, otherwise alphabetically first
142                    default_alias(&canonical)
143                        .filter(|default| winners.contains(default))
144                        .map_or_else(
145                            || winners.into_iter().min().unwrap().to_string(),
146                            std::string::ToString::to_string,
147                        )
148                }
149            };
150
151            result.insert(canonical, winner);
152        }
153
154        result
155    }
156
157    /// Check if a language is allowed based on config
158    fn check_language_allowed(&self, canonical: Option<&str>, original_label: &str) -> Option<String> {
159        // Allowlist takes precedence
160        if !self.config.allowed_languages.is_empty() {
161            let allowed = self.config.allowed_languages.join(", ");
162            let Some(canonical) = canonical else {
163                return Some(format!(
164                    "Language '{original_label}' is not in the allowed list: {allowed}"
165                ));
166            };
167            if !self
168                .config
169                .allowed_languages
170                .iter()
171                .any(|a| a.eq_ignore_ascii_case(canonical))
172            {
173                return Some(format!(
174                    "Language '{original_label}' ({canonical}) is not in the allowed list: {allowed}"
175                ));
176            }
177        } else if !self.config.disallowed_languages.is_empty()
178            && canonical.is_some_and(|canonical| {
179                self.config
180                    .disallowed_languages
181                    .iter()
182                    .any(|d| d.eq_ignore_ascii_case(canonical))
183            })
184        {
185            let canonical = canonical.unwrap_or("unknown");
186            return Some(format!("Language '{original_label}' ({canonical}) is disallowed"));
187        }
188        None
189    }
190
191    /// Check for unknown language based on config
192    fn check_unknown_language(&self, label: &str) -> Option<(String, Severity)> {
193        // GitHub accepts names, aliases, AND file extensions as fence labels
194        // (```pytb highlights via the .pytb extension), so the unknown check
195        // consults the full accept-set, not just resolvable aliases.
196        if crate::linguist_data::is_known_language(label) {
197            return None;
198        }
199
200        match self.config.unknown_language_action {
201            UnknownLanguageAction::Ignore => None,
202            UnknownLanguageAction::Warn => Some((
203                format!("Unknown language '{label}' (not in GitHub Linguist). Syntax highlighting may not work."),
204                Severity::Warning,
205            )),
206            UnknownLanguageAction::Error => Some((
207                format!("Unknown language '{label}' (not in GitHub Linguist)"),
208                Severity::Error,
209            )),
210        }
211    }
212}
213
214impl Rule for MD040FencedCodeLanguage {
215    fn name(&self) -> &'static str {
216        "MD040"
217    }
218
219    fn description(&self) -> &'static str {
220        "Code blocks should have a language specified"
221    }
222
223    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
224        let content = ctx.content;
225        let mut warnings = Vec::new();
226
227        // Validate config and emit warnings for invalid configuration
228        for error in self.validate_config() {
229            warnings.push(LintWarning {
230                rule_name: Some(self.name().to_string()),
231                line: 1,
232                column: 1,
233                end_line: 1,
234                end_column: 1,
235                message: format!("[config error] {error}"),
236                severity: Severity::Error,
237                fix: None,
238            });
239        }
240
241        // Derive fenced code blocks from pre-computed context
242        let fenced_blocks = derive_fenced_code_blocks(ctx);
243
244        // Pre-compute disabled ranges for efficient lookup
245        let disabled_ranges = compute_disabled_ranges(content, self.name());
246
247        // Compute preferred labels for consistent mode
248        let preferred_labels = if self.config.style == LanguageStyle::Consistent {
249            self.compute_preferred_labels(&fenced_blocks, &disabled_ranges)
250        } else {
251            HashMap::new()
252        };
253
254        let lines = ctx.raw_lines();
255
256        for block in &fenced_blocks {
257            // Skip if this line is in a disabled range
258            if is_line_disabled(&disabled_ranges, block.line_idx) {
259                continue;
260            }
261
262            // Get the actual line content for additional checks. Strip any
263            // blockquote prefix so the info string after the fence is recognized
264            // inside blockquotes the same way it is at the top level.
265            let line = lines.get(block.line_idx).unwrap_or(&"");
266            let fence_line = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
267            let after_fence = fence_line.strip_prefix(&block.fence_marker).unwrap_or("").trim();
268
269            // Check if fence has MkDocs superfences attributes but no language
270            let has_mkdocs_attrs_only =
271                ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
272
273            // MyST directives use {name} as the info string (e.g., {note}, {code-cell} python).
274            // These are valid MyST syntax and should not trigger missing-language warnings.
275            let is_myst_directive =
276                ctx.flavor.supports_myst_directives() && after_fence.starts_with('{') && after_fence.contains('}') && {
277                    let name = after_fence.trim_start_matches('{').split('}').next().unwrap_or("");
278                    !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
279                };
280
281            // Pandoc/Quarto brace-syntax code chunks fall into three forms:
282            //   1. `{=html}` raw blocks — accepted under any Pandoc-compatible flavor.
283            //      Validated by `is_pandoc_raw_block_lang` (non-empty ASCII format name).
284            //   2. `{.python}` / `{.haskell .numberLines}` code-attribute syntax — the
285            //      first `.class` declares the language. Accepted under any
286            //      Pandoc-compatible flavor.
287            //   3. `{r}` / `{python}` exec chunks — accepted under Quarto only.
288            // Anything else wrapped in braces (e.g. `{r}` under pure Pandoc, or
289            // `{#myid}` with no class) is not a real language identifier and must be
290            // flagged as missing-language.
291            let is_pandoc_raw =
292                ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(after_fence);
293            let is_pandoc_class_attr =
294                ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(after_fence);
295            let is_quarto_exec = ctx.flavor == crate::config::MarkdownFlavor::Quarto
296                && after_fence.starts_with('{')
297                && after_fence.ends_with('}')
298                && !is_pandoc_raw
299                && !is_pandoc_class_attr;
300            let has_pandoc_or_quarto_syntax = is_pandoc_raw || is_pandoc_class_attr || is_quarto_exec;
301            let is_unrecognized_brace_syntax = after_fence.starts_with('{')
302                && after_fence.ends_with('}')
303                && !has_pandoc_or_quarto_syntax
304                && !is_myst_directive;
305
306            let needs_language = !has_mkdocs_attrs_only
307                && !is_myst_directive
308                && (block.language.is_empty()
309                    || is_superfences_attribute(&block.language)
310                    || is_unrecognized_brace_syntax);
311
312            if needs_language && !has_pandoc_or_quarto_syntax {
313                let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
314
315                warnings.push(LintWarning {
316                    rule_name: Some(self.name().to_string()),
317                    line: start_line,
318                    column: start_col,
319                    end_line,
320                    end_column: end_col,
321                    message: "Code block (```) missing language".to_string(),
322                    severity: Severity::Warning,
323                    fix: Some(Fix::new(
324                        {
325                            let marker_offset = fence_marker_offset(line);
326                            let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
327                            let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
328                            // Replace from after fence marker to end of line content,
329                            // so trailing whitespace is cleaned up while any existing
330                            // info string / attributes are preserved via the replacement.
331                            let line_end_byte = line_start_byte + line.len();
332                            fence_end_byte..line_end_byte
333                        },
334                        {
335                            let line: &str = line;
336                            let after_fence = &line[fence_marker_offset(line) + block.fence_marker.len()..];
337                            let after_fence_trimmed = after_fence.trim();
338                            if after_fence_trimmed.is_empty() {
339                                "text".to_string()
340                            } else {
341                                format!("text {after_fence_trimmed}")
342                            }
343                        },
344                    )),
345                });
346                continue;
347            }
348
349            // Skip further checks for Pandoc raw blocks and Quarto exec chunks
350            if has_pandoc_or_quarto_syntax {
351                continue;
352            }
353
354            let canonical = resolve_canonical(&block.language);
355
356            // Check language restrictions (allowlist/denylist)
357            if let Some(msg) = self.check_language_allowed(canonical, &block.language) {
358                let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
359
360                warnings.push(LintWarning {
361                    rule_name: Some(self.name().to_string()),
362                    line: start_line,
363                    column: start_col,
364                    end_line,
365                    end_column: end_col,
366                    message: msg,
367                    severity: Severity::Warning,
368                    fix: None,
369                });
370                continue;
371            }
372
373            // Check for unknown language (only if not handled by allowlist)
374            if canonical.is_none() {
375                if let Some((msg, severity)) = self.check_unknown_language(&block.language) {
376                    let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
377
378                    warnings.push(LintWarning {
379                        rule_name: Some(self.name().to_string()),
380                        line: start_line,
381                        column: start_col,
382                        end_line,
383                        end_column: end_col,
384                        message: msg,
385                        severity,
386                        fix: None,
387                    });
388                }
389                continue;
390            }
391
392            // Check consistency
393            if self.config.style == LanguageStyle::Consistent
394                && let Some(preferred) = preferred_labels.get(canonical.unwrap())
395                && &block.language != preferred
396            {
397                let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
398
399                let fix = find_label_span(line, &block.fence_marker).map(|(label_start, label_end)| {
400                    let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
401                    Fix::new(
402                        (line_start_byte + label_start)..(line_start_byte + label_end),
403                        preferred.clone(),
404                    )
405                });
406                let lang = &block.language;
407                let canonical = canonical.unwrap();
408
409                warnings.push(LintWarning {
410                    rule_name: Some(self.name().to_string()),
411                    line: start_line,
412                    column: start_col,
413                    end_line,
414                    end_column: end_col,
415                    message: format!("Inconsistent language label '{lang}' for {canonical} (use '{preferred}')"),
416                    severity: Severity::Warning,
417                    fix,
418                });
419            }
420        }
421
422        Ok(warnings)
423    }
424
425    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
426        if self.should_skip(ctx) {
427            return Ok(ctx.content.to_string());
428        }
429        let warnings = self.check(ctx)?;
430        if warnings.is_empty() {
431            return Ok(ctx.content.to_string());
432        }
433        let warnings =
434            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
435        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
436    }
437
438    /// Get the category of this rule for selective processing
439    fn category(&self) -> RuleCategory {
440        RuleCategory::CodeBlock
441    }
442
443    /// Check if this rule should be skipped
444    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
445        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
446    }
447
448    fn as_any(&self) -> &dyn std::any::Any {
449        self
450    }
451
452    fn default_config_section(&self) -> Option<(String, toml::Value)> {
453        let default_config = MD040Config::default();
454        let json_value = serde_json::to_value(&default_config).ok()?;
455        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
456
457        if let toml::Value::Table(table) = toml_value {
458            if !table.is_empty() {
459                Some((MD040Config::RULE_NAME.to_string(), toml::Value::Table(table)))
460            } else {
461                None
462            }
463        } else {
464            None
465        }
466    }
467
468    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
469    where
470        Self: Sized,
471    {
472        let rule_config: MD040Config = load_rule_config(config);
473        Box::new(MD040FencedCodeLanguage::with_config(rule_config))
474    }
475}
476
477/// Derive fenced code blocks from pre-computed CodeBlockDetail data
478fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
479    let content = ctx.content;
480    let line_offsets = &ctx.line_offsets;
481
482    ctx.code_block_details
483        .iter()
484        .filter(|d| d.is_fenced)
485        .map(|detail| {
486            let line_idx = match line_offsets.binary_search(&detail.start) {
487                Ok(idx) => idx,
488                Err(idx) => idx.saturating_sub(1),
489            };
490
491            // Determine fence marker from the actual line content
492            let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
493            let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
494            let line = content.get(line_start..line_end).unwrap_or("");
495            // Strip any blockquote prefix (`> `) before measuring the fence so
496            // markers inside blockquotes are detected by their actual length.
497            let trimmed = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
498            let fence_marker = if trimmed.starts_with('`') {
499                let count = trimmed.chars().take_while(|&c| c == '`').count();
500                "`".repeat(count)
501            } else if trimmed.starts_with('~') {
502                let count = trimmed.chars().take_while(|&c| c == '~').count();
503                "~".repeat(count)
504            } else {
505                "```".to_string()
506            };
507
508            let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
509
510            FencedCodeBlock {
511                line_idx,
512                language,
513                fence_marker,
514            }
515        })
516        .collect()
517}
518
519/// Compute disabled line ranges from disable/enable comments
520fn compute_disabled_ranges(content: &str, rule_name: &str) -> Vec<(usize, usize)> {
521    let mut ranges = Vec::new();
522    let mut disabled_start: Option<usize> = None;
523
524    for (i, line) in content.lines().enumerate() {
525        let trimmed = line.trim();
526
527        if let Some(rules) = crate::inline_config::parse_disable_comment(trimmed)
528            && (rules.is_empty() || rules.contains(&rule_name))
529            && disabled_start.is_none()
530        {
531            disabled_start = Some(i);
532        }
533
534        if let Some(rules) = crate::inline_config::parse_enable_comment(trimmed)
535            && (rules.is_empty() || rules.contains(&rule_name))
536            && let Some(start) = disabled_start.take()
537        {
538            ranges.push((start, i));
539        }
540    }
541
542    // Handle unclosed disable
543    if let Some(start) = disabled_start {
544        ranges.push((start, usize::MAX));
545    }
546
547    ranges
548}
549
550/// Check if a line index is within a disabled range
551fn is_line_disabled(ranges: &[(usize, usize)], line_idx: usize) -> bool {
552    ranges.iter().any(|&(start, end)| line_idx >= start && line_idx < end)
553}
554
555/// Byte offset within `line` where the fence marker begins.
556///
557/// Accounts for an optional blockquote prefix (`>`, `> >`, `>>`, etc.) followed
558/// by indentation. For a plain or list-indented fence the blockquote prefix is
559/// empty, so this reduces to the leading-whitespace length.
560fn fence_marker_offset(line: &str) -> usize {
561    let content = crate::utils::blockquote::strip_blockquote_prefix(line);
562    let blockquote_prefix_len = line.len() - content.len();
563    let indent_len = content.len() - content.trim_start().len();
564    blockquote_prefix_len + indent_len
565}
566
567/// Find the byte span of the language label in a fence line.
568fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
569    let marker_offset = fence_marker_offset(line);
570    let after_indent = &line[marker_offset..];
571    if !after_indent.starts_with(fence_marker) {
572        return None;
573    }
574    let after_fence = &after_indent[fence_marker.len()..];
575
576    let label_start_rel = after_fence
577        .char_indices()
578        .find(|&(_, ch)| !ch.is_whitespace())
579        .map(|(idx, _)| idx)?;
580    let after_label = &after_fence[label_start_rel..];
581    let label_end_rel = after_label
582        .char_indices()
583        .find(|&(_, ch)| ch.is_whitespace())
584        .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
585
586    Some((
587        marker_offset + fence_marker.len() + label_start_rel,
588        marker_offset + fence_marker.len() + label_end_rel,
589    ))
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::lint_context::LintContext;
596
597    fn run_check(content: &str) -> LintResult {
598        let rule = MD040FencedCodeLanguage::default();
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600        rule.check(&ctx)
601    }
602
603    fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
604        let rule = MD040FencedCodeLanguage::with_config(config);
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606        rule.check(&ctx)
607    }
608
609    fn run_fix(content: &str) -> Result<String, LintError> {
610        let rule = MD040FencedCodeLanguage::default();
611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612        rule.fix(&ctx)
613    }
614
615    fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
616        let rule = MD040FencedCodeLanguage::with_config(config);
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618        rule.fix(&ctx)
619    }
620
621    fn run_check_mkdocs(content: &str) -> LintResult {
622        let rule = MD040FencedCodeLanguage::default();
623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
624        rule.check(&ctx)
625    }
626
627    // =========================================================================
628    // Basic functionality tests
629    // =========================================================================
630
631    #[test]
632    fn test_code_blocks_with_language_specified() {
633        let content = r#"# Test
634
635```python
636print("Hello, world!")
637```
638
639```javascript
640console.log("Hello!");
641```
642"#;
643        let result = run_check(content).unwrap();
644        assert!(result.is_empty(), "No warnings expected for code blocks with language");
645    }
646
647    #[test]
648    fn test_code_blocks_without_language() {
649        let content = r#"# Test
650
651```
652print("Hello, world!")
653```
654"#;
655        let result = run_check(content).unwrap();
656        assert_eq!(result.len(), 1);
657        assert_eq!(result[0].message, "Code block (```) missing language");
658        assert_eq!(result[0].line, 3);
659    }
660
661    #[test]
662    fn test_fix_method_adds_text_language() {
663        let content = r#"# Test
664
665```
666code without language
667```
668
669```python
670already has language
671```
672
673```
674another block without
675```
676"#;
677        let fixed = run_fix(content).unwrap();
678        assert!(fixed.contains("```text"));
679        assert!(fixed.contains("```python"));
680        assert_eq!(fixed.matches("```text").count(), 2);
681    }
682
683    #[test]
684    fn test_fix_preserves_indentation() {
685        let content = r#"# Test
686
687- List item
688  ```
689  indented code block
690  ```
691"#;
692        let fixed = run_fix(content).unwrap();
693        assert!(fixed.contains("  ```text"));
694    }
695
696    #[test]
697    fn test_fix_blockquote_empty_fence() {
698        // An empty fence inside a blockquote must become a valid `> ```text`
699        // fence, not a corrupted `> `text `` inline span. MD040 only touches the
700        // fence lines, so the indented content is preserved verbatim.
701        let content = "# Title\n\n> ```\n> root/\n> └── nested/\n>     └── file.txt\n> ```\n";
702        let fixed = run_fix(content).unwrap();
703        let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n>     └── file.txt\n> ```\n";
704        assert_eq!(fixed, expected);
705    }
706
707    #[test]
708    fn test_fix_blockquote_tilde_and_longer_fences() {
709        // Tilde fences and fences longer than three characters inside a
710        // blockquote must be detected by their actual marker, not the default.
711        let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
712        assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
713
714        let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
715        assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
716
717        let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
718        assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
719    }
720
721    #[test]
722    fn test_fix_nested_blockquote_empty_fence() {
723        // Compact and spaced nested blockquotes both carry their prefix into the
724        // fence line; the fix must place `text` after the real fence marker.
725        let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
726        assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
727
728        let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
729        assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
730    }
731
732    #[test]
733    fn test_fix_blockquote_empty_fence_is_idempotent() {
734        // Re-running the fix on its own output must be a no-op.
735        let content = "> ```\n> root/\n>     nested\n> ```\n";
736        let once = run_fix(content).unwrap();
737        let twice = run_fix(&once).unwrap();
738        assert_eq!(once, twice);
739        assert_eq!(once, "> ```text\n> root/\n>     nested\n> ```\n");
740    }
741
742    // =========================================================================
743    // Consistent mode tests
744    // =========================================================================
745
746    #[test]
747    fn test_consistent_mode_detects_inconsistency() {
748        let content = r#"```bash
749echo hi
750```
751
752```sh
753echo there
754```
755
756```bash
757echo again
758```
759"#;
760        let config = MD040Config {
761            style: LanguageStyle::Consistent,
762            ..Default::default()
763        };
764        let result = run_check_with_config(content, config).unwrap();
765        assert_eq!(result.len(), 1);
766        assert!(result[0].message.contains("Inconsistent"));
767        assert!(result[0].message.contains("sh"));
768        assert!(result[0].message.contains("bash"));
769    }
770
771    #[test]
772    fn test_consistent_mode_fix_normalizes() {
773        let content = r#"```bash
774echo hi
775```
776
777```sh
778echo there
779```
780
781```bash
782echo again
783```
784"#;
785        let config = MD040Config {
786            style: LanguageStyle::Consistent,
787            ..Default::default()
788        };
789        let fixed = run_fix_with_config(content, config).unwrap();
790        assert_eq!(fixed.matches("```bash").count(), 3);
791        assert_eq!(fixed.matches("```sh").count(), 0);
792    }
793
794    #[test]
795    fn test_consistent_mode_tie_break_uses_curated_default() {
796        // When there's a tie (1 bash, 1 sh), should use curated default (bash)
797        let content = r#"```bash
798echo hi
799```
800
801```sh
802echo there
803```
804"#;
805        let config = MD040Config {
806            style: LanguageStyle::Consistent,
807            ..Default::default()
808        };
809        let fixed = run_fix_with_config(content, config).unwrap();
810        // bash is the curated default for Shell
811        assert_eq!(fixed.matches("```bash").count(), 2);
812    }
813
814    #[test]
815    fn test_consistent_mode_with_preferred_alias() {
816        let content = r#"```bash
817echo hi
818```
819
820```sh
821echo there
822```
823"#;
824        let mut preferred = HashMap::new();
825        preferred.insert("Shell".to_string(), "sh".to_string());
826
827        let config = MD040Config {
828            style: LanguageStyle::Consistent,
829            preferred_aliases: preferred,
830            ..Default::default()
831        };
832        let fixed = run_fix_with_config(content, config).unwrap();
833        assert_eq!(fixed.matches("```sh").count(), 2);
834        assert_eq!(fixed.matches("```bash").count(), 0);
835    }
836
837    #[test]
838    fn test_consistent_mode_fix_inside_blockquote() {
839        // Consistent-mode normalization must reach fences inside blockquotes.
840        // With one `bash` and one `sh`, the curated default `bash` wins.
841        let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
842        let config = MD040Config {
843            style: LanguageStyle::Consistent,
844            ..Default::default()
845        };
846        let fixed = run_fix_with_config(content, config).unwrap();
847        assert_eq!(
848            fixed,
849            "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
850        );
851    }
852
853    #[test]
854    fn test_consistent_mode_ignores_disabled_blocks() {
855        let content = r#"```bash
856echo hi
857```
858<!-- rumdl-disable MD040 -->
859```sh
860echo there
861```
862```sh
863echo again
864```
865<!-- rumdl-enable MD040 -->
866"#;
867        let config = MD040Config {
868            style: LanguageStyle::Consistent,
869            ..Default::default()
870        };
871        let result = run_check_with_config(content, config).unwrap();
872        assert!(result.is_empty(), "Disabled blocks should not affect consistency");
873    }
874
875    #[test]
876    fn test_fix_preserves_attributes() {
877        let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
878        let config = MD040Config {
879            style: LanguageStyle::Consistent,
880            ..Default::default()
881        };
882        let fixed = run_fix_with_config(content, config).unwrap();
883        assert!(fixed.contains("```bash {.highlight}"));
884    }
885
886    #[test]
887    fn test_fix_preserves_spacing_before_label() {
888        let content = "```bash\ncode\n```\n\n```  sh {.highlight}\ncode\n```";
889        let config = MD040Config {
890            style: LanguageStyle::Consistent,
891            ..Default::default()
892        };
893        let fixed = run_fix_with_config(content, config).unwrap();
894        assert!(fixed.contains("```  bash {.highlight}"));
895        assert!(!fixed.contains("```  sh {.highlight}"));
896    }
897
898    // =========================================================================
899    // Allowlist/denylist tests
900    // =========================================================================
901
902    #[test]
903    fn test_allowlist_blocks_unlisted() {
904        let content = "```java\ncode\n```";
905        let config = MD040Config {
906            allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
907            ..Default::default()
908        };
909        let result = run_check_with_config(content, config).unwrap();
910        assert_eq!(result.len(), 1);
911        assert!(result[0].message.contains("not in the allowed list"));
912    }
913
914    #[test]
915    fn test_allowlist_allows_listed() {
916        let content = "```python\ncode\n```";
917        let config = MD040Config {
918            allowed_languages: vec!["Python".to_string()],
919            ..Default::default()
920        };
921        let result = run_check_with_config(content, config).unwrap();
922        assert!(result.is_empty());
923    }
924
925    #[test]
926    fn test_allowlist_blocks_unknown_language() {
927        let content = "```mysterylang\ncode\n```";
928        let config = MD040Config {
929            allowed_languages: vec!["Python".to_string()],
930            ..Default::default()
931        };
932        let result = run_check_with_config(content, config).unwrap();
933        assert_eq!(result.len(), 1);
934        assert!(result[0].message.contains("allowed list"));
935    }
936
937    #[test]
938    fn test_allowlist_case_insensitive() {
939        let content = "```python\ncode\n```";
940        let config = MD040Config {
941            allowed_languages: vec!["PYTHON".to_string()],
942            ..Default::default()
943        };
944        let result = run_check_with_config(content, config).unwrap();
945        assert!(result.is_empty());
946    }
947
948    #[test]
949    fn test_denylist_blocks_listed() {
950        let content = "```java\ncode\n```";
951        let config = MD040Config {
952            disallowed_languages: vec!["Java".to_string()],
953            ..Default::default()
954        };
955        let result = run_check_with_config(content, config).unwrap();
956        assert_eq!(result.len(), 1);
957        assert!(result[0].message.contains("disallowed"));
958    }
959
960    #[test]
961    fn test_denylist_allows_unlisted() {
962        let content = "```python\ncode\n```";
963        let config = MD040Config {
964            disallowed_languages: vec!["Java".to_string()],
965            ..Default::default()
966        };
967        let result = run_check_with_config(content, config).unwrap();
968        assert!(result.is_empty());
969    }
970
971    #[test]
972    fn test_allowlist_takes_precedence_over_denylist() {
973        let content = "```python\ncode\n```";
974        let config = MD040Config {
975            allowed_languages: vec!["Python".to_string()],
976            disallowed_languages: vec!["Python".to_string()], // Should be ignored
977            ..Default::default()
978        };
979        let result = run_check_with_config(content, config).unwrap();
980        assert!(result.is_empty());
981    }
982
983    // =========================================================================
984    // Unknown language tests
985    // =========================================================================
986
987    #[test]
988    fn test_unknown_language_ignore_default() {
989        let content = "```mycustomlang\ncode\n```";
990        let result = run_check(content).unwrap();
991        assert!(result.is_empty(), "Unknown languages ignored by default");
992    }
993
994    #[test]
995    fn test_unknown_language_warn() {
996        let content = "```mycustomlang\ncode\n```";
997        let config = MD040Config {
998            unknown_language_action: UnknownLanguageAction::Warn,
999            ..Default::default()
1000        };
1001        let result = run_check_with_config(content, config).unwrap();
1002        assert_eq!(result.len(), 1);
1003        assert!(result[0].message.contains("Unknown language"));
1004        assert!(result[0].message.contains("mycustomlang"));
1005        assert_eq!(result[0].severity, Severity::Warning);
1006    }
1007
1008    /// Regression test for a category of bugs, not one instance: rumdl's generated
1009    /// Linguist alias table must recognize every high-traffic language alias, not
1010    /// just the exact one reported (`py`). Each alias below is confirmed present in
1011    /// GitHub Linguist's current `aliases:` list for its language (`py` and `py3`
1012    /// were added upstream after the `e51c2270` generation pin, see the header
1013    /// comment in `src/linguist_data.rs`); none of them should ever trigger an
1014    /// unknown-language warning.
1015    #[test]
1016    fn test_unknown_language_warn_known_aliases_not_flagged() {
1017        let known_aliases = [
1018            "py",
1019            "python",
1020            "sh",
1021            "bash",
1022            "shell",
1023            "zsh",
1024            "js",
1025            "javascript",
1026            "ts",
1027            "typescript",
1028            "rb",
1029            "ruby",
1030            "rs",
1031            "rust",
1032            "yml",
1033            "yaml",
1034            "cpp",
1035            "c++",
1036            "csharp",
1037            "golang",
1038            "dockerfile",
1039            "jsonc",
1040            "kotlin",
1041        ];
1042        for alias in known_aliases {
1043            let content = format!("```{alias}\ncode\n```");
1044            let config = MD040Config {
1045                unknown_language_action: UnknownLanguageAction::Warn,
1046                ..Default::default()
1047            };
1048            let result = run_check_with_config(&content, config).unwrap();
1049            assert!(
1050                result.is_empty(),
1051                "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1052            );
1053        }
1054    }
1055
1056    /// GitHub also accepts file extensions as fence labels (a ```pytb block
1057    /// renders with the Python-traceback grammar even though `pytb` appears
1058    /// only in Linguist's `extensions:`, never `aliases:`), so extension
1059    /// labels must not be flagged as unknown either.
1060    #[test]
1061    fn test_unknown_language_extension_labels_not_flagged() {
1062        for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
1063            let content = format!("```{label}\ncode\n```");
1064            let config = MD040Config {
1065                unknown_language_action: UnknownLanguageAction::Error,
1066                ..Default::default()
1067            };
1068            let result = run_check_with_config(&content, config).unwrap();
1069            assert!(
1070                result.is_empty(),
1071                "Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
1072            );
1073        }
1074    }
1075
1076    #[test]
1077    fn test_unknown_language_error() {
1078        let content = "```mycustomlang\ncode\n```";
1079        let config = MD040Config {
1080            unknown_language_action: UnknownLanguageAction::Error,
1081            ..Default::default()
1082        };
1083        let result = run_check_with_config(content, config).unwrap();
1084        assert_eq!(result.len(), 1);
1085        assert!(result[0].message.contains("Unknown language"));
1086        assert_eq!(result[0].severity, Severity::Error);
1087    }
1088
1089    // =========================================================================
1090    // Config validation tests
1091    // =========================================================================
1092
1093    #[test]
1094    fn test_invalid_preferred_alias_detected() {
1095        let mut preferred = HashMap::new();
1096        preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1097
1098        let config = MD040Config {
1099            style: LanguageStyle::Consistent,
1100            preferred_aliases: preferred,
1101            ..Default::default()
1102        };
1103        let rule = MD040FencedCodeLanguage::with_config(config);
1104        let errors = rule.validate_config();
1105        assert_eq!(errors.len(), 1);
1106        assert!(errors[0].contains("Invalid alias"));
1107        assert!(errors[0].contains("invalid_alias"));
1108    }
1109
1110    #[test]
1111    fn test_unknown_language_in_preferred_aliases_detected() {
1112        let mut preferred = HashMap::new();
1113        preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1114
1115        let config = MD040Config {
1116            style: LanguageStyle::Consistent,
1117            preferred_aliases: preferred,
1118            ..Default::default()
1119        };
1120        let rule = MD040FencedCodeLanguage::with_config(config);
1121        let errors = rule.validate_config();
1122        assert_eq!(errors.len(), 1);
1123        assert!(errors[0].contains("Unknown language"));
1124    }
1125
1126    #[test]
1127    fn test_valid_preferred_alias_accepted() {
1128        let mut preferred = HashMap::new();
1129        preferred.insert("Shell".to_string(), "bash".to_string());
1130        preferred.insert("JavaScript".to_string(), "js".to_string());
1131
1132        let config = MD040Config {
1133            style: LanguageStyle::Consistent,
1134            preferred_aliases: preferred,
1135            ..Default::default()
1136        };
1137        let rule = MD040FencedCodeLanguage::with_config(config);
1138        let errors = rule.validate_config();
1139        assert!(errors.is_empty());
1140    }
1141
1142    #[test]
1143    fn test_config_error_uses_valid_line_column() {
1144        let config = md040_config::MD040Config {
1145            preferred_aliases: {
1146                let mut map = std::collections::HashMap::new();
1147                map.insert("Shell".to_string(), "invalid_alias".to_string());
1148                map
1149            },
1150            ..Default::default()
1151        };
1152        let rule = MD040FencedCodeLanguage::with_config(config);
1153
1154        let content = "```shell\necho hello\n```";
1155        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156        let result = rule.check(&ctx).unwrap();
1157
1158        // Find the config error warning
1159        let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1160        assert!(config_error.is_some(), "Should have a config error warning");
1161
1162        let warning = config_error.unwrap();
1163        // Line and column should be 1-indexed (not 0)
1164        assert!(
1165            warning.line >= 1,
1166            "Config error line should be >= 1, got {}",
1167            warning.line
1168        );
1169        assert!(
1170            warning.column >= 1,
1171            "Config error column should be >= 1, got {}",
1172            warning.column
1173        );
1174    }
1175
1176    // =========================================================================
1177    // Linguist resolution tests
1178    // =========================================================================
1179
1180    #[test]
1181    fn test_linguist_resolution() {
1182        assert_eq!(resolve_canonical("bash"), Some("Shell"));
1183        assert_eq!(resolve_canonical("sh"), Some("Shell"));
1184        assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1185        assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1186        assert_eq!(resolve_canonical("python"), Some("Python"));
1187        assert_eq!(resolve_canonical("unknown_lang"), None);
1188    }
1189
1190    #[test]
1191    fn test_linguist_resolution_case_insensitive() {
1192        assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1193        assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1194        assert_eq!(resolve_canonical("Python"), Some("Python"));
1195        assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1196    }
1197
1198    #[test]
1199    fn test_alias_validation() {
1200        assert!(is_valid_alias("Shell", "bash"));
1201        assert!(is_valid_alias("Shell", "sh"));
1202        assert!(is_valid_alias("Shell", "zsh"));
1203        assert!(!is_valid_alias("Shell", "python"));
1204        assert!(!is_valid_alias("Shell", "invalid"));
1205    }
1206
1207    #[test]
1208    fn test_default_alias() {
1209        assert_eq!(default_alias("Shell"), Some("bash"));
1210        assert_eq!(default_alias("JavaScript"), Some("js"));
1211        assert_eq!(default_alias("Python"), Some("python"));
1212    }
1213
1214    // =========================================================================
1215    // Edge case tests
1216    // =========================================================================
1217
1218    #[test]
1219    fn test_mixed_case_labels_normalized() {
1220        let content = r#"```BASH
1221echo hi
1222```
1223
1224```Bash
1225echo there
1226```
1227
1228```bash
1229echo again
1230```
1231"#;
1232        let config = MD040Config {
1233            style: LanguageStyle::Consistent,
1234            ..Default::default()
1235        };
1236        // All should resolve to Shell, most prevalent should win
1237        let result = run_check_with_config(content, config).unwrap();
1238        // "bash" appears 1x, "Bash" appears 1x, "BASH" appears 1x
1239        // All are different strings, so there's a 3-way tie
1240        // Should pick curated default "bash" or alphabetically first
1241        assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1242    }
1243
1244    #[test]
1245    fn test_multiple_languages_independent() {
1246        let content = r#"```bash
1247shell code
1248```
1249
1250```python
1251python code
1252```
1253
1254```sh
1255more shell
1256```
1257
1258```python3
1259more python
1260```
1261"#;
1262        let config = MD040Config {
1263            style: LanguageStyle::Consistent,
1264            ..Default::default()
1265        };
1266        let result = run_check_with_config(content, config).unwrap();
1267        // Should have 2 warnings: one for sh (inconsistent with bash) and one for python3 (inconsistent with python)
1268        assert_eq!(result.len(), 2);
1269    }
1270
1271    #[test]
1272    fn test_tilde_fences() {
1273        let content = r#"~~~bash
1274echo hi
1275~~~
1276
1277~~~sh
1278echo there
1279~~~
1280"#;
1281        let config = MD040Config {
1282            style: LanguageStyle::Consistent,
1283            ..Default::default()
1284        };
1285        let result = run_check_with_config(content, config.clone()).unwrap();
1286        assert_eq!(result.len(), 1);
1287
1288        let fixed = run_fix_with_config(content, config).unwrap();
1289        assert!(fixed.contains("~~~bash"));
1290        assert!(!fixed.contains("~~~sh"));
1291    }
1292
1293    #[test]
1294    fn test_longer_fence_markers_preserved() {
1295        let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1296        let config = MD040Config {
1297            style: LanguageStyle::Consistent,
1298            ..Default::default()
1299        };
1300        let fixed = run_fix_with_config(content, config).unwrap();
1301        assert!(fixed.contains("````bash"));
1302        assert!(fixed.contains("```bash"));
1303    }
1304
1305    #[test]
1306    fn test_empty_document() {
1307        let result = run_check("").unwrap();
1308        assert!(result.is_empty());
1309    }
1310
1311    #[test]
1312    fn test_no_code_blocks() {
1313        let content = "# Just a heading\n\nSome text.";
1314        let result = run_check(content).unwrap();
1315        assert!(result.is_empty());
1316    }
1317
1318    #[test]
1319    fn test_single_code_block_no_inconsistency() {
1320        let content = "```bash\necho hi\n```";
1321        let config = MD040Config {
1322            style: LanguageStyle::Consistent,
1323            ..Default::default()
1324        };
1325        let result = run_check_with_config(content, config).unwrap();
1326        assert!(result.is_empty(), "Single block has no inconsistency");
1327    }
1328
1329    #[test]
1330    fn test_idempotent_fix() {
1331        let content = r#"```bash
1332echo hi
1333```
1334
1335```sh
1336echo there
1337```
1338"#;
1339        let config = MD040Config {
1340            style: LanguageStyle::Consistent,
1341            ..Default::default()
1342        };
1343        let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1344        let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1345        assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1346    }
1347
1348    // =========================================================================
1349    // MkDocs superfences tests
1350    // =========================================================================
1351
1352    #[test]
1353    fn test_mkdocs_superfences_attribute_in_blockquote() {
1354        // A superfences attribute fence (no language) inside a blockquote must be
1355        // recognized just like a top-level one and not flagged as missing language.
1356        let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1357        let result = run_check_mkdocs(content).unwrap();
1358        assert!(
1359            result.is_empty(),
1360            "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_mkdocs_superfences_title_only() {
1366        // title= attribute without language should not warn in MkDocs flavor
1367        let content = r#"```title="Example"
1368echo hi
1369```
1370"#;
1371        let result = run_check_mkdocs(content).unwrap();
1372        assert!(
1373            result.is_empty(),
1374            "MkDocs superfences with title= should not require language"
1375        );
1376    }
1377
1378    #[test]
1379    fn test_mkdocs_superfences_hl_lines() {
1380        // hl_lines= attribute without language should not warn
1381        let content = r#"```hl_lines="1 2"
1382line 1
1383line 2
1384```
1385"#;
1386        let result = run_check_mkdocs(content).unwrap();
1387        assert!(
1388            result.is_empty(),
1389            "MkDocs superfences with hl_lines= should not require language"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_mkdocs_superfences_linenums() {
1395        // linenums= attribute without language should not warn
1396        let content = r#"```linenums="1"
1397line 1
1398line 2
1399```
1400"#;
1401        let result = run_check_mkdocs(content).unwrap();
1402        assert!(
1403            result.is_empty(),
1404            "MkDocs superfences with linenums= should not require language"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_mkdocs_superfences_class() {
1410        // Custom class (starting with .) should not warn
1411        let content = r#"```.my-class
1412some text
1413```
1414"#;
1415        let result = run_check_mkdocs(content).unwrap();
1416        assert!(
1417            result.is_empty(),
1418            "MkDocs superfences with .class should not require language"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_mkdocs_superfences_id() {
1424        // Custom ID (starting with #) should not warn
1425        let content = r#"```#my-id
1426some text
1427```
1428"#;
1429        let result = run_check_mkdocs(content).unwrap();
1430        assert!(
1431            result.is_empty(),
1432            "MkDocs superfences with #id should not require language"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_mkdocs_superfences_with_language() {
1438        // Language with superfences attributes should work fine
1439        let content = r#"```python title="Example" hl_lines="1"
1440print("hello")
1441```
1442"#;
1443        let result = run_check_mkdocs(content).unwrap();
1444        assert!(result.is_empty(), "Code block with language and attrs should pass");
1445    }
1446
1447    #[test]
1448    fn test_standard_flavor_no_special_handling() {
1449        // In Standard flavor, title= should still warn
1450        let content = r#"```title="Example"
1451echo hi
1452```
1453"#;
1454        let result = run_check(content).unwrap();
1455        assert_eq!(
1456            result.len(),
1457            1,
1458            "Standard flavor should warn about title= without language"
1459        );
1460    }
1461
1462    #[test]
1463    fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1464        // ```{=html} raw blocks are valid Pandoc syntax and should not trigger MD040
1465        // under Pandoc flavor.
1466        let rule = MD040FencedCodeLanguage::default();
1467        let content = "```{=html}\n<div>raw html</div>\n```\n";
1468        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1469        let result = rule.check(&ctx).unwrap();
1470        assert!(
1471            result.is_empty(),
1472            "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1473        );
1474    }
1475
1476    #[test]
1477    fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1478        // ```{=html} raw blocks are also valid under Quarto (which is Pandoc-compatible).
1479        let rule = MD040FencedCodeLanguage::default();
1480        let content = "```{=html}\n<div>raw html</div>\n```\n";
1481        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1482        let result = rule.check(&ctx).unwrap();
1483        assert!(
1484            result.is_empty(),
1485            "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1486        );
1487    }
1488
1489    /// Pandoc raw blocks like ```` ```{=html} ```` declare an output target,
1490    /// not a missing language. MD040 must accept them under Pandoc.
1491    #[test]
1492    fn test_pandoc_accepts_raw_html_block() {
1493        use crate::config::MarkdownFlavor;
1494        let rule = MD040FencedCodeLanguage::default();
1495        let content = "```{=html}\n<div>raw</div>\n```\n";
1496        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1497        let result = rule.check(&ctx).unwrap();
1498        assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1499    }
1500
1501    /// Under Pandoc (not Quarto), `{r}` is NOT a valid raw-format declaration —
1502    /// it's a Quarto-only execution syntax that should be flagged as missing language.
1503    #[test]
1504    fn test_pandoc_rejects_quarto_exec_blocks() {
1505        use crate::config::MarkdownFlavor;
1506        let rule = MD040FencedCodeLanguage::default();
1507        let content = "```{r}\nsummary(data)\n```\n";
1508        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1509        let result = rule.check(&ctx).unwrap();
1510        assert!(
1511            !result.is_empty(),
1512            "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1513        );
1514    }
1515
1516    /// Under Quarto, `{r}` IS valid — Quarto exec syntax. Must not be flagged.
1517    #[test]
1518    fn test_quarto_still_accepts_exec_block() {
1519        use crate::config::MarkdownFlavor;
1520        let rule = MD040FencedCodeLanguage::default();
1521        let content = "```{r}\nsummary(data)\n```\n";
1522        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1523        let result = rule.check(&ctx).unwrap();
1524        assert!(
1525            result.is_empty(),
1526            "MD040 under Quarto should accept `{{r}}`: {result:?}"
1527        );
1528    }
1529
1530    #[test]
1531    fn test_quarto_exec_block_skipped_under_quarto_only() {
1532        // ```{r} exec chunks are Quarto-specific syntax accepted only under the Quarto flavor.
1533        // Under Pandoc flavor, `{r}` is not a valid Pandoc raw-format declaration (those use
1534        // `{=format}` syntax), so MD040 flags it as missing a real language identifier.
1535        let rule = MD040FencedCodeLanguage::default();
1536        let content = "```{r}\n1 + 1\n```\n";
1537
1538        let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1539        let result_quarto = rule.check(&ctx_quarto).unwrap();
1540        assert!(
1541            result_quarto.is_empty(),
1542            "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1543        );
1544
1545        // Under Pandoc, `{r}` is unrecognized brace syntax — not a valid Pandoc raw block.
1546        // MD040 treats it as a missing language.
1547        let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1548        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1549        assert!(
1550            !result_pandoc.is_empty(),
1551            "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1552        );
1553    }
1554
1555    /// Pandoc code-attribute syntax `{.lang}` declares the language and is valid under
1556    /// both Pandoc and Quarto. MD040 must accept it.
1557    #[test]
1558    fn test_pandoc_class_attr_accepted_as_language() {
1559        use crate::config::MarkdownFlavor;
1560        let rule = MD040FencedCodeLanguage::default();
1561        let content = "```{.python}\nprint(\"hi\")\n```\n";
1562
1563        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1564        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1565        assert!(
1566            result_pandoc.is_empty(),
1567            "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1568        );
1569
1570        let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1571        let result_quarto = rule.check(&ctx_quarto).unwrap();
1572        assert!(
1573            result_quarto.is_empty(),
1574            "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1575        );
1576    }
1577
1578    /// Pandoc code attributes can include multiple classes plus key=value pairs.
1579    /// The first class is the language; trailing attributes (e.g. `.numberLines`) are decoration.
1580    #[test]
1581    fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1582        use crate::config::MarkdownFlavor;
1583        let rule = MD040FencedCodeLanguage::default();
1584        let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1585
1586        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1587        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1588        assert!(
1589            result_pandoc.is_empty(),
1590            "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1591        );
1592
1593        let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1594        let result_quarto = rule.check(&ctx_quarto).unwrap();
1595        assert!(
1596            result_quarto.is_empty(),
1597            "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1598        );
1599    }
1600
1601    /// Pandoc code attributes can include id (`#myid`) and key=value attributes.
1602    /// As long as a `.class` is present, the block declares a language.
1603    #[test]
1604    fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1605        use crate::config::MarkdownFlavor;
1606        let rule = MD040FencedCodeLanguage::default();
1607        let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1608
1609        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1610        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1611        assert!(
1612            result_pandoc.is_empty(),
1613            "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1614        );
1615    }
1616
1617    /// Standard flavor knows nothing about Pandoc code attributes — they remain
1618    /// unrecognized brace syntax and must still be flagged as missing-language.
1619    #[test]
1620    fn test_standard_still_flags_pandoc_class_attr() {
1621        use crate::config::MarkdownFlavor;
1622        let rule = MD040FencedCodeLanguage::default();
1623        let content = "```{.python}\nprint(\"hi\")\n```\n";
1624
1625        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1626        let result_standard = rule.check(&ctx_standard).unwrap();
1627        assert!(
1628            !result_standard.is_empty(),
1629            "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1630        );
1631    }
1632
1633    /// A brace block with only an id (`{#myid}`) and no class declares no language.
1634    /// Even under Pandoc this must remain flagged.
1635    #[test]
1636    fn test_pandoc_id_only_attr_still_flagged() {
1637        use crate::config::MarkdownFlavor;
1638        let rule = MD040FencedCodeLanguage::default();
1639        let content = "```{#myid}\ncode here\n```\n";
1640
1641        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1642        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1643        assert!(
1644            !result_pandoc.is_empty(),
1645            "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1646        );
1647    }
1648
1649    /// Empty `{}` braces declare nothing and must still be flagged under any flavor.
1650    #[test]
1651    fn test_pandoc_empty_braces_still_flagged() {
1652        use crate::config::MarkdownFlavor;
1653        let rule = MD040FencedCodeLanguage::default();
1654        let content = "```{}\ncode here\n```\n";
1655
1656        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1657        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1658        assert!(
1659            !result_pandoc.is_empty(),
1660            "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1661        );
1662    }
1663}