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]
1006 fn test_unknown_language_error() {
1007 let content = "```mycustomlang\ncode\n```";
1008 let config = MD040Config {
1009 unknown_language_action: UnknownLanguageAction::Error,
1010 ..Default::default()
1011 };
1012 let result = run_check_with_config(content, config).unwrap();
1013 assert_eq!(result.len(), 1);
1014 assert!(result[0].message.contains("Unknown language"));
1015 assert_eq!(result[0].severity, Severity::Error);
1016 }
1017
1018 #[test]
1023 fn test_invalid_preferred_alias_detected() {
1024 let mut preferred = HashMap::new();
1025 preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1026
1027 let config = MD040Config {
1028 style: LanguageStyle::Consistent,
1029 preferred_aliases: preferred,
1030 ..Default::default()
1031 };
1032 let rule = MD040FencedCodeLanguage::with_config(config);
1033 let errors = rule.validate_config();
1034 assert_eq!(errors.len(), 1);
1035 assert!(errors[0].contains("Invalid alias"));
1036 assert!(errors[0].contains("invalid_alias"));
1037 }
1038
1039 #[test]
1040 fn test_unknown_language_in_preferred_aliases_detected() {
1041 let mut preferred = HashMap::new();
1042 preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1043
1044 let config = MD040Config {
1045 style: LanguageStyle::Consistent,
1046 preferred_aliases: preferred,
1047 ..Default::default()
1048 };
1049 let rule = MD040FencedCodeLanguage::with_config(config);
1050 let errors = rule.validate_config();
1051 assert_eq!(errors.len(), 1);
1052 assert!(errors[0].contains("Unknown language"));
1053 }
1054
1055 #[test]
1056 fn test_valid_preferred_alias_accepted() {
1057 let mut preferred = HashMap::new();
1058 preferred.insert("Shell".to_string(), "bash".to_string());
1059 preferred.insert("JavaScript".to_string(), "js".to_string());
1060
1061 let config = MD040Config {
1062 style: LanguageStyle::Consistent,
1063 preferred_aliases: preferred,
1064 ..Default::default()
1065 };
1066 let rule = MD040FencedCodeLanguage::with_config(config);
1067 let errors = rule.validate_config();
1068 assert!(errors.is_empty());
1069 }
1070
1071 #[test]
1072 fn test_config_error_uses_valid_line_column() {
1073 let config = md040_config::MD040Config {
1074 preferred_aliases: {
1075 let mut map = std::collections::HashMap::new();
1076 map.insert("Shell".to_string(), "invalid_alias".to_string());
1077 map
1078 },
1079 ..Default::default()
1080 };
1081 let rule = MD040FencedCodeLanguage::with_config(config);
1082
1083 let content = "```shell\necho hello\n```";
1084 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let result = rule.check(&ctx).unwrap();
1086
1087 let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1089 assert!(config_error.is_some(), "Should have a config error warning");
1090
1091 let warning = config_error.unwrap();
1092 assert!(
1094 warning.line >= 1,
1095 "Config error line should be >= 1, got {}",
1096 warning.line
1097 );
1098 assert!(
1099 warning.column >= 1,
1100 "Config error column should be >= 1, got {}",
1101 warning.column
1102 );
1103 }
1104
1105 #[test]
1110 fn test_linguist_resolution() {
1111 assert_eq!(resolve_canonical("bash"), Some("Shell"));
1112 assert_eq!(resolve_canonical("sh"), Some("Shell"));
1113 assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1114 assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1115 assert_eq!(resolve_canonical("python"), Some("Python"));
1116 assert_eq!(resolve_canonical("unknown_lang"), None);
1117 }
1118
1119 #[test]
1120 fn test_linguist_resolution_case_insensitive() {
1121 assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1122 assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1123 assert_eq!(resolve_canonical("Python"), Some("Python"));
1124 assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1125 }
1126
1127 #[test]
1128 fn test_alias_validation() {
1129 assert!(is_valid_alias("Shell", "bash"));
1130 assert!(is_valid_alias("Shell", "sh"));
1131 assert!(is_valid_alias("Shell", "zsh"));
1132 assert!(!is_valid_alias("Shell", "python"));
1133 assert!(!is_valid_alias("Shell", "invalid"));
1134 }
1135
1136 #[test]
1137 fn test_default_alias() {
1138 assert_eq!(default_alias("Shell"), Some("bash"));
1139 assert_eq!(default_alias("JavaScript"), Some("js"));
1140 assert_eq!(default_alias("Python"), Some("python"));
1141 }
1142
1143 #[test]
1148 fn test_mixed_case_labels_normalized() {
1149 let content = r#"```BASH
1150echo hi
1151```
1152
1153```Bash
1154echo there
1155```
1156
1157```bash
1158echo again
1159```
1160"#;
1161 let config = MD040Config {
1162 style: LanguageStyle::Consistent,
1163 ..Default::default()
1164 };
1165 let result = run_check_with_config(content, config).unwrap();
1167 assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1171 }
1172
1173 #[test]
1174 fn test_multiple_languages_independent() {
1175 let content = r#"```bash
1176shell code
1177```
1178
1179```python
1180python code
1181```
1182
1183```sh
1184more shell
1185```
1186
1187```python3
1188more python
1189```
1190"#;
1191 let config = MD040Config {
1192 style: LanguageStyle::Consistent,
1193 ..Default::default()
1194 };
1195 let result = run_check_with_config(content, config).unwrap();
1196 assert_eq!(result.len(), 2);
1198 }
1199
1200 #[test]
1201 fn test_tilde_fences() {
1202 let content = r#"~~~bash
1203echo hi
1204~~~
1205
1206~~~sh
1207echo there
1208~~~
1209"#;
1210 let config = MD040Config {
1211 style: LanguageStyle::Consistent,
1212 ..Default::default()
1213 };
1214 let result = run_check_with_config(content, config.clone()).unwrap();
1215 assert_eq!(result.len(), 1);
1216
1217 let fixed = run_fix_with_config(content, config).unwrap();
1218 assert!(fixed.contains("~~~bash"));
1219 assert!(!fixed.contains("~~~sh"));
1220 }
1221
1222 #[test]
1223 fn test_longer_fence_markers_preserved() {
1224 let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1225 let config = MD040Config {
1226 style: LanguageStyle::Consistent,
1227 ..Default::default()
1228 };
1229 let fixed = run_fix_with_config(content, config).unwrap();
1230 assert!(fixed.contains("````bash"));
1231 assert!(fixed.contains("```bash"));
1232 }
1233
1234 #[test]
1235 fn test_empty_document() {
1236 let result = run_check("").unwrap();
1237 assert!(result.is_empty());
1238 }
1239
1240 #[test]
1241 fn test_no_code_blocks() {
1242 let content = "# Just a heading\n\nSome text.";
1243 let result = run_check(content).unwrap();
1244 assert!(result.is_empty());
1245 }
1246
1247 #[test]
1248 fn test_single_code_block_no_inconsistency() {
1249 let content = "```bash\necho hi\n```";
1250 let config = MD040Config {
1251 style: LanguageStyle::Consistent,
1252 ..Default::default()
1253 };
1254 let result = run_check_with_config(content, config).unwrap();
1255 assert!(result.is_empty(), "Single block has no inconsistency");
1256 }
1257
1258 #[test]
1259 fn test_idempotent_fix() {
1260 let content = r#"```bash
1261echo hi
1262```
1263
1264```sh
1265echo there
1266```
1267"#;
1268 let config = MD040Config {
1269 style: LanguageStyle::Consistent,
1270 ..Default::default()
1271 };
1272 let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1273 let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1274 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1275 }
1276
1277 #[test]
1282 fn test_mkdocs_superfences_attribute_in_blockquote() {
1283 let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1286 let result = run_check_mkdocs(content).unwrap();
1287 assert!(
1288 result.is_empty(),
1289 "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_mkdocs_superfences_title_only() {
1295 let content = r#"```title="Example"
1297echo hi
1298```
1299"#;
1300 let result = run_check_mkdocs(content).unwrap();
1301 assert!(
1302 result.is_empty(),
1303 "MkDocs superfences with title= should not require language"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_mkdocs_superfences_hl_lines() {
1309 let content = r#"```hl_lines="1 2"
1311line 1
1312line 2
1313```
1314"#;
1315 let result = run_check_mkdocs(content).unwrap();
1316 assert!(
1317 result.is_empty(),
1318 "MkDocs superfences with hl_lines= should not require language"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_mkdocs_superfences_linenums() {
1324 let content = r#"```linenums="1"
1326line 1
1327line 2
1328```
1329"#;
1330 let result = run_check_mkdocs(content).unwrap();
1331 assert!(
1332 result.is_empty(),
1333 "MkDocs superfences with linenums= should not require language"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_mkdocs_superfences_class() {
1339 let content = r#"```.my-class
1341some text
1342```
1343"#;
1344 let result = run_check_mkdocs(content).unwrap();
1345 assert!(
1346 result.is_empty(),
1347 "MkDocs superfences with .class should not require language"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_mkdocs_superfences_id() {
1353 let content = r#"```#my-id
1355some text
1356```
1357"#;
1358 let result = run_check_mkdocs(content).unwrap();
1359 assert!(
1360 result.is_empty(),
1361 "MkDocs superfences with #id should not require language"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_mkdocs_superfences_with_language() {
1367 let content = r#"```python title="Example" hl_lines="1"
1369print("hello")
1370```
1371"#;
1372 let result = run_check_mkdocs(content).unwrap();
1373 assert!(result.is_empty(), "Code block with language and attrs should pass");
1374 }
1375
1376 #[test]
1377 fn test_standard_flavor_no_special_handling() {
1378 let content = r#"```title="Example"
1380echo hi
1381```
1382"#;
1383 let result = run_check(content).unwrap();
1384 assert_eq!(
1385 result.len(),
1386 1,
1387 "Standard flavor should warn about title= without language"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1393 let rule = MD040FencedCodeLanguage::default();
1396 let content = "```{=html}\n<div>raw html</div>\n```\n";
1397 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1398 let result = rule.check(&ctx).unwrap();
1399 assert!(
1400 result.is_empty(),
1401 "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1402 );
1403 }
1404
1405 #[test]
1406 fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1407 let rule = MD040FencedCodeLanguage::default();
1409 let content = "```{=html}\n<div>raw html</div>\n```\n";
1410 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1411 let result = rule.check(&ctx).unwrap();
1412 assert!(
1413 result.is_empty(),
1414 "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1415 );
1416 }
1417
1418 #[test]
1421 fn test_pandoc_accepts_raw_html_block() {
1422 use crate::config::MarkdownFlavor;
1423 let rule = MD040FencedCodeLanguage::default();
1424 let content = "```{=html}\n<div>raw</div>\n```\n";
1425 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1426 let result = rule.check(&ctx).unwrap();
1427 assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1428 }
1429
1430 #[test]
1433 fn test_pandoc_rejects_quarto_exec_blocks() {
1434 use crate::config::MarkdownFlavor;
1435 let rule = MD040FencedCodeLanguage::default();
1436 let content = "```{r}\nsummary(data)\n```\n";
1437 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1438 let result = rule.check(&ctx).unwrap();
1439 assert!(
1440 !result.is_empty(),
1441 "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1442 );
1443 }
1444
1445 #[test]
1447 fn test_quarto_still_accepts_exec_block() {
1448 use crate::config::MarkdownFlavor;
1449 let rule = MD040FencedCodeLanguage::default();
1450 let content = "```{r}\nsummary(data)\n```\n";
1451 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1452 let result = rule.check(&ctx).unwrap();
1453 assert!(
1454 result.is_empty(),
1455 "MD040 under Quarto should accept `{{r}}`: {result:?}"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_quarto_exec_block_skipped_under_quarto_only() {
1461 let rule = MD040FencedCodeLanguage::default();
1465 let content = "```{r}\n1 + 1\n```\n";
1466
1467 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1468 let result_quarto = rule.check(&ctx_quarto).unwrap();
1469 assert!(
1470 result_quarto.is_empty(),
1471 "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1472 );
1473
1474 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1477 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1478 assert!(
1479 !result_pandoc.is_empty(),
1480 "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1481 );
1482 }
1483
1484 #[test]
1487 fn test_pandoc_class_attr_accepted_as_language() {
1488 use crate::config::MarkdownFlavor;
1489 let rule = MD040FencedCodeLanguage::default();
1490 let content = "```{.python}\nprint(\"hi\")\n```\n";
1491
1492 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1493 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1494 assert!(
1495 result_pandoc.is_empty(),
1496 "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1497 );
1498
1499 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1500 let result_quarto = rule.check(&ctx_quarto).unwrap();
1501 assert!(
1502 result_quarto.is_empty(),
1503 "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1504 );
1505 }
1506
1507 #[test]
1510 fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1511 use crate::config::MarkdownFlavor;
1512 let rule = MD040FencedCodeLanguage::default();
1513 let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1514
1515 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1516 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1517 assert!(
1518 result_pandoc.is_empty(),
1519 "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1520 );
1521
1522 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1523 let result_quarto = rule.check(&ctx_quarto).unwrap();
1524 assert!(
1525 result_quarto.is_empty(),
1526 "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1527 );
1528 }
1529
1530 #[test]
1533 fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1534 use crate::config::MarkdownFlavor;
1535 let rule = MD040FencedCodeLanguage::default();
1536 let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1537
1538 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1539 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1540 assert!(
1541 result_pandoc.is_empty(),
1542 "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1543 );
1544 }
1545
1546 #[test]
1549 fn test_standard_still_flags_pandoc_class_attr() {
1550 use crate::config::MarkdownFlavor;
1551 let rule = MD040FencedCodeLanguage::default();
1552 let content = "```{.python}\nprint(\"hi\")\n```\n";
1553
1554 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1555 let result_standard = rule.check(&ctx_standard).unwrap();
1556 assert!(
1557 !result_standard.is_empty(),
1558 "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1559 );
1560 }
1561
1562 #[test]
1565 fn test_pandoc_id_only_attr_still_flagged() {
1566 use crate::config::MarkdownFlavor;
1567 let rule = MD040FencedCodeLanguage::default();
1568 let content = "```{#myid}\ncode here\n```\n";
1569
1570 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1571 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1572 assert!(
1573 !result_pandoc.is_empty(),
1574 "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1575 );
1576 }
1577
1578 #[test]
1580 fn test_pandoc_empty_braces_still_flagged() {
1581 use crate::config::MarkdownFlavor;
1582 let rule = MD040FencedCodeLanguage::default();
1583 let content = "```{}\ncode here\n```\n";
1584
1585 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1586 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1587 assert!(
1588 !result_pandoc.is_empty(),
1589 "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1590 );
1591 }
1592}