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