1use crate::linguist_data::{default_alias, resolve_canonical};
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::{RuleConfig, load_rule_config};
4use crate::utils::range_utils::calculate_line_range;
5use std::collections::HashMap;
6
7pub mod md040_config;
11
12const MKDOCS_SUPERFENCES_ATTR_PREFIXES: &[&str] = &[
20 "title=", "hl_lines=", "linenums=", ".", "#", ];
26
27#[inline]
29fn is_superfences_attribute(s: &str) -> bool {
30 MKDOCS_SUPERFENCES_ATTR_PREFIXES
31 .iter()
32 .any(|prefix| s.starts_with(prefix))
33}
34use md040_config::{LanguageStyle, MD040Config, UnknownLanguageAction};
35
36struct FencedCodeBlock {
37 line_idx: usize,
39 language: String,
41 fence_marker: String,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct MD040FencedCodeLanguage {
47 config: MD040Config,
48}
49
50impl MD040FencedCodeLanguage {
51 pub fn with_config(config: MD040Config) -> Self {
52 Self { config }
53 }
54
55 fn resolve_language(&self, label: &str) -> Option<&str> {
62 resolve_canonical(label).or_else(|| self.config.custom_language(label))
63 }
64
65 fn validate_config(&self) -> Vec<String> {
67 let mut errors = Vec::new();
68
69 for declared in &self.config.custom_languages {
73 if declared.trim().is_empty() {
74 errors.push("Empty entry in custom-languages.".to_string());
75 } else if declared.chars().any(char::is_whitespace) {
76 errors.push(format!(
77 "Custom language '{declared}' contains whitespace, so no fence label can match it."
78 ));
79 }
80 }
81
82 errors.extend(
83 self.config
84 .preferred_aliases
85 .iter()
86 .filter_map(|(language, alias)| self.config.preferred_alias_problem(language, alias)),
87 );
88
89 errors
90 }
91
92 fn is_disabled_at(&self, ctx: &crate::lint_context::LintContext, block: &FencedCodeBlock) -> bool {
97 ctx.is_rule_disabled(self.name(), block.line_idx + 1)
98 }
99
100 fn compute_preferred_labels(
102 &self,
103 ctx: &crate::lint_context::LintContext,
104 blocks: &[FencedCodeBlock],
105 ) -> HashMap<String, String> {
106 let mut by_canonical: HashMap<String, Vec<&str>> = HashMap::new();
108
109 for block in blocks {
110 if self.is_disabled_at(ctx, block) {
111 continue;
112 }
113 if block.language.is_empty() {
114 continue;
115 }
116 if let Some(canonical) = self.resolve_language(&block.language) {
117 by_canonical
118 .entry(canonical.to_string())
119 .or_default()
120 .push(&block.language);
121 }
122 }
123
124 let mut result = HashMap::new();
126
127 for (canonical, labels) in by_canonical {
128 let winner = if let Some(preferred) = self.config.preferred_label(&canonical) {
130 preferred.to_string()
131 } else {
132 let mut counts: HashMap<&str, usize> = HashMap::new();
134 for label in &labels {
135 *counts.entry(*label).or_default() += 1;
136 }
137
138 let max_count = counts.values().max().copied().unwrap_or(0);
139 let winners: Vec<_> = counts
140 .iter()
141 .filter(|(_, c)| **c == max_count)
142 .map(|(l, _)| *l)
143 .collect();
144
145 if winners.len() == 1 {
146 winners[0].to_string()
147 } else {
148 default_alias(&canonical)
151 .or_else(|| self.config.custom_language(&canonical))
152 .filter(|default| winners.contains(default))
153 .map_or_else(
154 || winners.into_iter().min().unwrap().to_string(),
155 std::string::ToString::to_string,
156 )
157 }
158 };
159
160 result.insert(canonical, winner);
161 }
162
163 result
164 }
165
166 fn check_language_allowed(&self, canonical: Option<&str>, original_label: &str) -> Option<String> {
168 if !self.config.allowed_languages.is_empty() {
170 let allowed = self.config.allowed_languages.join(", ");
171 let Some(canonical) = canonical else {
172 return Some(format!(
173 "Language '{original_label}' is not in the allowed list: {allowed}"
174 ));
175 };
176 if !self
177 .config
178 .allowed_languages
179 .iter()
180 .any(|a| a.eq_ignore_ascii_case(canonical))
181 {
182 return Some(format!(
183 "Language '{original_label}' ({canonical}) is not in the allowed list: {allowed}"
184 ));
185 }
186 } else if !self.config.disallowed_languages.is_empty()
187 && canonical.is_some_and(|canonical| {
188 self.config
189 .disallowed_languages
190 .iter()
191 .any(|d| d.eq_ignore_ascii_case(canonical))
192 })
193 {
194 let canonical = canonical.unwrap_or("unknown");
195 return Some(format!("Language '{original_label}' ({canonical}) is disallowed"));
196 }
197 None
198 }
199
200 fn check_unknown_language(&self, label: &str) -> Option<(String, Severity)> {
202 if crate::linguist_data::is_known_language(label) || self.config.custom_language(label).is_some() {
206 return None;
207 }
208
209 match self.config.unknown_language_action {
210 UnknownLanguageAction::Ignore => None,
211 UnknownLanguageAction::Warn => Some((
212 format!(
213 "Unknown language '{label}' (not in GitHub Linguist). Syntax highlighting may not work. Add it to custom-languages to accept it."
214 ),
215 Severity::Warning,
216 )),
217 UnknownLanguageAction::Error => Some((
218 format!(
219 "Unknown language '{label}' (not in GitHub Linguist). Add it to custom-languages to accept it."
220 ),
221 Severity::Error,
222 )),
223 }
224 }
225}
226
227impl Rule for MD040FencedCodeLanguage {
228 fn name(&self) -> &'static str {
229 "MD040"
230 }
231
232 fn description(&self) -> &'static str {
233 "Code blocks should have a language specified"
234 }
235
236 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
237 let mut warnings = Vec::new();
238
239 for error in self.validate_config() {
241 warnings.push(LintWarning {
242 rule_name: Some(self.name().to_string()),
243 line: 1,
244 column: 1,
245 end_line: 1,
246 end_column: 1,
247 message: format!("[config error] {error}"),
248 severity: Severity::Error,
249 fix: None,
250 });
251 }
252
253 let fenced_blocks = derive_fenced_code_blocks(ctx);
255
256 let preferred_labels = if self.config.style == LanguageStyle::Consistent {
258 self.compute_preferred_labels(ctx, &fenced_blocks)
259 } else {
260 HashMap::new()
261 };
262
263 let lines = ctx.raw_lines();
264
265 for block in &fenced_blocks {
266 if self.is_disabled_at(ctx, block) {
267 continue;
268 }
269
270 let line = lines.get(block.line_idx).unwrap_or(&"");
274 let fence_line = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
275 let after_fence = fence_line.strip_prefix(&block.fence_marker).unwrap_or("").trim();
276
277 let has_mkdocs_attrs_only =
279 ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_superfences_attribute(after_fence);
280
281 let is_myst_directive =
284 ctx.flavor.supports_myst_directives() && after_fence.starts_with('{') && after_fence.contains('}') && {
285 let name = after_fence.trim_start_matches('{').split('}').next().unwrap_or("");
286 !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
287 };
288
289 let is_pandoc_raw =
300 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(after_fence);
301 let is_pandoc_class_attr =
302 ctx.flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(after_fence);
303 let is_quarto_exec = ctx.flavor == crate::config::MarkdownFlavor::Quarto
304 && after_fence.starts_with('{')
305 && after_fence.ends_with('}')
306 && !is_pandoc_raw
307 && !is_pandoc_class_attr;
308 let has_pandoc_or_quarto_syntax = is_pandoc_raw || is_pandoc_class_attr || is_quarto_exec;
309 let is_unrecognized_brace_syntax = after_fence.starts_with('{')
310 && after_fence.ends_with('}')
311 && !has_pandoc_or_quarto_syntax
312 && !is_myst_directive;
313
314 let needs_language = !has_mkdocs_attrs_only
315 && !is_myst_directive
316 && (block.language.is_empty()
317 || is_superfences_attribute(&block.language)
318 || is_unrecognized_brace_syntax);
319
320 if needs_language && !has_pandoc_or_quarto_syntax {
321 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
322
323 warnings.push(LintWarning {
324 rule_name: Some(self.name().to_string()),
325 line: start_line,
326 column: start_col,
327 end_line,
328 end_column: end_col,
329 message: "Code block (```) missing language".to_string(),
330 severity: Severity::Warning,
331 fix: Some(Fix::new(
332 {
333 let marker_offset = fence_marker_offset(line);
334 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
335 let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
336 let line_end_byte = line_start_byte + line.len();
340 fence_end_byte..line_end_byte
341 },
342 {
343 let line: &str = line;
344 let after_fence = &line[fence_marker_offset(line) + block.fence_marker.len()..];
345 let after_fence_trimmed = after_fence.trim();
346 if after_fence_trimmed.is_empty() {
347 "text".to_string()
348 } else {
349 format!("text {after_fence_trimmed}")
350 }
351 },
352 )),
353 });
354 continue;
355 }
356
357 if has_pandoc_or_quarto_syntax {
359 continue;
360 }
361
362 let canonical = self.resolve_language(&block.language);
363
364 if let Some(msg) = self.check_language_allowed(canonical, &block.language) {
366 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
367
368 warnings.push(LintWarning {
369 rule_name: Some(self.name().to_string()),
370 line: start_line,
371 column: start_col,
372 end_line,
373 end_column: end_col,
374 message: msg,
375 severity: Severity::Warning,
376 fix: None,
377 });
378 continue;
379 }
380
381 if canonical.is_none() {
383 if let Some((msg, severity)) = self.check_unknown_language(&block.language) {
384 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
385
386 warnings.push(LintWarning {
387 rule_name: Some(self.name().to_string()),
388 line: start_line,
389 column: start_col,
390 end_line,
391 end_column: end_col,
392 message: msg,
393 severity,
394 fix: None,
395 });
396 }
397 continue;
398 }
399
400 if self.config.style == LanguageStyle::Consistent
402 && let Some(preferred) = preferred_labels.get(canonical.unwrap())
403 && &block.language != preferred
404 {
405 let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
406
407 let fix = find_label_span(line, &block.fence_marker).map(|(label_start, label_end)| {
408 let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
409 Fix::new(
410 (line_start_byte + label_start)..(line_start_byte + label_end),
411 preferred.clone(),
412 )
413 });
414 let lang = &block.language;
415 let canonical = canonical.unwrap();
416
417 warnings.push(LintWarning {
418 rule_name: Some(self.name().to_string()),
419 line: start_line,
420 column: start_col,
421 end_line,
422 end_column: end_col,
423 message: format!("Inconsistent language label '{lang}' for {canonical} (use '{preferred}')"),
424 severity: Severity::Warning,
425 fix,
426 });
427 }
428 }
429
430 Ok(warnings)
431 }
432
433 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
434 if self.should_skip(ctx) {
435 return Ok(ctx.content.to_string());
436 }
437 let warnings = self.check(ctx)?;
438 if warnings.is_empty() {
439 return Ok(ctx.content.to_string());
440 }
441 let warnings =
442 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
443 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
444 }
445
446 fn category(&self) -> RuleCategory {
448 RuleCategory::CodeBlock
449 }
450
451 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
453 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
454 }
455
456 fn as_any(&self) -> &dyn std::any::Any {
457 self
458 }
459
460 fn default_config_section(&self) -> Option<(String, toml::Value)> {
461 let default_config = MD040Config::default();
462 let json_value = serde_json::to_value(&default_config).ok()?;
463 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
464
465 if let toml::Value::Table(table) = toml_value {
466 if !table.is_empty() {
467 Some((MD040Config::RULE_NAME.to_string(), toml::Value::Table(table)))
468 } else {
469 None
470 }
471 } else {
472 None
473 }
474 }
475
476 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
477 where
478 Self: Sized,
479 {
480 let rule_config: MD040Config = load_rule_config(config);
481 Box::new(MD040FencedCodeLanguage::with_config(rule_config))
482 }
483}
484
485fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<FencedCodeBlock> {
487 let content = ctx.content;
488 let line_offsets = &ctx.line_offsets;
489
490 ctx.code_block_details
491 .iter()
492 .filter(|d| d.is_fenced)
493 .map(|detail| {
494 let line_idx = match line_offsets.binary_search(&detail.start) {
495 Ok(idx) => idx,
496 Err(idx) => idx.saturating_sub(1),
497 };
498
499 let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
501 let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
502 let line = content.get(line_start..line_end).unwrap_or("");
503 let trimmed = crate::utils::blockquote::strip_blockquote_prefix(line).trim();
506 let fence_marker = if trimmed.starts_with('`') {
507 let count = trimmed.chars().take_while(|&c| c == '`').count();
508 "`".repeat(count)
509 } else if trimmed.starts_with('~') {
510 let count = trimmed.chars().take_while(|&c| c == '~').count();
511 "~".repeat(count)
512 } else {
513 "```".to_string()
514 };
515
516 let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
517
518 FencedCodeBlock {
519 line_idx,
520 language,
521 fence_marker,
522 }
523 })
524 .collect()
525}
526
527fn fence_marker_offset(line: &str) -> usize {
533 let content = crate::utils::blockquote::strip_blockquote_prefix(line);
534 let blockquote_prefix_len = line.len() - content.len();
535 let indent_len = content.len() - content.trim_start().len();
536 blockquote_prefix_len + indent_len
537}
538
539fn find_label_span(line: &str, fence_marker: &str) -> Option<(usize, usize)> {
541 let marker_offset = fence_marker_offset(line);
542 let after_indent = &line[marker_offset..];
543 if !after_indent.starts_with(fence_marker) {
544 return None;
545 }
546 let after_fence = &after_indent[fence_marker.len()..];
547
548 let label_start_rel = after_fence
549 .char_indices()
550 .find(|&(_, ch)| !ch.is_whitespace())
551 .map(|(idx, _)| idx)?;
552 let after_label = &after_fence[label_start_rel..];
553 let label_end_rel = after_label
554 .char_indices()
555 .find(|&(_, ch)| ch.is_whitespace())
556 .map_or(after_fence.len(), |(idx, _)| label_start_rel + idx);
557
558 Some((
559 marker_offset + fence_marker.len() + label_start_rel,
560 marker_offset + fence_marker.len() + label_end_rel,
561 ))
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::lint_context::LintContext;
568
569 fn run_check(content: &str) -> LintResult {
570 let rule = MD040FencedCodeLanguage::default();
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 rule.check(&ctx)
573 }
574
575 fn run_check_with_config(content: &str, config: MD040Config) -> LintResult {
576 let rule = MD040FencedCodeLanguage::with_config(config);
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 rule.check(&ctx)
579 }
580
581 fn run_fix(content: &str) -> Result<String, LintError> {
582 let rule = MD040FencedCodeLanguage::default();
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 rule.fix(&ctx)
585 }
586
587 fn run_fix_with_config(content: &str, config: MD040Config) -> Result<String, LintError> {
588 let rule = MD040FencedCodeLanguage::with_config(config);
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 rule.fix(&ctx)
591 }
592
593 fn run_check_mkdocs(content: &str) -> LintResult {
594 let rule = MD040FencedCodeLanguage::default();
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
596 rule.check(&ctx)
597 }
598
599 #[test]
604 fn test_code_blocks_with_language_specified() {
605 let content = r#"# Test
606
607```python
608print("Hello, world!")
609```
610
611```javascript
612console.log("Hello!");
613```
614"#;
615 let result = run_check(content).unwrap();
616 assert!(result.is_empty(), "No warnings expected for code blocks with language");
617 }
618
619 #[test]
620 fn test_code_blocks_without_language() {
621 let content = r#"# Test
622
623```
624print("Hello, world!")
625```
626"#;
627 let result = run_check(content).unwrap();
628 assert_eq!(result.len(), 1);
629 assert_eq!(result[0].message, "Code block (```) missing language");
630 assert_eq!(result[0].line, 3);
631 }
632
633 #[test]
634 fn test_fix_method_adds_text_language() {
635 let content = r#"# Test
636
637```
638code without language
639```
640
641```python
642already has language
643```
644
645```
646another block without
647```
648"#;
649 let fixed = run_fix(content).unwrap();
650 assert!(fixed.contains("```text"));
651 assert!(fixed.contains("```python"));
652 assert_eq!(fixed.matches("```text").count(), 2);
653 }
654
655 #[test]
656 fn test_fix_preserves_indentation() {
657 let content = r#"# Test
658
659- List item
660 ```
661 indented code block
662 ```
663"#;
664 let fixed = run_fix(content).unwrap();
665 assert!(fixed.contains(" ```text"));
666 }
667
668 #[test]
669 fn test_fix_blockquote_empty_fence() {
670 let content = "# Title\n\n> ```\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
674 let fixed = run_fix(content).unwrap();
675 let expected = "# Title\n\n> ```text\n> root/\n> └── nested/\n> └── file.txt\n> ```\n";
676 assert_eq!(fixed, expected);
677 }
678
679 #[test]
680 fn test_fix_blockquote_tilde_and_longer_fences() {
681 let tilde = run_fix("> ~~~\n> code\n> ~~~\n").unwrap();
684 assert_eq!(tilde, "> ~~~text\n> code\n> ~~~\n");
685
686 let longer = run_fix("> ~~~~\n> code\n> ~~~~\n").unwrap();
687 assert_eq!(longer, "> ~~~~text\n> code\n> ~~~~\n");
688
689 let longer_backtick = run_fix("> ````\n> code\n> ````\n").unwrap();
690 assert_eq!(longer_backtick, "> ````text\n> code\n> ````\n");
691 }
692
693 #[test]
694 fn test_fix_nested_blockquote_empty_fence() {
695 let compact = run_fix(">> ```\n>> code\n>> ```\n").unwrap();
698 assert_eq!(compact, ">> ```text\n>> code\n>> ```\n");
699
700 let spaced = run_fix("> > ```\n> > code\n> > ```\n").unwrap();
701 assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
702 }
703
704 #[test]
705 fn test_fix_blockquote_empty_fence_is_idempotent() {
706 let content = "> ```\n> root/\n> nested\n> ```\n";
708 let once = run_fix(content).unwrap();
709 let twice = run_fix(&once).unwrap();
710 assert_eq!(once, twice);
711 assert_eq!(once, "> ```text\n> root/\n> nested\n> ```\n");
712 }
713
714 #[test]
719 fn test_consistent_mode_detects_inconsistency() {
720 let content = r#"```bash
721echo hi
722```
723
724```sh
725echo there
726```
727
728```bash
729echo again
730```
731"#;
732 let config = MD040Config {
733 style: LanguageStyle::Consistent,
734 ..Default::default()
735 };
736 let result = run_check_with_config(content, config).unwrap();
737 assert_eq!(result.len(), 1);
738 assert!(result[0].message.contains("Inconsistent"));
739 assert!(result[0].message.contains("sh"));
740 assert!(result[0].message.contains("bash"));
741 }
742
743 #[test]
744 fn test_consistent_mode_fix_normalizes() {
745 let content = r#"```bash
746echo hi
747```
748
749```sh
750echo there
751```
752
753```bash
754echo again
755```
756"#;
757 let config = MD040Config {
758 style: LanguageStyle::Consistent,
759 ..Default::default()
760 };
761 let fixed = run_fix_with_config(content, config).unwrap();
762 assert_eq!(fixed.matches("```bash").count(), 3);
763 assert_eq!(fixed.matches("```sh").count(), 0);
764 }
765
766 #[test]
767 fn test_consistent_mode_tie_break_uses_curated_default() {
768 let content = r#"```bash
770echo hi
771```
772
773```sh
774echo there
775```
776"#;
777 let config = MD040Config {
778 style: LanguageStyle::Consistent,
779 ..Default::default()
780 };
781 let fixed = run_fix_with_config(content, config).unwrap();
782 assert_eq!(fixed.matches("```bash").count(), 2);
784 }
785
786 #[test]
787 fn test_consistent_mode_with_preferred_alias() {
788 let content = r#"```bash
789echo hi
790```
791
792```sh
793echo there
794```
795"#;
796 let mut preferred = HashMap::new();
797 preferred.insert("Shell".to_string(), "sh".to_string());
798
799 let config = MD040Config {
800 style: LanguageStyle::Consistent,
801 preferred_aliases: preferred,
802 ..Default::default()
803 };
804 let fixed = run_fix_with_config(content, config).unwrap();
805 assert_eq!(fixed.matches("```sh").count(), 2);
806 assert_eq!(fixed.matches("```bash").count(), 0);
807 }
808
809 #[test]
810 fn test_consistent_mode_fix_inside_blockquote() {
811 let content = "> ```bash\n> echo hi\n> ```\n>\n> ```sh\n> echo there\n> ```\n";
814 let config = MD040Config {
815 style: LanguageStyle::Consistent,
816 ..Default::default()
817 };
818 let fixed = run_fix_with_config(content, config).unwrap();
819 assert_eq!(
820 fixed,
821 "> ```bash\n> echo hi\n> ```\n>\n> ```bash\n> echo there\n> ```\n"
822 );
823 }
824
825 #[test]
826 fn test_consistent_mode_ignores_disabled_blocks() {
827 let content = r#"```bash
828echo hi
829```
830<!-- rumdl-disable MD040 -->
831```sh
832echo there
833```
834```sh
835echo again
836```
837<!-- rumdl-enable MD040 -->
838"#;
839 let config = MD040Config {
840 style: LanguageStyle::Consistent,
841 ..Default::default()
842 };
843 let result = run_check_with_config(content, config).unwrap();
844 assert!(result.is_empty(), "Disabled blocks should not affect consistency");
845 }
846
847 #[test]
848 fn test_disable_comment_naming_the_rule_by_alias_disables_it() {
849 let content = r#"```bash
850echo hi
851```
852<!-- rumdl-disable fenced-code-language -->
853```sh
854echo there
855```
856```sh
857echo again
858```
859<!-- rumdl-enable fenced-code-language -->
860"#;
861 let config = MD040Config {
862 style: LanguageStyle::Consistent,
863 ..Default::default()
864 };
865 let result = run_check_with_config(content, config.clone()).unwrap();
866 assert!(
867 result.is_empty(),
868 "an alias names the same rule as the ID does: {result:?}"
869 );
870
871 let names_another_rule = content.replace("fenced-code-language", "line-length");
872 let result = run_check_with_config(&names_another_rule, config).unwrap();
873 assert_eq!(
874 result.len(),
875 1,
876 "a directive naming another rule leaves the sh blocks voting: {result:?}"
877 );
878 }
879
880 #[test]
881 fn test_a_line_scoped_directive_takes_the_fence_out_of_the_vote() {
882 let directive = "<!-- rumdl-disable-next-line MD040 -->\n";
883 let content =
884 format!("```bash\necho one\n```\n\n{directive}```sh\necho two\n```\n\n{directive}```sh\necho three\n```\n");
885 let config = MD040Config {
886 style: LanguageStyle::Consistent,
887 ..Default::default()
888 };
889
890 let without_directives = content.replace(directive, "");
891 assert_eq!(
892 run_check_with_config(&without_directives, config.clone())
893 .unwrap()
894 .len(),
895 1,
896 "control: two sh fences outvote the bash one"
897 );
898
899 let result = run_check_with_config(&content, config).unwrap();
902 assert!(result.is_empty(), "a disabled fence casts no vote: {result:?}");
903 }
904
905 #[test]
906 fn test_a_directive_shown_inside_a_code_block_disables_nothing() {
907 let sample = "```text\n<!-- rumdl-disable RULE -->\n```\n";
910 let blocks = "\n```bash\necho one\n```\n\n```sh\necho two\n```\n\n```sh\necho three\n```\n";
911 let config = MD040Config {
912 style: LanguageStyle::Consistent,
913 ..Default::default()
914 };
915
916 let without_sample = run_check_with_config(blocks, config.clone()).unwrap();
917 assert_eq!(without_sample.len(), 1, "control: the bash fence is reported");
918
919 for name in ["MD040", "fenced-code-language"] {
920 let content = format!("{}{blocks}", sample.replace("RULE", name));
921 let result = run_check_with_config(&content, config.clone()).unwrap();
922 assert_eq!(
923 result.len(),
924 1,
925 "`{name}` inside a code block is sample text, not a directive: {result:?}"
926 );
927 }
928 }
929
930 #[test]
931 fn test_fix_preserves_attributes() {
932 let content = "```sh {.highlight}\ncode\n```\n\n```bash\nmore\n```";
933 let config = MD040Config {
934 style: LanguageStyle::Consistent,
935 ..Default::default()
936 };
937 let fixed = run_fix_with_config(content, config).unwrap();
938 assert!(fixed.contains("```bash {.highlight}"));
939 }
940
941 #[test]
942 fn test_fix_preserves_spacing_before_label() {
943 let content = "```bash\ncode\n```\n\n``` sh {.highlight}\ncode\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 assert!(!fixed.contains("``` sh {.highlight}"));
951 }
952
953 #[test]
958 fn test_allowlist_blocks_unlisted() {
959 let content = "```java\ncode\n```";
960 let config = MD040Config {
961 allowed_languages: vec!["Python".to_string(), "Shell".to_string()],
962 ..Default::default()
963 };
964 let result = run_check_with_config(content, config).unwrap();
965 assert_eq!(result.len(), 1);
966 assert!(result[0].message.contains("not in the allowed list"));
967 }
968
969 #[test]
970 fn test_allowlist_allows_listed() {
971 let content = "```python\ncode\n```";
972 let config = MD040Config {
973 allowed_languages: vec!["Python".to_string()],
974 ..Default::default()
975 };
976 let result = run_check_with_config(content, config).unwrap();
977 assert!(result.is_empty());
978 }
979
980 #[test]
981 fn test_allowlist_blocks_unknown_language() {
982 let content = "```mysterylang\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_eq!(result.len(), 1);
989 assert!(result[0].message.contains("allowed list"));
990 }
991
992 #[test]
993 fn test_allowlist_case_insensitive() {
994 let content = "```python\ncode\n```";
995 let config = MD040Config {
996 allowed_languages: vec!["PYTHON".to_string()],
997 ..Default::default()
998 };
999 let result = run_check_with_config(content, config).unwrap();
1000 assert!(result.is_empty());
1001 }
1002
1003 #[test]
1004 fn test_denylist_blocks_listed() {
1005 let content = "```java\ncode\n```";
1006 let config = MD040Config {
1007 disallowed_languages: vec!["Java".to_string()],
1008 ..Default::default()
1009 };
1010 let result = run_check_with_config(content, config).unwrap();
1011 assert_eq!(result.len(), 1);
1012 assert!(result[0].message.contains("disallowed"));
1013 }
1014
1015 #[test]
1016 fn test_denylist_allows_unlisted() {
1017 let content = "```python\ncode\n```";
1018 let config = MD040Config {
1019 disallowed_languages: vec!["Java".to_string()],
1020 ..Default::default()
1021 };
1022 let result = run_check_with_config(content, config).unwrap();
1023 assert!(result.is_empty());
1024 }
1025
1026 #[test]
1027 fn test_allowlist_takes_precedence_over_denylist() {
1028 let content = "```python\ncode\n```";
1029 let config = MD040Config {
1030 allowed_languages: vec!["Python".to_string()],
1031 disallowed_languages: vec!["Python".to_string()], ..Default::default()
1033 };
1034 let result = run_check_with_config(content, config).unwrap();
1035 assert!(result.is_empty());
1036 }
1037
1038 #[test]
1043 fn test_unknown_language_ignore_default() {
1044 let content = "```mycustomlang\ncode\n```";
1045 let result = run_check(content).unwrap();
1046 assert!(result.is_empty(), "Unknown languages ignored by default");
1047 }
1048
1049 #[test]
1050 fn test_unknown_language_warn() {
1051 let content = "```mycustomlang\ncode\n```";
1052 let config = MD040Config {
1053 unknown_language_action: UnknownLanguageAction::Warn,
1054 ..Default::default()
1055 };
1056 let result = run_check_with_config(content, config).unwrap();
1057 assert_eq!(result.len(), 1);
1058 assert!(result[0].message.contains("Unknown language"));
1059 assert!(result[0].message.contains("mycustomlang"));
1060 assert_eq!(result[0].severity, Severity::Warning);
1061 }
1062
1063 #[test]
1071 fn test_unknown_language_warn_known_aliases_not_flagged() {
1072 let known_aliases = [
1073 "py",
1074 "python",
1075 "sh",
1076 "bash",
1077 "shell",
1078 "zsh",
1079 "js",
1080 "javascript",
1081 "ts",
1082 "typescript",
1083 "rb",
1084 "ruby",
1085 "rs",
1086 "rust",
1087 "yml",
1088 "yaml",
1089 "cpp",
1090 "c++",
1091 "csharp",
1092 "golang",
1093 "dockerfile",
1094 "jsonc",
1095 "kotlin",
1096 ];
1097 for alias in known_aliases {
1098 let content = format!("```{alias}\ncode\n```");
1099 let config = MD040Config {
1100 unknown_language_action: UnknownLanguageAction::Warn,
1101 ..Default::default()
1102 };
1103 let result = run_check_with_config(&content, config).unwrap();
1104 assert!(
1105 result.is_empty(),
1106 "known Linguist alias '{alias}' should not be flagged as unknown: {result:?}"
1107 );
1108 }
1109 }
1110
1111 #[test]
1116 fn test_unknown_language_extension_labels_not_flagged() {
1117 for label in ["pytb", "cs", "kt", "pl", "pyi", "cjs", "mts"] {
1118 let content = format!("```{label}\ncode\n```");
1119 let config = MD040Config {
1120 unknown_language_action: UnknownLanguageAction::Error,
1121 ..Default::default()
1122 };
1123 let result = run_check_with_config(&content, config).unwrap();
1124 assert!(
1125 result.is_empty(),
1126 "Linguist file extension '{label}' should not be flagged as unknown: {result:?}"
1127 );
1128 }
1129 }
1130
1131 #[test]
1132 fn test_unknown_language_error() {
1133 let content = "```mycustomlang\ncode\n```";
1134 let config = MD040Config {
1135 unknown_language_action: UnknownLanguageAction::Error,
1136 ..Default::default()
1137 };
1138 let result = run_check_with_config(content, config).unwrap();
1139 assert_eq!(result.len(), 1);
1140 assert!(result[0].message.contains("Unknown language"));
1141 assert_eq!(result[0].severity, Severity::Error);
1142 }
1143
1144 #[test]
1149 fn test_invalid_preferred_alias_detected() {
1150 let mut preferred = HashMap::new();
1151 preferred.insert("Shell".to_string(), "invalid_alias".to_string());
1152
1153 let config = MD040Config {
1154 style: LanguageStyle::Consistent,
1155 preferred_aliases: preferred,
1156 ..Default::default()
1157 };
1158 let rule = MD040FencedCodeLanguage::with_config(config);
1159 let errors = rule.validate_config();
1160 assert_eq!(errors.len(), 1);
1161 assert!(errors[0].contains("Invalid alias"));
1162 assert!(errors[0].contains("invalid_alias"));
1163 }
1164
1165 #[test]
1166 fn test_invalid_preferred_alias_is_not_normalized_to() {
1167 let content = "```sh\necho one\n```\n\n```bash\necho two\n```\n";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171
1172 let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1173 style: LanguageStyle::Consistent,
1174 preferred_aliases: HashMap::from([("Shell".to_string(), "invalid_alias".to_string())]),
1175 ..Default::default()
1176 });
1177 let fixed = rule.fix(&ctx).unwrap();
1178 assert!(
1179 !fixed.contains("invalid_alias"),
1180 "an invalid alias must not reach the document, got:\n{fixed}"
1181 );
1182 assert!(
1183 rule.check(&ctx)
1184 .unwrap()
1185 .iter()
1186 .any(|w| w.message.contains("Invalid alias")),
1187 "the invalid alias is still reported"
1188 );
1189
1190 let rule = MD040FencedCodeLanguage::with_config(MD040Config {
1192 style: LanguageStyle::Consistent,
1193 preferred_aliases: HashMap::from([("Shell".to_string(), "zsh".to_string())]),
1194 ..Default::default()
1195 });
1196 assert_eq!(
1197 rule.fix(&ctx).unwrap(),
1198 "```zsh\necho one\n```\n\n```zsh\necho two\n```\n"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_unknown_language_in_preferred_aliases_detected() {
1204 let mut preferred = HashMap::new();
1205 preferred.insert("NotARealLanguage".to_string(), "nope".to_string());
1206
1207 let config = MD040Config {
1208 style: LanguageStyle::Consistent,
1209 preferred_aliases: preferred,
1210 ..Default::default()
1211 };
1212 let rule = MD040FencedCodeLanguage::with_config(config);
1213 let errors = rule.validate_config();
1214 assert_eq!(errors.len(), 1);
1215 assert!(errors[0].contains("Unknown language"));
1216 }
1217
1218 #[test]
1219 fn test_valid_preferred_alias_accepted() {
1220 let mut preferred = HashMap::new();
1221 preferred.insert("Shell".to_string(), "bash".to_string());
1222 preferred.insert("JavaScript".to_string(), "js".to_string());
1223
1224 let config = MD040Config {
1225 style: LanguageStyle::Consistent,
1226 preferred_aliases: preferred,
1227 ..Default::default()
1228 };
1229 let rule = MD040FencedCodeLanguage::with_config(config);
1230 let errors = rule.validate_config();
1231 assert!(errors.is_empty());
1232 }
1233
1234 #[test]
1235 fn test_config_error_uses_valid_line_column() {
1236 let config = md040_config::MD040Config {
1237 preferred_aliases: {
1238 let mut map = std::collections::HashMap::new();
1239 map.insert("Shell".to_string(), "invalid_alias".to_string());
1240 map
1241 },
1242 ..Default::default()
1243 };
1244 let rule = MD040FencedCodeLanguage::with_config(config);
1245
1246 let content = "```shell\necho hello\n```";
1247 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249
1250 let config_error = result.iter().find(|w| w.message.contains("[config error]"));
1252 assert!(config_error.is_some(), "Should have a config error warning");
1253
1254 let warning = config_error.unwrap();
1255 assert!(
1257 warning.line >= 1,
1258 "Config error line should be >= 1, got {}",
1259 warning.line
1260 );
1261 assert!(
1262 warning.column >= 1,
1263 "Config error column should be >= 1, got {}",
1264 warning.column
1265 );
1266 }
1267
1268 fn custom_languages_config(declared: &[&str]) -> MD040Config {
1273 MD040Config {
1274 unknown_language_action: UnknownLanguageAction::Error,
1275 custom_languages: declared.iter().map(|s| (*s).to_string()).collect(),
1276 ..Default::default()
1277 }
1278 }
1279
1280 #[test]
1281 fn test_declared_custom_language_is_not_unknown() {
1282 let content = "```cddl\nfoo = tstr\n```\n";
1283
1284 let flagged = run_check_with_config(content, custom_languages_config(&[])).unwrap();
1285 assert_eq!(flagged.len(), 1, "an undeclared unknown label must still be reported");
1286 assert!(flagged[0].message.contains("Unknown language 'cddl'"));
1287
1288 let accepted = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1289 assert!(accepted.is_empty(), "a declared label must be accepted: {accepted:?}");
1290 }
1291
1292 #[test]
1293 fn test_custom_language_matches_a_label_case_insensitively() {
1294 let content = "```CDDL\nfoo = tstr\n```\n";
1295 let result = run_check_with_config(content, custom_languages_config(&["cddl"])).unwrap();
1296 assert!(result.is_empty(), "label case must not matter: {result:?}");
1297 }
1298
1299 #[test]
1300 fn test_custom_language_does_not_shadow_linguist() {
1301 let content = "```sh\necho hi\n```\n\n```bash\necho there\n```\n\n```bash\necho again\n```\n";
1304 let config = MD040Config {
1305 style: LanguageStyle::Consistent,
1306 custom_languages: vec!["sh".to_string()],
1307 ..Default::default()
1308 };
1309 let result = run_check_with_config(content, config.clone()).unwrap();
1310 assert_eq!(result.len(), 1, "sh must still be judged against Shell: {result:?}");
1311 assert!(result[0].message.contains("use 'bash'"));
1312
1313 let fixed = run_fix_with_config(content, config).unwrap();
1314 assert!(!fixed.contains("```sh\n"));
1315 }
1316
1317 #[test]
1318 fn test_custom_language_normalizes_under_consistent_style() {
1319 let content = "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n";
1320 let config = MD040Config {
1321 style: LanguageStyle::Consistent,
1322 custom_languages: vec!["cddl".to_string()],
1323 ..Default::default()
1324 };
1325 let result = run_check_with_config(content, config.clone()).unwrap();
1326 assert_eq!(result.len(), 1, "the two spellings are one language: {result:?}");
1327
1328 let fixed = run_fix_with_config(content, config).unwrap();
1330 assert!(fixed.contains("```cddl"));
1331 assert!(!fixed.contains("```CDDL"));
1332 }
1333
1334 #[test]
1335 fn test_custom_language_participates_in_allowed_and_disallowed_lists() {
1336 let content = "```cddl\nfoo = tstr\n```\n";
1337
1338 let allowed = run_check_with_config(
1339 content,
1340 MD040Config {
1341 allowed_languages: vec!["cddl".to_string()],
1342 custom_languages: vec!["cddl".to_string()],
1343 ..Default::default()
1344 },
1345 )
1346 .unwrap();
1347 assert!(allowed.is_empty(), "an allowed custom language passes: {allowed:?}");
1348
1349 let disallowed = run_check_with_config(
1350 content,
1351 MD040Config {
1352 disallowed_languages: vec!["cddl".to_string()],
1353 custom_languages: vec!["cddl".to_string()],
1354 ..Default::default()
1355 },
1356 )
1357 .unwrap();
1358 assert_eq!(disallowed.len(), 1, "a disallowed custom language is reported");
1359 assert!(disallowed[0].message.contains("is disallowed"));
1360 }
1361
1362 #[test]
1363 fn test_undeclared_language_is_not_allowed_by_the_allowlist() {
1364 let result = run_check_with_config(
1367 "```cddl\nfoo = tstr\n```\n",
1368 MD040Config {
1369 allowed_languages: vec!["cddl".to_string()],
1370 ..Default::default()
1371 },
1372 )
1373 .unwrap();
1374 assert_eq!(result.len(), 1);
1375 assert!(result[0].message.contains("is not in the allowed list"));
1376 }
1377
1378 #[test]
1379 fn test_unusable_custom_language_entries_are_config_errors() {
1380 let rule =
1381 MD040FencedCodeLanguage::with_config(custom_languages_config(&["c ddl", "cddl ", " cddl", " ", "cddl"]));
1382 let errors = rule.validate_config();
1383 assert_eq!(errors.len(), 4, "only the unusable entries are reported: {errors:?}");
1384 assert_eq!(
1385 errors.iter().filter(|e| e.contains("contains whitespace")).count(),
1386 3,
1387 "whitespace around an entry is as unmatchable as whitespace inside it: {errors:?}"
1388 );
1389 assert!(errors.iter().any(|e| e.contains("Empty entry in custom-languages")));
1390 }
1391
1392 #[test]
1393 fn test_custom_language_with_surrounding_whitespace_does_not_match_a_label() {
1394 let content = "```cddl\nfoo = int\n```";
1395 let result = run_check_with_config(content, custom_languages_config(&["cddl "])).unwrap();
1396 assert_eq!(
1397 result.len(),
1398 2,
1399 "the label stays unknown and the entry is reported: {result:?}"
1400 );
1401 assert!(result[0].message.contains("[config error]"));
1402 assert!(result[0].message.contains("contains whitespace"));
1403 assert!(result[1].message.contains("Unknown language 'cddl'"));
1404 }
1405
1406 #[test]
1407 fn test_preferred_alias_for_a_custom_language() {
1408 let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1409 preferred_aliases: HashMap::from([("CDDL".to_string(), "cddl".to_string())]),
1410 custom_languages: vec!["cddl".to_string()],
1411 ..Default::default()
1412 });
1413 assert!(
1414 accepted.validate_config().is_empty(),
1415 "a spelling of the declared name is a valid preference"
1416 );
1417
1418 let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1419 preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1420 custom_languages: vec!["cddl".to_string()],
1421 ..Default::default()
1422 });
1423 let errors = rejected.validate_config();
1424 assert_eq!(errors.len(), 1, "a custom language has no aliases: {errors:?}");
1425 assert!(errors[0].contains("Invalid alias 'cbor-dl' for custom language 'cddl'"));
1426
1427 let ctx = LintContext::new(
1430 "```cddl\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n",
1431 crate::config::MarkdownFlavor::Standard,
1432 None,
1433 );
1434 let rejected = MD040FencedCodeLanguage::with_config(MD040Config {
1435 style: LanguageStyle::Consistent,
1436 preferred_aliases: HashMap::from([("cddl".to_string(), "cbor-dl".to_string())]),
1437 custom_languages: vec!["cddl".to_string()],
1438 ..Default::default()
1439 });
1440 assert_eq!(
1441 rejected.fix(&ctx).unwrap(),
1442 "```cddl\nfoo = tstr\n```\n\n```cddl\nbar = int\n```\n"
1443 );
1444
1445 let accepted = MD040FencedCodeLanguage::with_config(MD040Config {
1447 style: LanguageStyle::Consistent,
1448 preferred_aliases: HashMap::from([("cddl".to_string(), "CDDL".to_string())]),
1449 custom_languages: vec!["cddl".to_string()],
1450 ..Default::default()
1451 });
1452 assert_eq!(
1453 accepted.fix(&ctx).unwrap(),
1454 "```CDDL\nfoo = tstr\n```\n\n```CDDL\nbar = int\n```\n"
1455 );
1456 }
1457
1458 #[test]
1463 fn test_linguist_resolution() {
1464 assert_eq!(resolve_canonical("bash"), Some("Shell"));
1465 assert_eq!(resolve_canonical("sh"), Some("Shell"));
1466 assert_eq!(resolve_canonical("zsh"), Some("Shell"));
1467 assert_eq!(resolve_canonical("js"), Some("JavaScript"));
1468 assert_eq!(resolve_canonical("python"), Some("Python"));
1469 assert_eq!(resolve_canonical("unknown_lang"), None);
1470 }
1471
1472 #[test]
1473 fn test_linguist_resolution_case_insensitive() {
1474 assert_eq!(resolve_canonical("BASH"), Some("Shell"));
1475 assert_eq!(resolve_canonical("Bash"), Some("Shell"));
1476 assert_eq!(resolve_canonical("Python"), Some("Python"));
1477 assert_eq!(resolve_canonical("PYTHON"), Some("Python"));
1478 }
1479
1480 #[test]
1481 fn test_alias_validation() {
1482 use crate::linguist_data::is_valid_alias;
1483
1484 assert!(is_valid_alias("Shell", "bash"));
1485 assert!(is_valid_alias("Shell", "sh"));
1486 assert!(is_valid_alias("Shell", "zsh"));
1487 assert!(!is_valid_alias("Shell", "python"));
1488 assert!(!is_valid_alias("Shell", "invalid"));
1489 }
1490
1491 #[test]
1492 fn test_default_alias() {
1493 assert_eq!(default_alias("Shell"), Some("bash"));
1494 assert_eq!(default_alias("JavaScript"), Some("js"));
1495 assert_eq!(default_alias("Python"), Some("python"));
1496 }
1497
1498 #[test]
1503 fn test_mixed_case_labels_normalized() {
1504 let content = r#"```BASH
1505echo hi
1506```
1507
1508```Bash
1509echo there
1510```
1511
1512```bash
1513echo again
1514```
1515"#;
1516 let config = MD040Config {
1517 style: LanguageStyle::Consistent,
1518 ..Default::default()
1519 };
1520 let result = run_check_with_config(content, config).unwrap();
1522 assert!(result.len() >= 2, "Should flag at least 2 inconsistent labels");
1526 }
1527
1528 #[test]
1529 fn test_multiple_languages_independent() {
1530 let content = r#"```bash
1531shell code
1532```
1533
1534```python
1535python code
1536```
1537
1538```sh
1539more shell
1540```
1541
1542```python3
1543more python
1544```
1545"#;
1546 let config = MD040Config {
1547 style: LanguageStyle::Consistent,
1548 ..Default::default()
1549 };
1550 let result = run_check_with_config(content, config).unwrap();
1551 assert_eq!(result.len(), 2);
1553 }
1554
1555 #[test]
1556 fn test_tilde_fences() {
1557 let content = r#"~~~bash
1558echo hi
1559~~~
1560
1561~~~sh
1562echo there
1563~~~
1564"#;
1565 let config = MD040Config {
1566 style: LanguageStyle::Consistent,
1567 ..Default::default()
1568 };
1569 let result = run_check_with_config(content, config.clone()).unwrap();
1570 assert_eq!(result.len(), 1);
1571
1572 let fixed = run_fix_with_config(content, config).unwrap();
1573 assert!(fixed.contains("~~~bash"));
1574 assert!(!fixed.contains("~~~sh"));
1575 }
1576
1577 #[test]
1578 fn test_longer_fence_markers_preserved() {
1579 let content = "````sh\ncode\n````\n\n```bash\ncode\n```";
1580 let config = MD040Config {
1581 style: LanguageStyle::Consistent,
1582 ..Default::default()
1583 };
1584 let fixed = run_fix_with_config(content, config).unwrap();
1585 assert!(fixed.contains("````bash"));
1586 assert!(fixed.contains("```bash"));
1587 }
1588
1589 #[test]
1590 fn test_empty_document() {
1591 let result = run_check("").unwrap();
1592 assert!(result.is_empty());
1593 }
1594
1595 #[test]
1596 fn test_no_code_blocks() {
1597 let content = "# Just a heading\n\nSome text.";
1598 let result = run_check(content).unwrap();
1599 assert!(result.is_empty());
1600 }
1601
1602 #[test]
1603 fn test_single_code_block_no_inconsistency() {
1604 let content = "```bash\necho hi\n```";
1605 let config = MD040Config {
1606 style: LanguageStyle::Consistent,
1607 ..Default::default()
1608 };
1609 let result = run_check_with_config(content, config).unwrap();
1610 assert!(result.is_empty(), "Single block has no inconsistency");
1611 }
1612
1613 #[test]
1614 fn test_idempotent_fix() {
1615 let content = r#"```bash
1616echo hi
1617```
1618
1619```sh
1620echo there
1621```
1622"#;
1623 let config = MD040Config {
1624 style: LanguageStyle::Consistent,
1625 ..Default::default()
1626 };
1627 let fixed1 = run_fix_with_config(content, config.clone()).unwrap();
1628 let fixed2 = run_fix_with_config(&fixed1, config).unwrap();
1629 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1630 }
1631
1632 #[test]
1637 fn test_mkdocs_superfences_attribute_in_blockquote() {
1638 let content = "> ```title=\"Example\"\n> echo hi\n> ```\n";
1641 let result = run_check_mkdocs(content).unwrap();
1642 assert!(
1643 result.is_empty(),
1644 "MkDocs superfences attribute inside a blockquote should not require language: {result:?}"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_mkdocs_superfences_title_only() {
1650 let content = r#"```title="Example"
1652echo hi
1653```
1654"#;
1655 let result = run_check_mkdocs(content).unwrap();
1656 assert!(
1657 result.is_empty(),
1658 "MkDocs superfences with title= should not require language"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_mkdocs_superfences_hl_lines() {
1664 let content = r#"```hl_lines="1 2"
1666line 1
1667line 2
1668```
1669"#;
1670 let result = run_check_mkdocs(content).unwrap();
1671 assert!(
1672 result.is_empty(),
1673 "MkDocs superfences with hl_lines= should not require language"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_mkdocs_superfences_linenums() {
1679 let content = r#"```linenums="1"
1681line 1
1682line 2
1683```
1684"#;
1685 let result = run_check_mkdocs(content).unwrap();
1686 assert!(
1687 result.is_empty(),
1688 "MkDocs superfences with linenums= should not require language"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_mkdocs_superfences_class() {
1694 let content = r#"```.my-class
1696some text
1697```
1698"#;
1699 let result = run_check_mkdocs(content).unwrap();
1700 assert!(
1701 result.is_empty(),
1702 "MkDocs superfences with .class should not require language"
1703 );
1704 }
1705
1706 #[test]
1707 fn test_mkdocs_superfences_id() {
1708 let content = r#"```#my-id
1710some text
1711```
1712"#;
1713 let result = run_check_mkdocs(content).unwrap();
1714 assert!(
1715 result.is_empty(),
1716 "MkDocs superfences with #id should not require language"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_mkdocs_superfences_with_language() {
1722 let content = r#"```python title="Example" hl_lines="1"
1724print("hello")
1725```
1726"#;
1727 let result = run_check_mkdocs(content).unwrap();
1728 assert!(result.is_empty(), "Code block with language and attrs should pass");
1729 }
1730
1731 #[test]
1732 fn test_standard_flavor_no_special_handling() {
1733 let content = r#"```title="Example"
1735echo hi
1736```
1737"#;
1738 let result = run_check(content).unwrap();
1739 assert_eq!(
1740 result.len(),
1741 1,
1742 "Standard flavor should warn about title= without language"
1743 );
1744 }
1745
1746 #[test]
1747 fn test_pandoc_raw_block_skipped_under_pandoc_flavor() {
1748 let rule = MD040FencedCodeLanguage::default();
1751 let content = "```{=html}\n<div>raw html</div>\n```\n";
1752 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1753 let result = rule.check(&ctx).unwrap();
1754 assert!(
1755 result.is_empty(),
1756 "MD040 should skip Pandoc raw blocks ({{=html}}) under Pandoc flavor: {result:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_pandoc_raw_block_skipped_under_quarto_flavor() {
1762 let rule = MD040FencedCodeLanguage::default();
1764 let content = "```{=html}\n<div>raw html</div>\n```\n";
1765 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1766 let result = rule.check(&ctx).unwrap();
1767 assert!(
1768 result.is_empty(),
1769 "MD040 should skip Pandoc raw blocks ({{=html}}) under Quarto flavor: {result:?}"
1770 );
1771 }
1772
1773 #[test]
1776 fn test_pandoc_accepts_raw_html_block() {
1777 use crate::config::MarkdownFlavor;
1778 let rule = MD040FencedCodeLanguage::default();
1779 let content = "```{=html}\n<div>raw</div>\n```\n";
1780 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1781 let result = rule.check(&ctx).unwrap();
1782 assert!(result.is_empty(), "MD040 should accept ```{{=html}}```: {result:?}");
1783 }
1784
1785 #[test]
1788 fn test_pandoc_rejects_quarto_exec_blocks() {
1789 use crate::config::MarkdownFlavor;
1790 let rule = MD040FencedCodeLanguage::default();
1791 let content = "```{r}\nsummary(data)\n```\n";
1792 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1793 let result = rule.check(&ctx).unwrap();
1794 assert!(
1795 !result.is_empty(),
1796 "MD040 under Pandoc should flag `{{r}}` (Quarto-only)"
1797 );
1798 }
1799
1800 #[test]
1802 fn test_quarto_still_accepts_exec_block() {
1803 use crate::config::MarkdownFlavor;
1804 let rule = MD040FencedCodeLanguage::default();
1805 let content = "```{r}\nsummary(data)\n```\n";
1806 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1807 let result = rule.check(&ctx).unwrap();
1808 assert!(
1809 result.is_empty(),
1810 "MD040 under Quarto should accept `{{r}}`: {result:?}"
1811 );
1812 }
1813
1814 #[test]
1815 fn test_quarto_exec_block_skipped_under_quarto_only() {
1816 let rule = MD040FencedCodeLanguage::default();
1820 let content = "```{r}\n1 + 1\n```\n";
1821
1822 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1823 let result_quarto = rule.check(&ctx_quarto).unwrap();
1824 assert!(
1825 result_quarto.is_empty(),
1826 "MD040 should skip Quarto exec chunks under Quarto flavor: {result_quarto:?}"
1827 );
1828
1829 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1832 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1833 assert!(
1834 !result_pandoc.is_empty(),
1835 "MD040 should flag `{{r}}` under Pandoc as missing a real language"
1836 );
1837 }
1838
1839 #[test]
1842 fn test_pandoc_class_attr_accepted_as_language() {
1843 use crate::config::MarkdownFlavor;
1844 let rule = MD040FencedCodeLanguage::default();
1845 let content = "```{.python}\nprint(\"hi\")\n```\n";
1846
1847 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1848 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1849 assert!(
1850 result_pandoc.is_empty(),
1851 "MD040 under Pandoc should accept ```{{.python}}``` as language declaration: {result_pandoc:?}"
1852 );
1853
1854 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1855 let result_quarto = rule.check(&ctx_quarto).unwrap();
1856 assert!(
1857 result_quarto.is_empty(),
1858 "MD040 under Quarto should accept ```{{.python}}``` as language declaration: {result_quarto:?}"
1859 );
1860 }
1861
1862 #[test]
1865 fn test_pandoc_class_attr_with_extra_attributes_accepted() {
1866 use crate::config::MarkdownFlavor;
1867 let rule = MD040FencedCodeLanguage::default();
1868 let content = "```{.haskell .numberLines}\nmain = putStrLn \"hi\"\n```\n";
1869
1870 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1871 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1872 assert!(
1873 result_pandoc.is_empty(),
1874 "MD040 under Pandoc should accept ```{{.haskell .numberLines}}```: {result_pandoc:?}"
1875 );
1876
1877 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1878 let result_quarto = rule.check(&ctx_quarto).unwrap();
1879 assert!(
1880 result_quarto.is_empty(),
1881 "MD040 under Quarto should accept ```{{.haskell .numberLines}}```: {result_quarto:?}"
1882 );
1883 }
1884
1885 #[test]
1888 fn test_pandoc_class_attr_with_id_and_keyvalue_accepted() {
1889 use crate::config::MarkdownFlavor;
1890 let rule = MD040FencedCodeLanguage::default();
1891 let content = "```{#snippet .python startFrom=\"10\"}\nprint(1)\n```\n";
1892
1893 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1894 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1895 assert!(
1896 result_pandoc.is_empty(),
1897 "MD040 under Pandoc should accept ```{{#snippet .python …}}```: {result_pandoc:?}"
1898 );
1899 }
1900
1901 #[test]
1904 fn test_standard_still_flags_pandoc_class_attr() {
1905 use crate::config::MarkdownFlavor;
1906 let rule = MD040FencedCodeLanguage::default();
1907 let content = "```{.python}\nprint(\"hi\")\n```\n";
1908
1909 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1910 let result_standard = rule.check(&ctx_standard).unwrap();
1911 assert!(
1912 !result_standard.is_empty(),
1913 "MD040 under Standard should still flag ```{{.python}}``` (no Pandoc support)"
1914 );
1915 }
1916
1917 #[test]
1920 fn test_pandoc_id_only_attr_still_flagged() {
1921 use crate::config::MarkdownFlavor;
1922 let rule = MD040FencedCodeLanguage::default();
1923 let content = "```{#myid}\ncode here\n```\n";
1924
1925 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1926 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1927 assert!(
1928 !result_pandoc.is_empty(),
1929 "MD040 under Pandoc should flag ```{{#myid}}``` — id without class declares no language"
1930 );
1931 }
1932
1933 #[test]
1935 fn test_pandoc_empty_braces_still_flagged() {
1936 use crate::config::MarkdownFlavor;
1937 let rule = MD040FencedCodeLanguage::default();
1938 let content = "```{}\ncode here\n```\n";
1939
1940 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1941 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1942 assert!(
1943 !result_pandoc.is_empty(),
1944 "MD040 under Pandoc should flag ```{{}}``` (no language declared)"
1945 );
1946 }
1947}