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 crate::linguist_data::is_known_language(label) {
197 return None;
198 }
199
200 match self.config.unknown_language_action {
201 UnknownLanguageAction::Ignore => None,
202 UnknownLanguageAction::Warn => Some((
203 format!("Unknown language '{label}' (not in GitHub Linguist). Syntax highlighting may not work."),
204 Severity::Warning,
205 )),
206 UnknownLanguageAction::Error => Some((
207 format!("Unknown language '{label}' (not in GitHub Linguist)"),
208 Severity::Error,
209 )),
210 }
211 }
212}
213
214impl Rule for MD040FencedCodeLanguage {
215 fn name(&self) -> &'static str {
216 "MD040"
217 }
218
219 fn description(&self) -> &'static str {
220 "Code blocks should have a language specified"
221 }
222
223 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
224 let content = ctx.content;
225 let mut warnings = Vec::new();
226
227 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 let fenced_blocks = derive_fenced_code_blocks(ctx);
243
244 let disabled_ranges = compute_disabled_ranges(content, self.name());
246
247 let preferred_labels = if self.config.style == LanguageStyle::Consistent {
249 self.compute_preferred_labels(&fenced_blocks, &disabled_ranges)
250 } else {
251 HashMap::new()
252 };
253
254 let lines = ctx.raw_lines();
255
256 for block in &fenced_blocks {
257 if is_line_disabled(&disabled_ranges, block.line_idx) {
259 continue;
260 }
261
262 let line = lines.get(block.line_idx).unwrap_or(&"");
266 let fence_line = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
267 let after_fence = fence_line.strip_prefix(&block.fence_marker).unwrap_or("").trim();
268
269 let has_mkdocs_attrs_only =
271 ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
272
273 let is_myst_directive =
276 ctx.flavor.supports_myst_directives() && after_fence.starts_with('{') && after_fence.contains('}') && {
277 let name = after_fence.trim_start_matches('{').split('}').next().unwrap_or("");
278 !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
279 };
280
281 let is_pandoc_raw =
292 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(after_fence);
293 let is_pandoc_class_attr =
294 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(after_fence);
295 let is_quarto_exec = ctx.flavor == crate::config::MarkdownFlavor::Quarto
296 && after_fence.starts_with('{')
297 && after_fence.ends_with('}')
298 && !is_pandoc_raw
299 && !is_pandoc_class_attr;
300 let has_pandoc_or_quarto_syntax = is_pandoc_raw || is_pandoc_class_attr || is_quarto_exec;
301 let is_unrecognized_brace_syntax = after_fence.starts_with('{')
302 && after_fence.ends_with('}')
303 && !has_pandoc_or_quarto_syntax
304 && !is_myst_directive;
305
306 let needs_language = !has_mkdocs_attrs_only
307 && !is_myst_directive
308 && (block.language.is_empty()
309 || is_superfences_attribute(&block.language)
310 || is_unrecognized_brace_syntax);
311
312 if needs_language && !has_pandoc_or_quarto_syntax {
313 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
314
315 warnings.push(LintWarning {
316 rule_name: Some(self.name().to_string()),
317 line: start_line,
318 column: start_col,
319 end_line,
320 end_column: end_col,
321 message: "Code block (```) missing language".to_string(),
322 severity: Severity::Warning,
323 fix: Some(Fix::new(
324 {
325 let marker_offset = fence_marker_offset(line);
326 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
327 let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
328 let line_end_byte = line_start_byte + line.len();
332 fence_end_byte..line_end_byte
333 },
334 {
335 let line: &str = line;
336 let after_fence = &line[fence_marker_offset(line) + block.fence_marker.len()..];
337 let after_fence_trimmed = after_fence.trim();
338 if after_fence_trimmed.is_empty() {
339 "text".to_string()
340 } else {
341 format!("text {after_fence_trimmed}")
342 }
343 },
344 )),
345 });
346 continue;
347 }
348
349 if has_pandoc_or_quarto_syntax {
351 continue;
352 }
353
354 let canonical = resolve_canonical(&block.language);
355
356 if let Some(msg) = self.check_language_allowed(canonical, &block.language) {
358 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
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: msg,
367 severity: Severity::Warning,
368 fix: None,
369 });
370 continue;
371 }
372
373 if canonical.is_none() {
375 if let Some((msg, severity)) = self.check_unknown_language(&block.language) {
376 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
377
378 warnings.push(LintWarning {
379 rule_name: Some(self.name().to_string()),
380 line: start_line,
381 column: start_col,
382 end_line,
383 end_column: end_col,
384 message: msg,
385 severity,
386 fix: None,
387 });
388 }
389 continue;
390 }
391
392 if self.config.style == LanguageStyle::Consistent
394 && let Some(preferred) = preferred_labels.get(canonical.unwrap())
395 && &block.language != preferred
396 {
397 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
398
399 let fix = find_label_span(line, &block.fence_marker).map(|(label_start, label_end)| {
400 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
401 Fix::new(
402 (line_start_byte + label_start)..(line_start_byte + label_end),
403 preferred.clone(),
404 )
405 });
406 let lang = &block.language;
407 let canonical = canonical.unwrap();
408
409 warnings.push(LintWarning {
410 rule_name: Some(self.name().to_string()),
411 line: start_line,
412 column: start_col,
413 end_line,
414 end_column: end_col,
415 message: format!("Inconsistent language label '{lang}' for {canonical} (use '{preferred}')"),
416 severity: Severity::Warning,
417 fix,
418 });
419 }
420 }
421
422 Ok(warnings)
423 }
424
425 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
426 if self.should_skip(ctx) {
427 return Ok(ctx.content.to_string());
428 }
429 let warnings = self.check(ctx)?;
430 if warnings.is_empty() {
431 return Ok(ctx.content.to_string());
432 }
433 let warnings =
434 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
435 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
436 }
437
438 fn category(&self) -> RuleCategory {
440 RuleCategory::CodeBlock
441 }
442
443 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
445 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
446 }
447
448 fn as_any(&self) -> &dyn std::any::Any {
449 self
450 }
451
452 fn default_config_section(&self) -> Option<(String, toml::Value)> {
453 let default_config = MD040Config::default();
454 let json_value = serde_json::to_value(&default_config).ok()?;
455 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
456
457 if let toml::Value::Table(table) = toml_value {
458 if !table.is_empty() {
459 Some((MD040Config::RULE_NAME.to_string(), toml::Value::Table(table)))
460 } else {
461 None
462 }
463 } else {
464 None
465 }
466 }
467
468 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
469 where
470 Self: Sized,
471 {
472 let rule_config: MD040Config = load_rule_config(config);
473 Box::new(MD040FencedCodeLanguage::with_config(rule_config))
474 }
475}
476
477fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
479 let content = ctx.content;
480 let line_offsets = &ctx.line_offsets;
481
482 ctx.code_block_details
483 .iter()
484 .filter(|d| d.is_fenced)
485 .map(|detail| {
486 let line_idx = match line_offsets.binary_search(&detail.start) {
487 Ok(idx) => idx,
488 Err(idx) => idx.saturating_sub(1),
489 };
490
491 let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
493 let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
494 let line = content.get(line_start..line_end).unwrap_or("");
495 let trimmed = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
498 let fence_marker = if trimmed.starts_with('`') {
499 let count = trimmed.chars().take_while(|&c| c == '`').count();
500 "`".repeat(count)
501 } else if trimmed.starts_with('~') {
502 let count = trimmed.chars().take_while(|&c| c == '~').count();
503 "~".repeat(count)
504 } else {
505 "```".to_string()
506 };
507
508 let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
509
510 FencedCodeBlock {
511 line_idx,
512 language,
513 fence_marker,
514 }
515 })
516 .collect()
517}
518
519fn compute_disabled_ranges(content: &str, rule_name: &str) -> Vec<(usize, usize)> {
521 let mut ranges = Vec::new();
522 let mut disabled_start: Option<usize> = None;
523
524 for (i, line) in content.lines().enumerate() {
525 let trimmed = line.trim();
526
527 if let Some(rules) = crate::inline_config::parse_disable_comment(trimmed)
528 && (rules.is_empty() || rules.contains(&rule_name))
529 && disabled_start.is_none()
530 {
531 disabled_start = Some(i);
532 }
533
534 if let Some(rules) = crate::inline_config::parse_enable_comment(trimmed)
535 && (rules.is_empty() || rules.contains(&rule_name))
536 && let Some(start) = disabled_start.take()
537 {
538 ranges.push((start, i));
539 }
540 }
541
542 if let Some(start) = disabled_start {
544 ranges.push((start, usize::MAX));
545 }
546
547 ranges
548}
549
550fn is_line_disabled(ranges: &[(usize, usize)], line_idx: usize) -> bool {
552 ranges.iter().any(|&(start, end)| line_idx >= start && line_idx < end)
553}
554
555fn fence_marker_offset(line: &str) -> usize {
561 let content = crate::utils::blockquote::strip_blockquote_prefix(line);
562 let blockquote_prefix_len = line.len() - content.len();
563 let indent_len = content.len() - content.trim_start().len();
564 blockquote_prefix_len + indent_len
565}
566
567fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
569 let marker_offset = fence_marker_offset(line);
570 let after_indent = &line[marker_offset..];
571 if !after_indent.starts_with(fence_marker) {
572 return None;
573 }
574 let after_fence = &after_indent[fence_marker.len()..];
575
576 let label_start_rel = after_fence
577 .char_indices()
578 .find(|&(_, ch)| !ch.is_whitespace())
579 .map(|(idx, _)| idx)?;
580 let after_label = &after_fence[label_start_rel..];
581 let label_end_rel = after_label
582 .char_indices()
583 .find(|&(_, ch)| ch.is_whitespace())
584 .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
585
586 Some((
587 marker_offset + fence_marker.len() + label_start_rel,
588 marker_offset + fence_marker.len() + label_end_rel,
589 ))
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::lint_context::LintContext;
596
597 fn run_check(content: &str) -> LintResult {
598 let rule = MD040FencedCodeLanguage::default();
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 rule.check(&ctx)
601 }
602
603 fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
604 let rule = MD040FencedCodeLanguage::with_config(config);
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 rule.check(&ctx)
607 }
608
609 fn run_fix(content: &str) -> Result<String, LintError> {
610 let rule = MD040FencedCodeLanguage::default();
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 rule.fix(&ctx)
613 }
614
615 fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
616 let rule = MD040FencedCodeLanguage::with_config(config);
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 rule.fix(&ctx)
619 }
620
621 fn run_check_mkdocs(content: &str) -> LintResult {
622 let rule = MD040FencedCodeLanguage::default();
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
624 rule.check(&ctx)
625 }
626
627 #[test]
632 fn test_code_blocks_with_language_specified() {
633 let content = r#"# Test
634
635```python
636print("Hello, world!")
637```
638
639```javascript
640console.log("Hello!");
641```
642"#;
643 let result = run_check(content).unwrap();
644 assert!(result.is_empty(), "No warnings expected for code blocks with language");
645 }
646
647 #[test]
648 fn test_code_blocks_without_language() {
649 let content = r#"# Test
650
651```
652print("Hello, world!")
653```
654"#;
655 let result = run_check(content).unwrap();
656 assert_eq!(result.len(), 1);
657 assert_eq!(result[0].message, "Code block (```) missing language");
658 assert_eq!(result[0].line, 3);
659 }
660
661 #[test]
662 fn test_fix_method_adds_text_language() {
663 let content = r#"# Test
664
665```
666code without language
667```
668
669```python
670already has language
671```
672
673```
674another block without
675```
676"#;
677 let fixed = run_fix(content).unwrap();
678 assert!(fixed.contains("```text"));
679 assert!(fixed.contains("```python"));
680 assert_eq!(fixed.matches("```text").count(), 2);
681 }
682
683 #[test]
684 fn test_fix_preserves_indentation() {
685 let content = r#"# Test
686
687- List item
688 ```
689 indented code block
690 ```
691"#;
692 let fixed = run_fix(content).unwrap();
693 assert!(fixed.contains(" ```text"));
694 }
695
696 #[test]
697 fn test_fix_blockquote_empty_fence() {
698 let content = "# Title\n\n> ```\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
702 let fixed = run_fix(content).unwrap();
703 let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
704 assert_eq!(fixed, expected);
705 }
706
707 #[test]
708 fn test_fix_blockquote_tilde_and_longer_fences() {
709 let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
712 assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
713
714 let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
715 assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
716
717 let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
718 assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
719 }
720
721 #[test]
722 fn test_fix_nested_blockquote_empty_fence() {
723 let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
726 assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
727
728 let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
729 assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
730 }
731
732 #[test]
733 fn test_fix_blockquote_empty_fence_is_idempotent() {
734 let content = "> ```\n> root/\n> nested\n> ```\n";
736 let once = run_fix(content).unwrap();
737 let twice = run_fix(&once).unwrap();
738 assert_eq!(once, twice);
739 assert_eq!(once, "> ```text\n> root/\n> nested\n> ```\n");
740 }
741
742 #[test]
747 fn test_consistent_mode_detects_inconsistency() {
748 let content = r#"```bash
749echo hi
750```
751
752```sh
753echo there
754```
755
756```bash
757echo again
758```
759"#;
760 let config = MD040Config {
761 style: LanguageStyle::Consistent,
762 ..Default::default()
763 };
764 let result = run_check_with_config(content, config).unwrap();
765 assert_eq!(result.len(), 1);
766 assert!(result[0].message.contains("Inconsistent"));
767 assert!(result[0].message.contains("sh"));
768 assert!(result[0].message.contains("bash"));
769 }
770
771 #[test]
772 fn test_consistent_mode_fix_normalizes() {
773 let content = r#"```bash
774echo hi
775```
776
777```sh
778echo there
779```
780
781```bash
782echo again
783```
784"#;
785 let config = MD040Config {
786 style: LanguageStyle::Consistent,
787 ..Default::default()
788 };
789 let fixed = run_fix_with_config(content, config).unwrap();
790 assert_eq!(fixed.matches("```bash").count(), 3);
791 assert_eq!(fixed.matches("```sh").count(), 0);
792 }
793
794 #[test]
795 fn test_consistent_mode_tie_break_uses_curated_default() {
796 let content = r#"```bash
798echo hi
799```
800
801```sh
802echo there
803```
804"#;
805 let config = MD040Config {
806 style: LanguageStyle::Consistent,
807 ..Default::default()
808 };
809 let fixed = run_fix_with_config(content, config).unwrap();
810 assert_eq!(fixed.matches("```bash").count(), 2);
812 }
813
814 #[test]
815 fn test_consistent_mode_with_preferred_alias() {
816 let content = r#"```bash
817echo hi
818```
819
820```sh
821echo there
822```
823"#;
824 let mut preferred = HashMap::new();
825 preferred.insert("Shell".to_string(), "sh".to_string());
826
827 let config = MD040Config {
828 style: LanguageStyle::Consistent,
829 preferred_aliases: preferred,
830 ..Default::default()
831 };
832 let fixed = run_fix_with_config(content, config).unwrap();
833 assert_eq!(fixed.matches("```sh").count(), 2);
834 assert_eq!(fixed.matches("```bash").count(), 0);
835 }
836
837 #[test]
838 fn test_consistent_mode_fix_inside_blockquote() {
839 let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
842 let config = MD040Config {
843 style: LanguageStyle::Consistent,
844 ..Default::default()
845 };
846 let fixed = run_fix_with_config(content, config).unwrap();
847 assert_eq!(
848 fixed,
849 "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
850 );
851 }
852
853 #[test]
854 fn test_consistent_mode_ignores_disabled_blocks() {
855 let content = r#"```bash
856echo hi
857```
858<!-- rumdl-disable MD040 -->
859```sh
860echo there
861```
862```sh
863echo again
864```
865<!-- rumdl-enable MD040 -->
866"#;
867 let config = MD040Config {
868 style: LanguageStyle::Consistent,
869 ..Default::default()
870 };
871 let result = run_check_with_config(content, config).unwrap();
872 assert!(result.is_empty(), "Disabled blocks should not affect consistency");
873 }
874
875 #[test]
876 fn test_fix_preserves_attributes() {
877 let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
878 let config = MD040Config {
879 style: LanguageStyle::Consistent,
880 ..Default::default()
881 };
882 let fixed = run_fix_with_config(content, config).unwrap();
883 assert!(fixed.contains("```bash {.highlight}"));
884 }
885
886 #[test]
887 fn test_fix_preserves_spacing_before_label() {
888 let content = "```bash\ncode\n```\n\n``` sh {.highlight}\ncode\n```";
889 let config = MD040Config {
890 style: LanguageStyle::Consistent,
891 ..Default::default()
892 };
893 let fixed = run_fix_with_config(content, config).unwrap();
894 assert!(fixed.contains("``` bash {.highlight}"));
895 assert!(!fixed.contains("``` sh {.highlight}"));
896 }
897
898 #[test]
903 fn test_allowlist_blocks_unlisted() {
904 let content = "```java\ncode\n```";
905 let config = MD040Config {
906 allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
907 ..Default::default()
908 };
909 let result = run_check_with_config(content, config).unwrap();
910 assert_eq!(result.len(), 1);
911 assert!(result[0].message.contains("not in the allowed list"));
912 }
913
914 #[test]
915 fn test_allowlist_allows_listed() {
916 let content = "```python\ncode\n```";
917 let config = MD040Config {
918 allowed_languages: vec!["Python".to_string()],
919 ..Default::default()
920 };
921 let result = run_check_with_config(content, config).unwrap();
922 assert!(result.is_empty());
923 }
924
925 #[test]
926 fn test_allowlist_blocks_unknown_language() {
927 let content = "```mysterylang\ncode\n```";
928 let config = MD040Config {
929 allowed_languages: vec!["Python".to_string()],
930 ..Default::default()
931 };
932 let result = run_check_with_config(content, config).unwrap();
933 assert_eq!(result.len(), 1);
934 assert!(result[0].message.contains("allowed list"));
935 }
936
937 #[test]
938 fn test_allowlist_case_insensitive() {
939 let content = "```python\ncode\n```";
940 let config = MD040Config {
941 allowed_languages: vec!["PYTHON".to_string()],
942 ..Default::default()
943 };
944 let result = run_check_with_config(content, config).unwrap();
945 assert!(result.is_empty());
946 }
947
948 #[test]
949 fn test_denylist_blocks_listed() {
950 let content = "```java\ncode\n```";
951 let config = MD040Config {
952 disallowed_languages: vec!["Java".to_string()],
953 ..Default::default()
954 };
955 let result = run_check_with_config(content, config).unwrap();
956 assert_eq!(result.len(), 1);
957 assert!(result[0].message.contains("disallowed"));
958 }
959
960 #[test]
961 fn test_denylist_allows_unlisted() {
962 let content = "```python\ncode\n```";
963 let config = MD040Config {
964 disallowed_languages: vec!["Java".to_string()],
965 ..Default::default()
966 };
967 let result = run_check_with_config(content, config).unwrap();
968 assert!(result.is_empty());
969 }
970
971 #[test]
972 fn test_allowlist_takes_precedence_over_denylist() {
973 let content = "```python\ncode\n```";
974 let config = MD040Config {
975 allowed_languages: vec!["Python".to_string()],
976 disallowed_languages: vec!["Python".to_string()], ..Default::default()
978 };
979 let result = run_check_with_config(content, config).unwrap();
980 assert!(result.is_empty());
981 }
982
983 #[test]
988 fn test_unknown_language_ignore_default() {
989 let content = "```mycustomlang\ncode\n```";
990 let result = run_check(content).unwrap();
991 assert!(result.is_empty(), "Unknown languages ignored by default");
992 }
993
994 #[test]
995 fn test_unknown_language_warn() {
996 let content = "```mycustomlang\ncode\n```";
997 let config = MD040Config {
998 unknown_language_action: UnknownLanguageAction::Warn,
999 ..Default::default()
1000 };
1001 let result = run_check_with_config(content, config).unwrap();
1002 assert_eq!(result.len(), 1);
1003 assert!(result[0].message.contains("Unknown language"));
1004 assert!(result[0].message.contains("mycustomlang"));
1005 assert_eq!(result[0].severity, Severity::Warning);
1006 }
1007
1008 #[test]
1016 fn test_unknown_language_warn_known_aliases_not_flagged() {
1017 let known_aliases = [
1018 "py",
1019 "python",
1020 "sh",
1021 "bash",
1022 "shell",
1023 "zsh",
1024 "js",
1025 "javascript",
1026 "ts",
1027 "typescript",
1028 "rb",
1029 "ruby",
1030 "rs",
1031 "rust",
1032 "yml",
1033 "yaml",
1034 "cpp",
1035 "c++",
1036 "csharp",
1037 "golang",
1038 "dockerfile",
1039 "jsonc",
1040 "kotlin",
1041 ];
1042 for alias in known_aliases {
1043 let content = format!("```{alias}\ncode\n```");
1044 let config = MD040Config {
1045 unknown_language_action: UnknownLanguageAction::Warn,
1046 ..Default::default()
1047 };
1048 let result = run_check_with_config(&content, config).unwrap();
1049 assert!(
1050 result.is_empty(),
1051 "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1052 );
1053 }
1054 }
1055
1056 #[test]
1061 fn test_unknown_language_extension_labels_not_flagged() {
1062 for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
1063 let content = format!("```{label}\ncode\n```");
1064 let config = MD040Config {
1065 unknown_language_action: UnknownLanguageAction::Error,
1066 ..Default::default()
1067 };
1068 let result = run_check_with_config(&content, config).unwrap();
1069 assert!(
1070 result.is_empty(),
1071 "Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
1072 );
1073 }
1074 }
1075
1076 #[test]
1077 fn test_unknown_language_error() {
1078 let content = "```mycustomlang\ncode\n```";
1079 let config = MD040Config {
1080 unknown_language_action: UnknownLanguageAction::Error,
1081 ..Default::default()
1082 };
1083 let result = run_check_with_config(content, config).unwrap();
1084 assert_eq!(result.len(), 1);
1085 assert!(result[0].message.contains("Unknown language"));
1086 assert_eq!(result[0].severity, Severity::Error);
1087 }
1088
1089 #[test]
1094 fn test_invalid_preferred_alias_detected() {
1095 let mut preferred = HashMap::new();
1096 preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1097
1098 let config = MD040Config {
1099 style: LanguageStyle::Consistent,
1100 preferred_aliases: preferred,
1101 ..Default::default()
1102 };
1103 let rule = MD040FencedCodeLanguage::with_config(config);
1104 let errors = rule.validate_config();
1105 assert_eq!(errors.len(), 1);
1106 assert!(errors[0].contains("Invalid alias"));
1107 assert!(errors[0].contains("invalid_alias"));
1108 }
1109
1110 #[test]
1111 fn test_unknown_language_in_preferred_aliases_detected() {
1112 let mut preferred = HashMap::new();
1113 preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1114
1115 let config = MD040Config {
1116 style: LanguageStyle::Consistent,
1117 preferred_aliases: preferred,
1118 ..Default::default()
1119 };
1120 let rule = MD040FencedCodeLanguage::with_config(config);
1121 let errors = rule.validate_config();
1122 assert_eq!(errors.len(), 1);
1123 assert!(errors[0].contains("Unknown language"));
1124 }
1125
1126 #[test]
1127 fn test_valid_preferred_alias_accepted() {
1128 let mut preferred = HashMap::new();
1129 preferred.insert("Shell".to_string(), "bash".to_string());
1130 preferred.insert("JavaScript".to_string(), "js".to_string());
1131
1132 let config = MD040Config {
1133 style: LanguageStyle::Consistent,
1134 preferred_aliases: preferred,
1135 ..Default::default()
1136 };
1137 let rule = MD040FencedCodeLanguage::with_config(config);
1138 let errors = rule.validate_config();
1139 assert!(errors.is_empty());
1140 }
1141
1142 #[test]
1143 fn test_config_error_uses_valid_line_column() {
1144 let config = md040_config::MD040Config {
1145 preferred_aliases: {
1146 let mut map = std::collections::HashMap::new();
1147 map.insert("Shell".to_string(), "invalid_alias".to_string());
1148 map
1149 },
1150 ..Default::default()
1151 };
1152 let rule = MD040FencedCodeLanguage::with_config(config);
1153
1154 let content = "```shell\necho hello\n```";
1155 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156 let result = rule.check(&ctx).unwrap();
1157
1158 let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1160 assert!(config_error.is_some(), "Should have a config error warning");
1161
1162 let warning = config_error.unwrap();
1163 assert!(
1165 warning.line >= 1,
1166 "Config error line should be >= 1, got {}",
1167 warning.line
1168 );
1169 assert!(
1170 warning.column >= 1,
1171 "Config error column should be >= 1, got {}",
1172 warning.column
1173 );
1174 }
1175
1176 #[test]
1181 fn test_linguist_resolution() {
1182 assert_eq!(resolve_canonical("bash"), Some("Shell"));
1183 assert_eq!(resolve_canonical("sh"), Some("Shell"));
1184 assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1185 assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1186 assert_eq!(resolve_canonical("python"), Some("Python"));
1187 assert_eq!(resolve_canonical("unknown_lang"), None);
1188 }
1189
1190 #[test]
1191 fn test_linguist_resolution_case_insensitive() {
1192 assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1193 assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1194 assert_eq!(resolve_canonical("Python"), Some("Python"));
1195 assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1196 }
1197
1198 #[test]
1199 fn test_alias_validation() {
1200 assert!(is_valid_alias("Shell", "bash"));
1201 assert!(is_valid_alias("Shell", "sh"));
1202 assert!(is_valid_alias("Shell", "zsh"));
1203 assert!(!is_valid_alias("Shell", "python"));
1204 assert!(!is_valid_alias("Shell", "invalid"));
1205 }
1206
1207 #[test]
1208 fn test_default_alias() {
1209 assert_eq!(default_alias("Shell"), Some("bash"));
1210 assert_eq!(default_alias("JavaScript"), Some("js"));
1211 assert_eq!(default_alias("Python"), Some("python"));
1212 }
1213
1214 #[test]
1219 fn test_mixed_case_labels_normalized() {
1220 let content = r#"```BASH
1221echo hi
1222```
1223
1224```Bash
1225echo there
1226```
1227
1228```bash
1229echo again
1230```
1231"#;
1232 let config = MD040Config {
1233 style: LanguageStyle::Consistent,
1234 ..Default::default()
1235 };
1236 let result = run_check_with_config(content, config).unwrap();
1238 assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1242 }
1243
1244 #[test]
1245 fn test_multiple_languages_independent() {
1246 let content = r#"```bash
1247shell code
1248```
1249
1250```python
1251python code
1252```
1253
1254```sh
1255more shell
1256```
1257
1258```python3
1259more python
1260```
1261"#;
1262 let config = MD040Config {
1263 style: LanguageStyle::Consistent,
1264 ..Default::default()
1265 };
1266 let result = run_check_with_config(content, config).unwrap();
1267 assert_eq!(result.len(), 2);
1269 }
1270
1271 #[test]
1272 fn test_tilde_fences() {
1273 let content = r#"~~~bash
1274echo hi
1275~~~
1276
1277~~~sh
1278echo there
1279~~~
1280"#;
1281 let config = MD040Config {
1282 style: LanguageStyle::Consistent,
1283 ..Default::default()
1284 };
1285 let result = run_check_with_config(content, config.clone()).unwrap();
1286 assert_eq!(result.len(), 1);
1287
1288 let fixed = run_fix_with_config(content, config).unwrap();
1289 assert!(fixed.contains("~~~bash"));
1290 assert!(!fixed.contains("~~~sh"));
1291 }
1292
1293 #[test]
1294 fn test_longer_fence_markers_preserved() {
1295 let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1296 let config = MD040Config {
1297 style: LanguageStyle::Consistent,
1298 ..Default::default()
1299 };
1300 let fixed = run_fix_with_config(content, config).unwrap();
1301 assert!(fixed.contains("````bash"));
1302 assert!(fixed.contains("```bash"));
1303 }
1304
1305 #[test]
1306 fn test_empty_document() {
1307 let result = run_check("").unwrap();
1308 assert!(result.is_empty());
1309 }
1310
1311 #[test]
1312 fn test_no_code_blocks() {
1313 let content = "# Just a heading\n\nSome text.";
1314 let result = run_check(content).unwrap();
1315 assert!(result.is_empty());
1316 }
1317
1318 #[test]
1319 fn test_single_code_block_no_inconsistency() {
1320 let content = "```bash\necho hi\n```";
1321 let config = MD040Config {
1322 style: LanguageStyle::Consistent,
1323 ..Default::default()
1324 };
1325 let result = run_check_with_config(content, config).unwrap();
1326 assert!(result.is_empty(), "Single block has no inconsistency");
1327 }
1328
1329 #[test]
1330 fn test_idempotent_fix() {
1331 let content = r#"```bash
1332echo hi
1333```
1334
1335```sh
1336echo there
1337```
1338"#;
1339 let config = MD040Config {
1340 style: LanguageStyle::Consistent,
1341 ..Default::default()
1342 };
1343 let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1344 let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1345 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1346 }
1347
1348 #[test]
1353 fn test_mkdocs_superfences_attribute_in_blockquote() {
1354 let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1357 let result = run_check_mkdocs(content).unwrap();
1358 assert!(
1359 result.is_empty(),
1360 "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_mkdocs_superfences_title_only() {
1366 let content = r#"```title="Example"
1368echo hi
1369```
1370"#;
1371 let result = run_check_mkdocs(content).unwrap();
1372 assert!(
1373 result.is_empty(),
1374 "MkDocs superfences with title= should not require language"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_mkdocs_superfences_hl_lines() {
1380 let content = r#"```hl_lines="1 2"
1382line 1
1383line 2
1384```
1385"#;
1386 let result = run_check_mkdocs(content).unwrap();
1387 assert!(
1388 result.is_empty(),
1389 "MkDocs superfences with hl_lines= should not require language"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_mkdocs_superfences_linenums() {
1395 let content = r#"```linenums="1"
1397line 1
1398line 2
1399```
1400"#;
1401 let result = run_check_mkdocs(content).unwrap();
1402 assert!(
1403 result.is_empty(),
1404 "MkDocs superfences with linenums= should not require language"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_mkdocs_superfences_class() {
1410 let content = r#"```.my-class
1412some text
1413```
1414"#;
1415 let result = run_check_mkdocs(content).unwrap();
1416 assert!(
1417 result.is_empty(),
1418 "MkDocs superfences with .class should not require language"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_mkdocs_superfences_id() {
1424 let content = r#"```#my-id
1426some text
1427```
1428"#;
1429 let result = run_check_mkdocs(content).unwrap();
1430 assert!(
1431 result.is_empty(),
1432 "MkDocs superfences with #id should not require language"
1433 );
1434 }
1435
1436 #[test]
1437 fn test_mkdocs_superfences_with_language() {
1438 let content = r#"```python title="Example" hl_lines="1"
1440print("hello")
1441```
1442"#;
1443 let result = run_check_mkdocs(content).unwrap();
1444 assert!(result.is_empty(), "Code block with language and attrs should pass");
1445 }
1446
1447 #[test]
1448 fn test_standard_flavor_no_special_handling() {
1449 let content = r#"```title="Example"
1451echo hi
1452```
1453"#;
1454 let result = run_check(content).unwrap();
1455 assert_eq!(
1456 result.len(),
1457 1,
1458 "Standard flavor should warn about title= without language"
1459 );
1460 }
1461
1462 #[test]
1463 fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1464 let rule = MD040FencedCodeLanguage::default();
1467 let content = "```{=html}\n<div>raw html</div>\n```\n";
1468 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1469 let result = rule.check(&ctx).unwrap();
1470 assert!(
1471 result.is_empty(),
1472 "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1473 );
1474 }
1475
1476 #[test]
1477 fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1478 let rule = MD040FencedCodeLanguage::default();
1480 let content = "```{=html}\n<div>raw html</div>\n```\n";
1481 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1482 let result = rule.check(&ctx).unwrap();
1483 assert!(
1484 result.is_empty(),
1485 "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1486 );
1487 }
1488
1489 #[test]
1492 fn test_pandoc_accepts_raw_html_block() {
1493 use crate::config::MarkdownFlavor;
1494 let rule = MD040FencedCodeLanguage::default();
1495 let content = "```{=html}\n<div>raw</div>\n```\n";
1496 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1497 let result = rule.check(&ctx).unwrap();
1498 assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1499 }
1500
1501 #[test]
1504 fn test_pandoc_rejects_quarto_exec_blocks() {
1505 use crate::config::MarkdownFlavor;
1506 let rule = MD040FencedCodeLanguage::default();
1507 let content = "```{r}\nsummary(data)\n```\n";
1508 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1509 let result = rule.check(&ctx).unwrap();
1510 assert!(
1511 !result.is_empty(),
1512 "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1513 );
1514 }
1515
1516 #[test]
1518 fn test_quarto_still_accepts_exec_block() {
1519 use crate::config::MarkdownFlavor;
1520 let rule = MD040FencedCodeLanguage::default();
1521 let content = "```{r}\nsummary(data)\n```\n";
1522 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1523 let result = rule.check(&ctx).unwrap();
1524 assert!(
1525 result.is_empty(),
1526 "MD040 under Quarto should accept `{{r}}`: {result:?}"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_quarto_exec_block_skipped_under_quarto_only() {
1532 let rule = MD040FencedCodeLanguage::default();
1536 let content = "```{r}\n1 + 1\n```\n";
1537
1538 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1539 let result_quarto = rule.check(&ctx_quarto).unwrap();
1540 assert!(
1541 result_quarto.is_empty(),
1542 "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1543 );
1544
1545 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1548 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1549 assert!(
1550 !result_pandoc.is_empty(),
1551 "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1552 );
1553 }
1554
1555 #[test]
1558 fn test_pandoc_class_attr_accepted_as_language() {
1559 use crate::config::MarkdownFlavor;
1560 let rule = MD040FencedCodeLanguage::default();
1561 let content = "```{.python}\nprint(\"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 ```{{.python}}``` as language declaration: {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 ```{{.python}}``` as language declaration: {result_quarto:?}"
1575 );
1576 }
1577
1578 #[test]
1581 fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1582 use crate::config::MarkdownFlavor;
1583 let rule = MD040FencedCodeLanguage::default();
1584 let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\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 ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1591 );
1592
1593 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1594 let result_quarto = rule.check(&ctx_quarto).unwrap();
1595 assert!(
1596 result_quarto.is_empty(),
1597 "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1598 );
1599 }
1600
1601 #[test]
1604 fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1605 use crate::config::MarkdownFlavor;
1606 let rule = MD040FencedCodeLanguage::default();
1607 let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1608
1609 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1610 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1611 assert!(
1612 result_pandoc.is_empty(),
1613 "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1614 );
1615 }
1616
1617 #[test]
1620 fn test_standard_still_flags_pandoc_class_attr() {
1621 use crate::config::MarkdownFlavor;
1622 let rule = MD040FencedCodeLanguage::default();
1623 let content = "```{.python}\nprint(\"hi\")\n```\n";
1624
1625 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1626 let result_standard = rule.check(&ctx_standard).unwrap();
1627 assert!(
1628 !result_standard.is_empty(),
1629 "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1630 );
1631 }
1632
1633 #[test]
1636 fn test_pandoc_id_only_attr_still_flagged() {
1637 use crate::config::MarkdownFlavor;
1638 let rule = MD040FencedCodeLanguage::default();
1639 let content = "```{#myid}\ncode here\n```\n";
1640
1641 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1642 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1643 assert!(
1644 !result_pandoc.is_empty(),
1645 "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1646 );
1647 }
1648
1649 #[test]
1651 fn test_pandoc_empty_braces_still_flagged() {
1652 use crate::config::MarkdownFlavor;
1653 let rule = MD040FencedCodeLanguage::default();
1654 let content = "```{}\ncode here\n```\n";
1655
1656 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1657 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1658 assert!(
1659 !result_pandoc.is_empty(),
1660 "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1661 );
1662 }
1663}