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    /// Regression test for a category of bugs, not one instance: rumdl's generated
1006    /// Linguist alias table must recognize every high-traffic language alias, not
1007    /// just the exact one reported (`py`). Each alias below is confirmed present in
1008    /// GitHub Linguist's current `aliases:` list for its language (`py` and `py3`
1009    /// were added upstream after the `e51c2270` generation pin, see the header
1010    /// comment in `src/linguist_data.rs`); none of them should ever trigger an
1011    /// unknown-language warning.
1012    #[test]
1013    fn test_unknown_language_warn_known_aliases_not_flagged() {
1014        let known_aliases = [
1015            "py",
1016            "python",
1017            "sh",
1018            "bash",
1019            "shell",
1020            "zsh",
1021            "js",
1022            "javascript",
1023            "ts",
1024            "typescript",
1025            "rb",
1026            "ruby",
1027            "rs",
1028            "rust",
1029            "yml",
1030            "yaml",
1031            "cpp",
1032            "c++",
1033            "csharp",
1034            "golang",
1035            "dockerfile",
1036            "jsonc",
1037            "kotlin",
1038        ];
1039        for alias in known_aliases {
1040            let content = format!("```{alias}\ncode\n```");
1041            let config = MD040Config {
1042                unknown_language_action: UnknownLanguageAction::Warn,
1043                ..Default::default()
1044            };
1045            let result = run_check_with_config(&content, config).unwrap();
1046            assert!(
1047                result.is_empty(),
1048                "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1049            );
1050        }
1051    }
1052
1053    #[test]
1054    fn test_unknown_language_error() {
1055        let content = "```mycustomlang\ncode\n```";
1056        let config = MD040Config {
1057            unknown_language_action: UnknownLanguageAction::Error,
1058            ..Default::default()
1059        };
1060        let result = run_check_with_config(content, config).unwrap();
1061        assert_eq!(result.len(), 1);
1062        assert!(result[0].message.contains("Unknown language"));
1063        assert_eq!(result[0].severity, Severity::Error);
1064    }
1065
1066    // =========================================================================
1067    // Config validation tests
1068    // =========================================================================
1069
1070    #[test]
1071    fn test_invalid_preferred_alias_detected() {
1072        let mut preferred = HashMap::new();
1073        preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1074
1075        let config = MD040Config {
1076            style: LanguageStyle::Consistent,
1077            preferred_aliases: preferred,
1078            ..Default::default()
1079        };
1080        let rule = MD040FencedCodeLanguage::with_config(config);
1081        let errors = rule.validate_config();
1082        assert_eq!(errors.len(), 1);
1083        assert!(errors[0].contains("Invalid alias"));
1084        assert!(errors[0].contains("invalid_alias"));
1085    }
1086
1087    #[test]
1088    fn test_unknown_language_in_preferred_aliases_detected() {
1089        let mut preferred = HashMap::new();
1090        preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1091
1092        let config = MD040Config {
1093            style: LanguageStyle::Consistent,
1094            preferred_aliases: preferred,
1095            ..Default::default()
1096        };
1097        let rule = MD040FencedCodeLanguage::with_config(config);
1098        let errors = rule.validate_config();
1099        assert_eq!(errors.len(), 1);
1100        assert!(errors[0].contains("Unknown language"));
1101    }
1102
1103    #[test]
1104    fn test_valid_preferred_alias_accepted() {
1105        let mut preferred = HashMap::new();
1106        preferred.insert("Shell".to_string(), "bash".to_string());
1107        preferred.insert("JavaScript".to_string(), "js".to_string());
1108
1109        let config = MD040Config {
1110            style: LanguageStyle::Consistent,
1111            preferred_aliases: preferred,
1112            ..Default::default()
1113        };
1114        let rule = MD040FencedCodeLanguage::with_config(config);
1115        let errors = rule.validate_config();
1116        assert!(errors.is_empty());
1117    }
1118
1119    #[test]
1120    fn test_config_error_uses_valid_line_column() {
1121        let config = md040_config::MD040Config {
1122            preferred_aliases: {
1123                let mut map = std::collections::HashMap::new();
1124                map.insert("Shell".to_string(), "invalid_alias".to_string());
1125                map
1126            },
1127            ..Default::default()
1128        };
1129        let rule = MD040FencedCodeLanguage::with_config(config);
1130
1131        let content = "```shell\necho hello\n```";
1132        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133        let result = rule.check(&ctx).unwrap();
1134
1135        // Find the config error warning
1136        let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1137        assert!(config_error.is_some(), "Should have a config error warning");
1138
1139        let warning = config_error.unwrap();
1140        // Line and column should be 1-indexed (not 0)
1141        assert!(
1142            warning.line >= 1,
1143            "Config error line should be >= 1, got {}",
1144            warning.line
1145        );
1146        assert!(
1147            warning.column >= 1,
1148            "Config error column should be >= 1, got {}",
1149            warning.column
1150        );
1151    }
1152
1153    // =========================================================================
1154    // Linguist resolution tests
1155    // =========================================================================
1156
1157    #[test]
1158    fn test_linguist_resolution() {
1159        assert_eq!(resolve_canonical("bash"), Some("Shell"));
1160        assert_eq!(resolve_canonical("sh"), Some("Shell"));
1161        assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1162        assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1163        assert_eq!(resolve_canonical("python"), Some("Python"));
1164        assert_eq!(resolve_canonical("unknown_lang"), None);
1165    }
1166
1167    #[test]
1168    fn test_linguist_resolution_case_insensitive() {
1169        assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1170        assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1171        assert_eq!(resolve_canonical("Python"), Some("Python"));
1172        assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1173    }
1174
1175    #[test]
1176    fn test_alias_validation() {
1177        assert!(is_valid_alias("Shell", "bash"));
1178        assert!(is_valid_alias("Shell", "sh"));
1179        assert!(is_valid_alias("Shell", "zsh"));
1180        assert!(!is_valid_alias("Shell", "python"));
1181        assert!(!is_valid_alias("Shell", "invalid"));
1182    }
1183
1184    #[test]
1185    fn test_default_alias() {
1186        assert_eq!(default_alias("Shell"), Some("bash"));
1187        assert_eq!(default_alias("JavaScript"), Some("js"));
1188        assert_eq!(default_alias("Python"), Some("python"));
1189    }
1190
1191    // =========================================================================
1192    // Edge case tests
1193    // =========================================================================
1194
1195    #[test]
1196    fn test_mixed_case_labels_normalized() {
1197        let content = r#"```BASH
1198echo hi
1199```
1200
1201```Bash
1202echo there
1203```
1204
1205```bash
1206echo again
1207```
1208"#;
1209        let config = MD040Config {
1210            style: LanguageStyle::Consistent,
1211            ..Default::default()
1212        };
1213        // All should resolve to Shell, most prevalent should win
1214        let result = run_check_with_config(content, config).unwrap();
1215        // "bash" appears 1x, "Bash" appears 1x, "BASH" appears 1x
1216        // All are different strings, so there's a 3-way tie
1217        // Should pick curated default "bash" or alphabetically first
1218        assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1219    }
1220
1221    #[test]
1222    fn test_multiple_languages_independent() {
1223        let content = r#"```bash
1224shell code
1225```
1226
1227```python
1228python code
1229```
1230
1231```sh
1232more shell
1233```
1234
1235```python3
1236more python
1237```
1238"#;
1239        let config = MD040Config {
1240            style: LanguageStyle::Consistent,
1241            ..Default::default()
1242        };
1243        let result = run_check_with_config(content, config).unwrap();
1244        // Should have 2 warnings: one for sh (inconsistent with bash) and one for python3 (inconsistent with python)
1245        assert_eq!(result.len(), 2);
1246    }
1247
1248    #[test]
1249    fn test_tilde_fences() {
1250        let content = r#"~~~bash
1251echo hi
1252~~~
1253
1254~~~sh
1255echo there
1256~~~
1257"#;
1258        let config = MD040Config {
1259            style: LanguageStyle::Consistent,
1260            ..Default::default()
1261        };
1262        let result = run_check_with_config(content, config.clone()).unwrap();
1263        assert_eq!(result.len(), 1);
1264
1265        let fixed = run_fix_with_config(content, config).unwrap();
1266        assert!(fixed.contains("~~~bash"));
1267        assert!(!fixed.contains("~~~sh"));
1268    }
1269
1270    #[test]
1271    fn test_longer_fence_markers_preserved() {
1272        let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1273        let config = MD040Config {
1274            style: LanguageStyle::Consistent,
1275            ..Default::default()
1276        };
1277        let fixed = run_fix_with_config(content, config).unwrap();
1278        assert!(fixed.contains("````bash"));
1279        assert!(fixed.contains("```bash"));
1280    }
1281
1282    #[test]
1283    fn test_empty_document() {
1284        let result = run_check("").unwrap();
1285        assert!(result.is_empty());
1286    }
1287
1288    #[test]
1289    fn test_no_code_blocks() {
1290        let content = "# Just a heading\n\nSome text.";
1291        let result = run_check(content).unwrap();
1292        assert!(result.is_empty());
1293    }
1294
1295    #[test]
1296    fn test_single_code_block_no_inconsistency() {
1297        let content = "```bash\necho hi\n```";
1298        let config = MD040Config {
1299            style: LanguageStyle::Consistent,
1300            ..Default::default()
1301        };
1302        let result = run_check_with_config(content, config).unwrap();
1303        assert!(result.is_empty(), "Single block has no inconsistency");
1304    }
1305
1306    #[test]
1307    fn test_idempotent_fix() {
1308        let content = r#"```bash
1309echo hi
1310```
1311
1312```sh
1313echo there
1314```
1315"#;
1316        let config = MD040Config {
1317            style: LanguageStyle::Consistent,
1318            ..Default::default()
1319        };
1320        let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1321        let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1322        assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1323    }
1324
1325    // =========================================================================
1326    // MkDocs superfences tests
1327    // =========================================================================
1328
1329    #[test]
1330    fn test_mkdocs_superfences_attribute_in_blockquote() {
1331        // A superfences attribute fence (no language) inside a blockquote must be
1332        // recognized just like a top-level one and not flagged as missing language.
1333        let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1334        let result = run_check_mkdocs(content).unwrap();
1335        assert!(
1336            result.is_empty(),
1337            "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1338        );
1339    }
1340
1341    #[test]
1342    fn test_mkdocs_superfences_title_only() {
1343        // title= attribute without language should not warn in MkDocs flavor
1344        let content = r#"```title="Example"
1345echo hi
1346```
1347"#;
1348        let result = run_check_mkdocs(content).unwrap();
1349        assert!(
1350            result.is_empty(),
1351            "MkDocs superfences with title= should not require language"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_mkdocs_superfences_hl_lines() {
1357        // hl_lines= attribute without language should not warn
1358        let content = r#"```hl_lines="1 2"
1359line 1
1360line 2
1361```
1362"#;
1363        let result = run_check_mkdocs(content).unwrap();
1364        assert!(
1365            result.is_empty(),
1366            "MkDocs superfences with hl_lines= should not require language"
1367        );
1368    }
1369
1370    #[test]
1371    fn test_mkdocs_superfences_linenums() {
1372        // linenums= attribute without language should not warn
1373        let content = r#"```linenums="1"
1374line 1
1375line 2
1376```
1377"#;
1378        let result = run_check_mkdocs(content).unwrap();
1379        assert!(
1380            result.is_empty(),
1381            "MkDocs superfences with linenums= should not require language"
1382        );
1383    }
1384
1385    #[test]
1386    fn test_mkdocs_superfences_class() {
1387        // Custom class (starting with .) should not warn
1388        let content = r#"```.my-class
1389some text
1390```
1391"#;
1392        let result = run_check_mkdocs(content).unwrap();
1393        assert!(
1394            result.is_empty(),
1395            "MkDocs superfences with .class should not require language"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_mkdocs_superfences_id() {
1401        // Custom ID (starting with #) should not warn
1402        let content = r#"```#my-id
1403some text
1404```
1405"#;
1406        let result = run_check_mkdocs(content).unwrap();
1407        assert!(
1408            result.is_empty(),
1409            "MkDocs superfences with #id should not require language"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_mkdocs_superfences_with_language() {
1415        // Language with superfences attributes should work fine
1416        let content = r#"```python title="Example" hl_lines="1"
1417print("hello")
1418```
1419"#;
1420        let result = run_check_mkdocs(content).unwrap();
1421        assert!(result.is_empty(), "Code block with language and attrs should pass");
1422    }
1423
1424    #[test]
1425    fn test_standard_flavor_no_special_handling() {
1426        // In Standard flavor, title= should still warn
1427        let content = r#"```title="Example"
1428echo hi
1429```
1430"#;
1431        let result = run_check(content).unwrap();
1432        assert_eq!(
1433            result.len(),
1434            1,
1435            "Standard flavor should warn about title= without language"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1441        // ```{=html} raw blocks are valid Pandoc syntax and should not trigger MD040
1442        // under Pandoc flavor.
1443        let rule = MD040FencedCodeLanguage::default();
1444        let content = "```{=html}\n<div>raw html</div>\n```\n";
1445        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1446        let result = rule.check(&ctx).unwrap();
1447        assert!(
1448            result.is_empty(),
1449            "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1450        );
1451    }
1452
1453    #[test]
1454    fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1455        // ```{=html} raw blocks are also valid under Quarto (which is Pandoc-compatible).
1456        let rule = MD040FencedCodeLanguage::default();
1457        let content = "```{=html}\n<div>raw html</div>\n```\n";
1458        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1459        let result = rule.check(&ctx).unwrap();
1460        assert!(
1461            result.is_empty(),
1462            "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1463        );
1464    }
1465
1466    /// Pandoc raw blocks like ```` ```{=html} ```` declare an output target,
1467    /// not a missing language. MD040 must accept them under Pandoc.
1468    #[test]
1469    fn test_pandoc_accepts_raw_html_block() {
1470        use crate::config::MarkdownFlavor;
1471        let rule = MD040FencedCodeLanguage::default();
1472        let content = "```{=html}\n<div>raw</div>\n```\n";
1473        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1474        let result = rule.check(&ctx).unwrap();
1475        assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1476    }
1477
1478    /// Under Pandoc (not Quarto), `{r}` is NOT a valid raw-format declaration —
1479    /// it's a Quarto-only execution syntax that should be flagged as missing language.
1480    #[test]
1481    fn test_pandoc_rejects_quarto_exec_blocks() {
1482        use crate::config::MarkdownFlavor;
1483        let rule = MD040FencedCodeLanguage::default();
1484        let content = "```{r}\nsummary(data)\n```\n";
1485        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1486        let result = rule.check(&ctx).unwrap();
1487        assert!(
1488            !result.is_empty(),
1489            "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1490        );
1491    }
1492
1493    /// Under Quarto, `{r}` IS valid — Quarto exec syntax. Must not be flagged.
1494    #[test]
1495    fn test_quarto_still_accepts_exec_block() {
1496        use crate::config::MarkdownFlavor;
1497        let rule = MD040FencedCodeLanguage::default();
1498        let content = "```{r}\nsummary(data)\n```\n";
1499        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1500        let result = rule.check(&ctx).unwrap();
1501        assert!(
1502            result.is_empty(),
1503            "MD040 under Quarto should accept `{{r}}`: {result:?}"
1504        );
1505    }
1506
1507    #[test]
1508    fn test_quarto_exec_block_skipped_under_quarto_only() {
1509        // ```{r} exec chunks are Quarto-specific syntax accepted only under the Quarto flavor.
1510        // Under Pandoc flavor, `{r}` is not a valid Pandoc raw-format declaration (those use
1511        // `{=format}` syntax), so MD040 flags it as missing a real language identifier.
1512        let rule = MD040FencedCodeLanguage::default();
1513        let content = "```{r}\n1 + 1\n```\n";
1514
1515        let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1516        let result_quarto = rule.check(&ctx_quarto).unwrap();
1517        assert!(
1518            result_quarto.is_empty(),
1519            "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1520        );
1521
1522        // Under Pandoc, `{r}` is unrecognized brace syntax — not a valid Pandoc raw block.
1523        // MD040 treats it as a missing language.
1524        let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1525        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1526        assert!(
1527            !result_pandoc.is_empty(),
1528            "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1529        );
1530    }
1531
1532    /// Pandoc code-attribute syntax `{.lang}` declares the language and is valid under
1533    /// both Pandoc and Quarto. MD040 must accept it.
1534    #[test]
1535    fn test_pandoc_class_attr_accepted_as_language() {
1536        use crate::config::MarkdownFlavor;
1537        let rule = MD040FencedCodeLanguage::default();
1538        let content = "```{.python}\nprint(\"hi\")\n```\n";
1539
1540        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1541        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1542        assert!(
1543            result_pandoc.is_empty(),
1544            "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1545        );
1546
1547        let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1548        let result_quarto = rule.check(&ctx_quarto).unwrap();
1549        assert!(
1550            result_quarto.is_empty(),
1551            "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1552        );
1553    }
1554
1555    /// Pandoc code attributes can include multiple classes plus key=value pairs.
1556    /// The first class is the language; trailing attributes (e.g. `.numberLines`) are decoration.
1557    #[test]
1558    fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1559        use crate::config::MarkdownFlavor;
1560        let rule = MD040FencedCodeLanguage::default();
1561        let content = "```{.haskell .numberLines}\nmain = putStrLn \"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 ```{{.haskell .numberLines}}```: {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 ```{{.haskell .numberLines}}```: {result_quarto:?}"
1575        );
1576    }
1577
1578    /// Pandoc code attributes can include id (`#myid`) and key=value attributes.
1579    /// As long as a `.class` is present, the block declares a language.
1580    #[test]
1581    fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1582        use crate::config::MarkdownFlavor;
1583        let rule = MD040FencedCodeLanguage::default();
1584        let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\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 ```{{#snippet .python …}}```: {result_pandoc:?}"
1591        );
1592    }
1593
1594    /// Standard flavor knows nothing about Pandoc code attributes — they remain
1595    /// unrecognized brace syntax and must still be flagged as missing-language.
1596    #[test]
1597    fn test_standard_still_flags_pandoc_class_attr() {
1598        use crate::config::MarkdownFlavor;
1599        let rule = MD040FencedCodeLanguage::default();
1600        let content = "```{.python}\nprint(\"hi\")\n```\n";
1601
1602        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1603        let result_standard = rule.check(&ctx_standard).unwrap();
1604        assert!(
1605            !result_standard.is_empty(),
1606            "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1607        );
1608    }
1609
1610    /// A brace block with only an id (`{#myid}`) and no class declares no language.
1611    /// Even under Pandoc this must remain flagged.
1612    #[test]
1613    fn test_pandoc_id_only_attr_still_flagged() {
1614        use crate::config::MarkdownFlavor;
1615        let rule = MD040FencedCodeLanguage::default();
1616        let content = "```{#myid}\ncode here\n```\n";
1617
1618        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1619        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1620        assert!(
1621            !result_pandoc.is_empty(),
1622            "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1623        );
1624    }
1625
1626    /// Empty `{}` braces declare nothing and must still be flagged under any flavor.
1627    #[test]
1628    fn test_pandoc_empty_braces_still_flagged() {
1629        use crate::config::MarkdownFlavor;
1630        let rule = MD040FencedCodeLanguage::default();
1631        let content = "```{}\ncode here\n```\n";
1632
1633        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1634        let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1635        assert!(
1636            !result_pandoc.is_empty(),
1637            "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1638        );
1639    }
1640}