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