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