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