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