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