Skip to main content

rumdl_lib/rules/
md040_fenced_code_language.rs

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