1use crate::linguist_data::{default_alias, get_aliases, is_valid_alias, resolve_canonical};
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::{RuleConfig, load_rule_config};
4use crate::utils::range_utils::calculate_line_range;
5use std::collections::HashMap;
6
7pub mod md040_config;
11
12const MKDOCS_SUPERFENCES_ATTR_PREFIXES: &[&str] = &[
20 "title=", "hl_lines=", "linenums=", ".", "#", ];
26
27#[inline]
29fn is_superfences_attribute(s: &str) -> bool {
30 MKDOCS_SUPERFENCES_ATTR_PREFIXES
31 .iter()
32 .any(|prefix| s.starts_with(prefix))
33}
34use md040_config::{LanguageStyle, MD040Config, UnknownLanguageAction};
35
36struct FencedCodeBlock {
37 line_idx: usize,
39 language: String,
41 fence_marker: String,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct MD040FencedCodeLanguage {
47 config: MD040Config,
48}
49
50impl MD040FencedCodeLanguage {
51 pub fn with_config(config: MD040Config) -> Self {
52 Self { config }
53 }
54
55 fn validate_config(&self) -> Vec<String> {
57 let mut errors = Vec::new();
58
59 for (canonical, alias) in &self.config.preferred_aliases {
61 if let Some(actual_canonical) = resolve_canonical(canonical) {
63 if !is_valid_alias(actual_canonical, alias)
64 && let Some(valid_aliases) = get_aliases(actual_canonical)
65 {
66 let valid_list: Vec<_> = valid_aliases.iter().take(5).collect();
67 let valid_str = valid_list
68 .iter()
69 .map(|s| format!("'{s}'"))
70 .collect::<Vec<_>>()
71 .join(", ");
72 let suffix = if valid_aliases.len() > 5 { ", ..." } else { "" };
73 errors.push(format!(
74 "Invalid alias '{alias}' for language '{actual_canonical}'. Valid aliases include: {valid_str}{suffix}"
75 ));
76 }
77 } else {
78 errors.push(format!(
79 "Unknown language '{canonical}' in preferred-aliases. Use GitHub Linguist canonical names."
80 ));
81 }
82 }
83
84 errors
85 }
86
87 fn compute_preferred_labels(
89 &self,
90 blocks: &[FencedCodeBlock],
91 disabled_ranges: &[(usize, usize)],
92 ) -> HashMap<String, String> {
93 let mut by_canonical: HashMap<String, Vec<&str>> = HashMap::new();
95
96 for block in blocks {
97 if is_line_disabled(disabled_ranges, block.line_idx) {
98 continue;
99 }
100 if block.language.is_empty() {
101 continue;
102 }
103 if let Some(canonical) = resolve_canonical(&block.language) {
104 by_canonical
105 .entry(canonical.to_string())
106 .or_default()
107 .push(&block.language);
108 }
109 }
110
111 let mut result = HashMap::new();
113
114 for (canonical, labels) in by_canonical {
115 let winner = if let Some(preferred) = self
117 .config
118 .preferred_aliases
119 .iter()
120 .find(|(k, _)| k.eq_ignore_ascii_case(&canonical))
121 .map(|(_, v)| v.clone())
122 {
123 preferred
124 } else {
125 let mut counts: HashMap<&str, usize> = HashMap::new();
127 for label in &labels {
128 *counts.entry(*label).or_default() += 1;
129 }
130
131 let max_count = counts.values().max().copied().unwrap_or(0);
132 let winners: Vec<_> = counts
133 .iter()
134 .filter(|(_, c)| **c == max_count)
135 .map(|(l, _)| *l)
136 .collect();
137
138 if winners.len() == 1 {
139 winners[0].to_string()
140 } else {
141 default_alias(&canonical)
143 .filter(|default| winners.contains(default))
144 .map_or_else(
145 || winners.into_iter().min().unwrap().to_string(),
146 std::string::ToString::to_string,
147 )
148 }
149 };
150
151 result.insert(canonical, winner);
152 }
153
154 result
155 }
156
157 fn check_language_allowed(&self, canonical: Option<&str>, original_label: &str) -> Option<String> {
159 if !self.config.allowed_languages.is_empty() {
161 let allowed = self.config.allowed_languages.join(", ");
162 let Some(canonical) = canonical else {
163 return Some(format!(
164 "Language '{original_label}' is not in the allowed list: {allowed}"
165 ));
166 };
167 if !self
168 .config
169 .allowed_languages
170 .iter()
171 .any(|a| a.eq_ignore_ascii_case(canonical))
172 {
173 return Some(format!(
174 "Language '{original_label}' ({canonical}) is not in the allowed list: {allowed}"
175 ));
176 }
177 } else if !self.config.disallowed_languages.is_empty()
178 && canonical.is_some_and(|canonical| {
179 self.config
180 .disallowed_languages
181 .iter()
182 .any(|d| d.eq_ignore_ascii_case(canonical))
183 })
184 {
185 let canonical = canonical.unwrap_or("unknown");
186 return Some(format!("Language '{original_label}' ({canonical}) is disallowed"));
187 }
188 None
189 }
190
191 fn check_unknown_language(&self, label: &str) -> Option<(String, Severity)> {
193 if resolve_canonical(label).is_some() {
194 return None;
195 }
196
197 match self.config.unknown_language_action {
198 UnknownLanguageAction::Ignore => None,
199 UnknownLanguageAction::Warn => Some((
200 format!("Unknown language '{label}' (not in GitHub Linguist). Syntax highlighting may not work."),
201 Severity::Warning,
202 )),
203 UnknownLanguageAction::Error => Some((
204 format!("Unknown language '{label}' (not in GitHub Linguist)"),
205 Severity::Error,
206 )),
207 }
208 }
209}
210
211impl Rule for MD040FencedCodeLanguage {
212 fn name(&self) -> &'static str {
213 "MD040"
214 }
215
216 fn description(&self) -> &'static str {
217 "Code blocks should have a language specified"
218 }
219
220 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
221 let content = ctx.content;
222 let mut warnings = Vec::new();
223
224 for error in self.validate_config() {
226 warnings.push(LintWarning {
227 rule_name: Some(self.name().to_string()),
228 line: 1,
229 column: 1,
230 end_line: 1,
231 end_column: 1,
232 message: format!("[config error] {error}"),
233 severity: Severity::Error,
234 fix: None,
235 });
236 }
237
238 let fenced_blocks = derive_fenced_code_blocks(ctx);
240
241 let disabled_ranges = compute_disabled_ranges(content, self.name());
243
244 let preferred_labels = if self.config.style == LanguageStyle::Consistent {
246 self.compute_preferred_labels(&fenced_blocks, &disabled_ranges)
247 } else {
248 HashMap::new()
249 };
250
251 let lines = ctx.raw_lines();
252
253 for block in &fenced_blocks {
254 if is_line_disabled(&disabled_ranges, block.line_idx) {
256 continue;
257 }
258
259 let line = lines.get(block.line_idx).unwrap_or(&"");
263 let fence_line = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
264 let after_fence = fence_line.strip_prefix(&block.fence_marker).unwrap_or("").trim();
265
266 let has_mkdocs_attrs_only =
268 ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
269
270 let is_myst_directive =
273 ctx.flavor.supports_myst_directives() && after_fence.starts_with('{') && after_fence.contains('}') && {
274 let name = after_fence.trim_start_matches('{').split('}').next().unwrap_or("");
275 !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
276 };
277
278 let is_pandoc_raw =
289 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(after_fence);
290 let is_pandoc_class_attr =
291 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(after_fence);
292 let is_quarto_exec = ctx.flavor == crate::config::MarkdownFlavor::Quarto
293 && after_fence.starts_with('{')
294 && after_fence.ends_with('}')
295 && !is_pandoc_raw
296 && !is_pandoc_class_attr;
297 let has_pandoc_or_quarto_syntax = is_pandoc_raw || is_pandoc_class_attr || is_quarto_exec;
298 let is_unrecognized_brace_syntax = after_fence.starts_with('{')
299 && after_fence.ends_with('}')
300 && !has_pandoc_or_quarto_syntax
301 && !is_myst_directive;
302
303 let needs_language = !has_mkdocs_attrs_only
304 && !is_myst_directive
305 && (block.language.is_empty()
306 || is_superfences_attribute(&block.language)
307 || is_unrecognized_brace_syntax);
308
309 if needs_language && !has_pandoc_or_quarto_syntax {
310 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
311
312 warnings.push(LintWarning {
313 rule_name: Some(self.name().to_string()),
314 line: start_line,
315 column: start_col,
316 end_line,
317 end_column: end_col,
318 message: "Code block (```) missing language".to_string(),
319 severity: Severity::Warning,
320 fix: Some(Fix::new(
321 {
322 let marker_offset = fence_marker_offset(line);
323 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
324 let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
325 let line_end_byte = line_start_byte + line.len();
329 fence_end_byte..line_end_byte
330 },
331 {
332 let line: &str = line;
333 let after_fence = &line[fence_marker_offset(line) + block.fence_marker.len()..];
334 let after_fence_trimmed = after_fence.trim();
335 if after_fence_trimmed.is_empty() {
336 "text".to_string()
337 } else {
338 format!("text {after_fence_trimmed}")
339 }
340 },
341 )),
342 });
343 continue;
344 }
345
346 if has_pandoc_or_quarto_syntax {
348 continue;
349 }
350
351 let canonical = resolve_canonical(&block.language);
352
353 if let Some(msg) = self.check_language_allowed(canonical, &block.language) {
355 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
356
357 warnings.push(LintWarning {
358 rule_name: Some(self.name().to_string()),
359 line: start_line,
360 column: start_col,
361 end_line,
362 end_column: end_col,
363 message: msg,
364 severity: Severity::Warning,
365 fix: None,
366 });
367 continue;
368 }
369
370 if canonical.is_none() {
372 if let Some((msg, severity)) = self.check_unknown_language(&block.language) {
373 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
374
375 warnings.push(LintWarning {
376 rule_name: Some(self.name().to_string()),
377 line: start_line,
378 column: start_col,
379 end_line,
380 end_column: end_col,
381 message: msg,
382 severity,
383 fix: None,
384 });
385 }
386 continue;
387 }
388
389 if self.config.style == LanguageStyle::Consistent
391 && let Some(preferred) = preferred_labels.get(canonical.unwrap())
392 && &block.language != preferred
393 {
394 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
395
396 let fix = find_label_span(line, &block.fence_marker).map(|(label_start, label_end)| {
397 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
398 Fix::new(
399 (line_start_byte + label_start)..(line_start_byte + label_end),
400 preferred.clone(),
401 )
402 });
403 let lang = &block.language;
404 let canonical = canonical.unwrap();
405
406 warnings.push(LintWarning {
407 rule_name: Some(self.name().to_string()),
408 line: start_line,
409 column: start_col,
410 end_line,
411 end_column: end_col,
412 message: format!("Inconsistent language label '{lang}' for {canonical} (use '{preferred}')"),
413 severity: Severity::Warning,
414 fix,
415 });
416 }
417 }
418
419 Ok(warnings)
420 }
421
422 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
423 if self.should_skip(ctx) {
424 return Ok(ctx.content.to_string());
425 }
426 let warnings = self.check(ctx)?;
427 if warnings.is_empty() {
428 return Ok(ctx.content.to_string());
429 }
430 let warnings =
431 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
432 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
433 }
434
435 fn category(&self) -> RuleCategory {
437 RuleCategory::CodeBlock
438 }
439
440 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
442 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
443 }
444
445 fn as_any(&self) -> &dyn std::any::Any {
446 self
447 }
448
449 fn default_config_section(&self) -> Option<(String, toml::Value)> {
450 let default_config = MD040Config::default();
451 let json_value = serde_json::to_value(&default_config).ok()?;
452 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
453
454 if let toml::Value::Table(table) = toml_value {
455 if !table.is_empty() {
456 Some((MD040Config::RULE_NAME.to_string(), toml::Value::Table(table)))
457 } else {
458 None
459 }
460 } else {
461 None
462 }
463 }
464
465 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
466 where
467 Self: Sized,
468 {
469 let rule_config: MD040Config = load_rule_config(config);
470 Box::new(MD040FencedCodeLanguage::with_config(rule_config))
471 }
472}
473
474fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
476 let content = ctx.content;
477 let line_offsets = &ctx.line_offsets;
478
479 ctx.code_block_details
480 .iter()
481 .filter(|d| d.is_fenced)
482 .map(|detail| {
483 let line_idx = match line_offsets.binary_search(&detail.start) {
484 Ok(idx) => idx,
485 Err(idx) => idx.saturating_sub(1),
486 };
487
488 let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
490 let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
491 let line = content.get(line_start..line_end).unwrap_or("");
492 let trimmed = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
495 let fence_marker = if trimmed.starts_with('`') {
496 let count = trimmed.chars().take_while(|&c| c == '`').count();
497 "`".repeat(count)
498 } else if trimmed.starts_with('~') {
499 let count = trimmed.chars().take_while(|&c| c == '~').count();
500 "~".repeat(count)
501 } else {
502 "```".to_string()
503 };
504
505 let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
506
507 FencedCodeBlock {
508 line_idx,
509 language,
510 fence_marker,
511 }
512 })
513 .collect()
514}
515
516fn compute_disabled_ranges(content: &str, rule_name: &str) -> Vec<(usize, usize)> {
518 let mut ranges = Vec::new();
519 let mut disabled_start: Option<usize> = None;
520
521 for (i, line) in content.lines().enumerate() {
522 let trimmed = line.trim();
523
524 if let Some(rules) = crate::inline_config::parse_disable_comment(trimmed)
525 && (rules.is_empty() || rules.contains(&rule_name))
526 && disabled_start.is_none()
527 {
528 disabled_start = Some(i);
529 }
530
531 if let Some(rules) = crate::inline_config::parse_enable_comment(trimmed)
532 && (rules.is_empty() || rules.contains(&rule_name))
533 && let Some(start) = disabled_start.take()
534 {
535 ranges.push((start, i));
536 }
537 }
538
539 if let Some(start) = disabled_start {
541 ranges.push((start, usize::MAX));
542 }
543
544 ranges
545}
546
547fn is_line_disabled(ranges: &[(usize, usize)], line_idx: usize) -> bool {
549 ranges.iter().any(|&(start, end)| line_idx >= start && line_idx < end)
550}
551
552fn fence_marker_offset(line: &str) -> usize {
558 let content = crate::utils::blockquote::strip_blockquote_prefix(line);
559 let blockquote_prefix_len = line.len() - content.len();
560 let indent_len = content.len() - content.trim_start().len();
561 blockquote_prefix_len + indent_len
562}
563
564fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
566 let marker_offset = fence_marker_offset(line);
567 let after_indent = &line[marker_offset..];
568 if !after_indent.starts_with(fence_marker) {
569 return None;
570 }
571 let after_fence = &after_indent[fence_marker.len()..];
572
573 let label_start_rel = after_fence
574 .char_indices()
575 .find(|&(_, ch)| !ch.is_whitespace())
576 .map(|(idx, _)| idx)?;
577 let after_label = &after_fence[label_start_rel..];
578 let label_end_rel = after_label
579 .char_indices()
580 .find(|&(_, ch)| ch.is_whitespace())
581 .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
582
583 Some((
584 marker_offset + fence_marker.len() + label_start_rel,
585 marker_offset + fence_marker.len() + label_end_rel,
586 ))
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::lint_context::LintContext;
593
594 fn run_check(content: &str) -> LintResult {
595 let rule = MD040FencedCodeLanguage::default();
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 rule.check(&ctx)
598 }
599
600 fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
601 let rule = MD040FencedCodeLanguage::with_config(config);
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 rule.check(&ctx)
604 }
605
606 fn run_fix(content: &str) -> Result<String, LintError> {
607 let rule = MD040FencedCodeLanguage::default();
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 rule.fix(&ctx)
610 }
611
612 fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
613 let rule = MD040FencedCodeLanguage::with_config(config);
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 rule.fix(&ctx)
616 }
617
618 fn run_check_mkdocs(content: &str) -> LintResult {
619 let rule = MD040FencedCodeLanguage::default();
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
621 rule.check(&ctx)
622 }
623
624 #[test]
629 fn test_code_blocks_with_language_specified() {
630 let content = r#"# Test
631
632```python
633print("Hello, world!")
634```
635
636```javascript
637console.log("Hello!");
638```
639"#;
640 let result = run_check(content).unwrap();
641 assert!(result.is_empty(), "No warnings expected for code blocks with language");
642 }
643
644 #[test]
645 fn test_code_blocks_without_language() {
646 let content = r#"# Test
647
648```
649print("Hello, world!")
650```
651"#;
652 let result = run_check(content).unwrap();
653 assert_eq!(result.len(), 1);
654 assert_eq!(result[0].message, "Code block (```) missing language");
655 assert_eq!(result[0].line, 3);
656 }
657
658 #[test]
659 fn test_fix_method_adds_text_language() {
660 let content = r#"# Test
661
662```
663code without language
664```
665
666```python
667already has language
668```
669
670```
671another block without
672```
673"#;
674 let fixed = run_fix(content).unwrap();
675 assert!(fixed.contains("```text"));
676 assert!(fixed.contains("```python"));
677 assert_eq!(fixed.matches("```text").count(), 2);
678 }
679
680 #[test]
681 fn test_fix_preserves_indentation() {
682 let content = r#"# Test
683
684- List item
685 ```
686 indented code block
687 ```
688"#;
689 let fixed = run_fix(content).unwrap();
690 assert!(fixed.contains(" ```text"));
691 }
692
693 #[test]
694 fn test_fix_blockquote_empty_fence() {
695 let content = "# Title\n\n> ```\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
699 let fixed = run_fix(content).unwrap();
700 let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
701 assert_eq!(fixed, expected);
702 }
703
704 #[test]
705 fn test_fix_blockquote_tilde_and_longer_fences() {
706 let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
709 assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
710
711 let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
712 assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
713
714 let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
715 assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
716 }
717
718 #[test]
719 fn test_fix_nested_blockquote_empty_fence() {
720 let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
723 assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
724
725 let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
726 assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
727 }
728
729 #[test]
730 fn test_fix_blockquote_empty_fence_is_idempotent() {
731 let content = "> ```\n> root/\n> nested\n> ```\n";
733 let once = run_fix(content).unwrap();
734 let twice = run_fix(&once).unwrap();
735 assert_eq!(once, twice);
736 assert_eq!(once, "> ```text\n> root/\n> nested\n> ```\n");
737 }
738
739 #[test]
744 fn test_consistent_mode_detects_inconsistency() {
745 let content = r#"```bash
746echo hi
747```
748
749```sh
750echo there
751```
752
753```bash
754echo again
755```
756"#;
757 let config = MD040Config {
758 style: LanguageStyle::Consistent,
759 ..Default::default()
760 };
761 let result = run_check_with_config(content, config).unwrap();
762 assert_eq!(result.len(), 1);
763 assert!(result[0].message.contains("Inconsistent"));
764 assert!(result[0].message.contains("sh"));
765 assert!(result[0].message.contains("bash"));
766 }
767
768 #[test]
769 fn test_consistent_mode_fix_normalizes() {
770 let content = r#"```bash
771echo hi
772```
773
774```sh
775echo there
776```
777
778```bash
779echo again
780```
781"#;
782 let config = MD040Config {
783 style: LanguageStyle::Consistent,
784 ..Default::default()
785 };
786 let fixed = run_fix_with_config(content, config).unwrap();
787 assert_eq!(fixed.matches("```bash").count(), 3);
788 assert_eq!(fixed.matches("```sh").count(), 0);
789 }
790
791 #[test]
792 fn test_consistent_mode_tie_break_uses_curated_default() {
793 let content = r#"```bash
795echo hi
796```
797
798```sh
799echo there
800```
801"#;
802 let config = MD040Config {
803 style: LanguageStyle::Consistent,
804 ..Default::default()
805 };
806 let fixed = run_fix_with_config(content, config).unwrap();
807 assert_eq!(fixed.matches("```bash").count(), 2);
809 }
810
811 #[test]
812 fn test_consistent_mode_with_preferred_alias() {
813 let content = r#"```bash
814echo hi
815```
816
817```sh
818echo there
819```
820"#;
821 let mut preferred = HashMap::new();
822 preferred.insert("Shell".to_string(), "sh".to_string());
823
824 let config = MD040Config {
825 style: LanguageStyle::Consistent,
826 preferred_aliases: preferred,
827 ..Default::default()
828 };
829 let fixed = run_fix_with_config(content, config).unwrap();
830 assert_eq!(fixed.matches("```sh").count(), 2);
831 assert_eq!(fixed.matches("```bash").count(), 0);
832 }
833
834 #[test]
835 fn test_consistent_mode_fix_inside_blockquote() {
836 let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
839 let config = MD040Config {
840 style: LanguageStyle::Consistent,
841 ..Default::default()
842 };
843 let fixed = run_fix_with_config(content, config).unwrap();
844 assert_eq!(
845 fixed,
846 "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
847 );
848 }
849
850 #[test]
851 fn test_consistent_mode_ignores_disabled_blocks() {
852 let content = r#"```bash
853echo hi
854```
855<!-- rumdl-disable MD040 -->
856```sh
857echo there
858```
859```sh
860echo again
861```
862<!-- rumdl-enable MD040 -->
863"#;
864 let config = MD040Config {
865 style: LanguageStyle::Consistent,
866 ..Default::default()
867 };
868 let result = run_check_with_config(content, config).unwrap();
869 assert!(result.is_empty(), "Disabled blocks should not affect consistency");
870 }
871
872 #[test]
873 fn test_fix_preserves_attributes() {
874 let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
875 let config = MD040Config {
876 style: LanguageStyle::Consistent,
877 ..Default::default()
878 };
879 let fixed = run_fix_with_config(content, config).unwrap();
880 assert!(fixed.contains("```bash {.highlight}"));
881 }
882
883 #[test]
884 fn test_fix_preserves_spacing_before_label() {
885 let content = "```bash\ncode\n```\n\n``` sh {.highlight}\ncode\n```";
886 let config = MD040Config {
887 style: LanguageStyle::Consistent,
888 ..Default::default()
889 };
890 let fixed = run_fix_with_config(content, config).unwrap();
891 assert!(fixed.contains("``` bash {.highlight}"));
892 assert!(!fixed.contains("``` sh {.highlight}"));
893 }
894
895 #[test]
900 fn test_allowlist_blocks_unlisted() {
901 let content = "```java\ncode\n```";
902 let config = MD040Config {
903 allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
904 ..Default::default()
905 };
906 let result = run_check_with_config(content, config).unwrap();
907 assert_eq!(result.len(), 1);
908 assert!(result[0].message.contains("not in the allowed list"));
909 }
910
911 #[test]
912 fn test_allowlist_allows_listed() {
913 let content = "```python\ncode\n```";
914 let config = MD040Config {
915 allowed_languages: vec!["Python".to_string()],
916 ..Default::default()
917 };
918 let result = run_check_with_config(content, config).unwrap();
919 assert!(result.is_empty());
920 }
921
922 #[test]
923 fn test_allowlist_blocks_unknown_language() {
924 let content = "```mysterylang\ncode\n```";
925 let config = MD040Config {
926 allowed_languages: vec!["Python".to_string()],
927 ..Default::default()
928 };
929 let result = run_check_with_config(content, config).unwrap();
930 assert_eq!(result.len(), 1);
931 assert!(result[0].message.contains("allowed list"));
932 }
933
934 #[test]
935 fn test_allowlist_case_insensitive() {
936 let content = "```python\ncode\n```";
937 let config = MD040Config {
938 allowed_languages: vec!["PYTHON".to_string()],
939 ..Default::default()
940 };
941 let result = run_check_with_config(content, config).unwrap();
942 assert!(result.is_empty());
943 }
944
945 #[test]
946 fn test_denylist_blocks_listed() {
947 let content = "```java\ncode\n```";
948 let config = MD040Config {
949 disallowed_languages: vec!["Java".to_string()],
950 ..Default::default()
951 };
952 let result = run_check_with_config(content, config).unwrap();
953 assert_eq!(result.len(), 1);
954 assert!(result[0].message.contains("disallowed"));
955 }
956
957 #[test]
958 fn test_denylist_allows_unlisted() {
959 let content = "```python\ncode\n```";
960 let config = MD040Config {
961 disallowed_languages: vec!["Java".to_string()],
962 ..Default::default()
963 };
964 let result = run_check_with_config(content, config).unwrap();
965 assert!(result.is_empty());
966 }
967
968 #[test]
969 fn test_allowlist_takes_precedence_over_denylist() {
970 let content = "```python\ncode\n```";
971 let config = MD040Config {
972 allowed_languages: vec!["Python".to_string()],
973 disallowed_languages: vec!["Python".to_string()], ..Default::default()
975 };
976 let result = run_check_with_config(content, config).unwrap();
977 assert!(result.is_empty());
978 }
979
980 #[test]
985 fn test_unknown_language_ignore_default() {
986 let content = "```mycustomlang\ncode\n```";
987 let result = run_check(content).unwrap();
988 assert!(result.is_empty(), "Unknown languages ignored by default");
989 }
990
991 #[test]
992 fn test_unknown_language_warn() {
993 let content = "```mycustomlang\ncode\n```";
994 let config = MD040Config {
995 unknown_language_action: UnknownLanguageAction::Warn,
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("Unknown language"));
1001 assert!(result[0].message.contains("mycustomlang"));
1002 assert_eq!(result[0].severity, Severity::Warning);
1003 }
1004
1005 #[test]
1013 fn test_unknown_language_warn_known_aliases_not_flagged() {
1014 let known_aliases = [
1015 "py",
1016 "python",
1017 "sh",
1018 "bash",
1019 "shell",
1020 "zsh",
1021 "js",
1022 "javascript",
1023 "ts",
1024 "typescript",
1025 "rb",
1026 "ruby",
1027 "rs",
1028 "rust",
1029 "yml",
1030 "yaml",
1031 "cpp",
1032 "c++",
1033 "csharp",
1034 "golang",
1035 "dockerfile",
1036 "jsonc",
1037 "kotlin",
1038 ];
1039 for alias in known_aliases {
1040 let content = format!("```{alias}\ncode\n```");
1041 let config = MD040Config {
1042 unknown_language_action: UnknownLanguageAction::Warn,
1043 ..Default::default()
1044 };
1045 let result = run_check_with_config(&content, config).unwrap();
1046 assert!(
1047 result.is_empty(),
1048 "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1049 );
1050 }
1051 }
1052
1053 #[test]
1054 fn test_unknown_language_error() {
1055 let content = "```mycustomlang\ncode\n```";
1056 let config = MD040Config {
1057 unknown_language_action: UnknownLanguageAction::Error,
1058 ..Default::default()
1059 };
1060 let result = run_check_with_config(content, config).unwrap();
1061 assert_eq!(result.len(), 1);
1062 assert!(result[0].message.contains("Unknown language"));
1063 assert_eq!(result[0].severity, Severity::Error);
1064 }
1065
1066 #[test]
1071 fn test_invalid_preferred_alias_detected() {
1072 let mut preferred = HashMap::new();
1073 preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1074
1075 let config = MD040Config {
1076 style: LanguageStyle::Consistent,
1077 preferred_aliases: preferred,
1078 ..Default::default()
1079 };
1080 let rule = MD040FencedCodeLanguage::with_config(config);
1081 let errors = rule.validate_config();
1082 assert_eq!(errors.len(), 1);
1083 assert!(errors[0].contains("Invalid alias"));
1084 assert!(errors[0].contains("invalid_alias"));
1085 }
1086
1087 #[test]
1088 fn test_unknown_language_in_preferred_aliases_detected() {
1089 let mut preferred = HashMap::new();
1090 preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1091
1092 let config = MD040Config {
1093 style: LanguageStyle::Consistent,
1094 preferred_aliases: preferred,
1095 ..Default::default()
1096 };
1097 let rule = MD040FencedCodeLanguage::with_config(config);
1098 let errors = rule.validate_config();
1099 assert_eq!(errors.len(), 1);
1100 assert!(errors[0].contains("Unknown language"));
1101 }
1102
1103 #[test]
1104 fn test_valid_preferred_alias_accepted() {
1105 let mut preferred = HashMap::new();
1106 preferred.insert("Shell".to_string(), "bash".to_string());
1107 preferred.insert("JavaScript".to_string(), "js".to_string());
1108
1109 let config = MD040Config {
1110 style: LanguageStyle::Consistent,
1111 preferred_aliases: preferred,
1112 ..Default::default()
1113 };
1114 let rule = MD040FencedCodeLanguage::with_config(config);
1115 let errors = rule.validate_config();
1116 assert!(errors.is_empty());
1117 }
1118
1119 #[test]
1120 fn test_config_error_uses_valid_line_column() {
1121 let config = md040_config::MD040Config {
1122 preferred_aliases: {
1123 let mut map = std::collections::HashMap::new();
1124 map.insert("Shell".to_string(), "invalid_alias".to_string());
1125 map
1126 },
1127 ..Default::default()
1128 };
1129 let rule = MD040FencedCodeLanguage::with_config(config);
1130
1131 let content = "```shell\necho hello\n```";
1132 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134
1135 let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1137 assert!(config_error.is_some(), "Should have a config error warning");
1138
1139 let warning = config_error.unwrap();
1140 assert!(
1142 warning.line >= 1,
1143 "Config error line should be >= 1, got {}",
1144 warning.line
1145 );
1146 assert!(
1147 warning.column >= 1,
1148 "Config error column should be >= 1, got {}",
1149 warning.column
1150 );
1151 }
1152
1153 #[test]
1158 fn test_linguist_resolution() {
1159 assert_eq!(resolve_canonical("bash"), Some("Shell"));
1160 assert_eq!(resolve_canonical("sh"), Some("Shell"));
1161 assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1162 assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1163 assert_eq!(resolve_canonical("python"), Some("Python"));
1164 assert_eq!(resolve_canonical("unknown_lang"), None);
1165 }
1166
1167 #[test]
1168 fn test_linguist_resolution_case_insensitive() {
1169 assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1170 assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1171 assert_eq!(resolve_canonical("Python"), Some("Python"));
1172 assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1173 }
1174
1175 #[test]
1176 fn test_alias_validation() {
1177 assert!(is_valid_alias("Shell", "bash"));
1178 assert!(is_valid_alias("Shell", "sh"));
1179 assert!(is_valid_alias("Shell", "zsh"));
1180 assert!(!is_valid_alias("Shell", "python"));
1181 assert!(!is_valid_alias("Shell", "invalid"));
1182 }
1183
1184 #[test]
1185 fn test_default_alias() {
1186 assert_eq!(default_alias("Shell"), Some("bash"));
1187 assert_eq!(default_alias("JavaScript"), Some("js"));
1188 assert_eq!(default_alias("Python"), Some("python"));
1189 }
1190
1191 #[test]
1196 fn test_mixed_case_labels_normalized() {
1197 let content = r#"```BASH
1198echo hi
1199```
1200
1201```Bash
1202echo there
1203```
1204
1205```bash
1206echo again
1207```
1208"#;
1209 let config = MD040Config {
1210 style: LanguageStyle::Consistent,
1211 ..Default::default()
1212 };
1213 let result = run_check_with_config(content, config).unwrap();
1215 assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1219 }
1220
1221 #[test]
1222 fn test_multiple_languages_independent() {
1223 let content = r#"```bash
1224shell code
1225```
1226
1227```python
1228python code
1229```
1230
1231```sh
1232more shell
1233```
1234
1235```python3
1236more python
1237```
1238"#;
1239 let config = MD040Config {
1240 style: LanguageStyle::Consistent,
1241 ..Default::default()
1242 };
1243 let result = run_check_with_config(content, config).unwrap();
1244 assert_eq!(result.len(), 2);
1246 }
1247
1248 #[test]
1249 fn test_tilde_fences() {
1250 let content = r#"~~~bash
1251echo hi
1252~~~
1253
1254~~~sh
1255echo there
1256~~~
1257"#;
1258 let config = MD040Config {
1259 style: LanguageStyle::Consistent,
1260 ..Default::default()
1261 };
1262 let result = run_check_with_config(content, config.clone()).unwrap();
1263 assert_eq!(result.len(), 1);
1264
1265 let fixed = run_fix_with_config(content, config).unwrap();
1266 assert!(fixed.contains("~~~bash"));
1267 assert!(!fixed.contains("~~~sh"));
1268 }
1269
1270 #[test]
1271 fn test_longer_fence_markers_preserved() {
1272 let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1273 let config = MD040Config {
1274 style: LanguageStyle::Consistent,
1275 ..Default::default()
1276 };
1277 let fixed = run_fix_with_config(content, config).unwrap();
1278 assert!(fixed.contains("````bash"));
1279 assert!(fixed.contains("```bash"));
1280 }
1281
1282 #[test]
1283 fn test_empty_document() {
1284 let result = run_check("").unwrap();
1285 assert!(result.is_empty());
1286 }
1287
1288 #[test]
1289 fn test_no_code_blocks() {
1290 let content = "# Just a heading\n\nSome text.";
1291 let result = run_check(content).unwrap();
1292 assert!(result.is_empty());
1293 }
1294
1295 #[test]
1296 fn test_single_code_block_no_inconsistency() {
1297 let content = "```bash\necho hi\n```";
1298 let config = MD040Config {
1299 style: LanguageStyle::Consistent,
1300 ..Default::default()
1301 };
1302 let result = run_check_with_config(content, config).unwrap();
1303 assert!(result.is_empty(), "Single block has no inconsistency");
1304 }
1305
1306 #[test]
1307 fn test_idempotent_fix() {
1308 let content = r#"```bash
1309echo hi
1310```
1311
1312```sh
1313echo there
1314```
1315"#;
1316 let config = MD040Config {
1317 style: LanguageStyle::Consistent,
1318 ..Default::default()
1319 };
1320 let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1321 let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1322 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1323 }
1324
1325 #[test]
1330 fn test_mkdocs_superfences_attribute_in_blockquote() {
1331 let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1334 let result = run_check_mkdocs(content).unwrap();
1335 assert!(
1336 result.is_empty(),
1337 "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_mkdocs_superfences_title_only() {
1343 let content = r#"```title="Example"
1345echo hi
1346```
1347"#;
1348 let result = run_check_mkdocs(content).unwrap();
1349 assert!(
1350 result.is_empty(),
1351 "MkDocs superfences with title= should not require language"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_mkdocs_superfences_hl_lines() {
1357 let content = r#"```hl_lines="1 2"
1359line 1
1360line 2
1361```
1362"#;
1363 let result = run_check_mkdocs(content).unwrap();
1364 assert!(
1365 result.is_empty(),
1366 "MkDocs superfences with hl_lines= should not require language"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_mkdocs_superfences_linenums() {
1372 let content = r#"```linenums="1"
1374line 1
1375line 2
1376```
1377"#;
1378 let result = run_check_mkdocs(content).unwrap();
1379 assert!(
1380 result.is_empty(),
1381 "MkDocs superfences with linenums= should not require language"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_mkdocs_superfences_class() {
1387 let content = r#"```.my-class
1389some text
1390```
1391"#;
1392 let result = run_check_mkdocs(content).unwrap();
1393 assert!(
1394 result.is_empty(),
1395 "MkDocs superfences with .class should not require language"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_mkdocs_superfences_id() {
1401 let content = r#"```#my-id
1403some text
1404```
1405"#;
1406 let result = run_check_mkdocs(content).unwrap();
1407 assert!(
1408 result.is_empty(),
1409 "MkDocs superfences with #id should not require language"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_mkdocs_superfences_with_language() {
1415 let content = r#"```python title="Example" hl_lines="1"
1417print("hello")
1418```
1419"#;
1420 let result = run_check_mkdocs(content).unwrap();
1421 assert!(result.is_empty(), "Code block with language and attrs should pass");
1422 }
1423
1424 #[test]
1425 fn test_standard_flavor_no_special_handling() {
1426 let content = r#"```title="Example"
1428echo hi
1429```
1430"#;
1431 let result = run_check(content).unwrap();
1432 assert_eq!(
1433 result.len(),
1434 1,
1435 "Standard flavor should warn about title= without language"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1441 let rule = MD040FencedCodeLanguage::default();
1444 let content = "```{=html}\n<div>raw html</div>\n```\n";
1445 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1446 let result = rule.check(&ctx).unwrap();
1447 assert!(
1448 result.is_empty(),
1449 "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1455 let rule = MD040FencedCodeLanguage::default();
1457 let content = "```{=html}\n<div>raw html</div>\n```\n";
1458 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1459 let result = rule.check(&ctx).unwrap();
1460 assert!(
1461 result.is_empty(),
1462 "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1463 );
1464 }
1465
1466 #[test]
1469 fn test_pandoc_accepts_raw_html_block() {
1470 use crate::config::MarkdownFlavor;
1471 let rule = MD040FencedCodeLanguage::default();
1472 let content = "```{=html}\n<div>raw</div>\n```\n";
1473 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1474 let result = rule.check(&ctx).unwrap();
1475 assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1476 }
1477
1478 #[test]
1481 fn test_pandoc_rejects_quarto_exec_blocks() {
1482 use crate::config::MarkdownFlavor;
1483 let rule = MD040FencedCodeLanguage::default();
1484 let content = "```{r}\nsummary(data)\n```\n";
1485 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1486 let result = rule.check(&ctx).unwrap();
1487 assert!(
1488 !result.is_empty(),
1489 "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1490 );
1491 }
1492
1493 #[test]
1495 fn test_quarto_still_accepts_exec_block() {
1496 use crate::config::MarkdownFlavor;
1497 let rule = MD040FencedCodeLanguage::default();
1498 let content = "```{r}\nsummary(data)\n```\n";
1499 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1500 let result = rule.check(&ctx).unwrap();
1501 assert!(
1502 result.is_empty(),
1503 "MD040 under Quarto should accept `{{r}}`: {result:?}"
1504 );
1505 }
1506
1507 #[test]
1508 fn test_quarto_exec_block_skipped_under_quarto_only() {
1509 let rule = MD040FencedCodeLanguage::default();
1513 let content = "```{r}\n1 + 1\n```\n";
1514
1515 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1516 let result_quarto = rule.check(&ctx_quarto).unwrap();
1517 assert!(
1518 result_quarto.is_empty(),
1519 "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1520 );
1521
1522 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1525 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1526 assert!(
1527 !result_pandoc.is_empty(),
1528 "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1529 );
1530 }
1531
1532 #[test]
1535 fn test_pandoc_class_attr_accepted_as_language() {
1536 use crate::config::MarkdownFlavor;
1537 let rule = MD040FencedCodeLanguage::default();
1538 let content = "```{.python}\nprint(\"hi\")\n```\n";
1539
1540 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1541 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1542 assert!(
1543 result_pandoc.is_empty(),
1544 "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1545 );
1546
1547 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1548 let result_quarto = rule.check(&ctx_quarto).unwrap();
1549 assert!(
1550 result_quarto.is_empty(),
1551 "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1552 );
1553 }
1554
1555 #[test]
1558 fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1559 use crate::config::MarkdownFlavor;
1560 let rule = MD040FencedCodeLanguage::default();
1561 let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1562
1563 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1564 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1565 assert!(
1566 result_pandoc.is_empty(),
1567 "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1568 );
1569
1570 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1571 let result_quarto = rule.check(&ctx_quarto).unwrap();
1572 assert!(
1573 result_quarto.is_empty(),
1574 "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1575 );
1576 }
1577
1578 #[test]
1581 fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1582 use crate::config::MarkdownFlavor;
1583 let rule = MD040FencedCodeLanguage::default();
1584 let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1585
1586 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1587 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1588 assert!(
1589 result_pandoc.is_empty(),
1590 "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1591 );
1592 }
1593
1594 #[test]
1597 fn test_standard_still_flags_pandoc_class_attr() {
1598 use crate::config::MarkdownFlavor;
1599 let rule = MD040FencedCodeLanguage::default();
1600 let content = "```{.python}\nprint(\"hi\")\n```\n";
1601
1602 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1603 let result_standard = rule.check(&ctx_standard).unwrap();
1604 assert!(
1605 !result_standard.is_empty(),
1606 "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1607 );
1608 }
1609
1610 #[test]
1613 fn test_pandoc_id_only_attr_still_flagged() {
1614 use crate::config::MarkdownFlavor;
1615 let rule = MD040FencedCodeLanguage::default();
1616 let content = "```{#myid}\ncode here\n```\n";
1617
1618 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1619 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1620 assert!(
1621 !result_pandoc.is_empty(),
1622 "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1623 );
1624 }
1625
1626 #[test]
1628 fn test_pandoc_empty_braces_still_flagged() {
1629 use crate::config::MarkdownFlavor;
1630 let rule = MD040FencedCodeLanguage::default();
1631 let content = "```{}\ncode here\n```\n";
1632
1633 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1634 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1635 assert!(
1636 !result_pandoc.is_empty(),
1637 "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1638 );
1639 }
1640}