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