Skip to content

Commit 6099a6c

Browse files
committed
fix(md040): locate the fence a list marker holds instead of assuming the indent
A fence opened directly on a list marker line had its fix anchored at the first non-whitespace byte, which is the bullet rather than the fence. The replacement therefore began two bytes inside the fence and took the fence's own trailing characters for an info string to preserve, rewriting - ``` as an inline span. The block then stopped being a code block, so the list rules flattened the indentation of its contents and appended stray fences, while `rumdl fmt` reported the issue fixed and exited 0. The marker is now located on the line rather than assumed: no container prefix can hold a backtick or a tilde, so the first run of either is the fence. The same derivation supplies the marker itself, which previously fell back to three backticks whenever the line did not start with a fence character, so a tilde fence or a run longer than three was mismeasured as well. When the run found is not the expected marker, no fix is offered rather than one anchored at a guessed position. The blockquote form of this defect was fixed in #684; the list-marker prefix was never covered.
1 parent cfaad2e commit 6099a6c

3 files changed

Lines changed: 214 additions & 49 deletions

File tree

src/rules/md040_fenced_code_language.rs

Lines changed: 104 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,22 @@ impl Rule for MD040FencedCodeLanguage {
320320
if needs_language && !has_pandoc_or_quarto_syntax {
321321
let (start_line, start_col, end_line, end_col) = calculate_line_range(block.line_idx + 1, line);
322322

323+
let fix = fence_marker_offset(line, &block.fence_marker).map(|marker_offset| {
324+
let line_start_byte = ctx.line_offsets.get(block.line_idx).copied().unwrap_or(0);
325+
let fence_end_byte = line_start_byte + marker_offset + block.fence_marker.len();
326+
// Replace from after fence marker to end of line content,
327+
// so trailing whitespace is cleaned up while any existing
328+
// info string / attributes are preserved via the replacement.
329+
let line_end_byte = line_start_byte + line.len();
330+
let after_fence_trimmed = line[marker_offset + block.fence_marker.len()..].trim();
331+
let replacement = if after_fence_trimmed.is_empty() {
332+
"text".to_string()
333+
} else {
334+
format!("text {after_fence_trimmed}")
335+
};
336+
Fix::new(fence_end_byte..line_end_byte, replacement)
337+
});
338+
323339
warnings.push(LintWarning {
324340
rule_name: Some(self.name().to_string()),
325341
line: start_line,
@@ -328,28 +344,7 @@ impl Rule for MD040FencedCodeLanguage {
328344
end_column: end_col,
329345
message: "Code block (```) missing language".to_string(),
330346
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-
// Replace from after fence marker to end of line content,
337-
// so trailing whitespace is cleaned up while any existing
338-
// info string / attributes are preserved via the replacement.
339-
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-
)),
347+
fix,
353348
});
354349
continue;
355350
}
@@ -500,18 +495,8 @@ fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<Fenc
500495
let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
501496
let line_end = line_offsets.get(line_idx + 1).copied().unwrap_or(content.len());
502497
let line = content.get(line_start..line_end).unwrap_or("");
503-
// Strip any blockquote prefix (`> `) before measuring the fence so
504-
// markers inside blockquotes are detected by their actual length.
505-
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-
};
498+
let fence_marker =
499+
find_fence_marker(line).map_or_else(|| "```".to_string(), |(_, marker)| marker.to_string());
515500

516501
let language = detail.info_string.split_whitespace().next().unwrap_or("").to_string();
517502

@@ -524,26 +509,34 @@ fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<Fenc
524509
.collect()
525510
}
526511

527-
/// Byte offset within `line` where the fence marker begins.
512+
/// Locate the fence marker on a fence-opening line: its byte offset and the run
513+
/// of fence characters itself.
514+
///
515+
/// A fence opener can carry a blockquote prefix, indentation and one or more
516+
/// list markers (`- `, `1. `, and nested combinations). Rather than enumerating
517+
/// those prefixes, locate the marker itself: none of them can hold a backtick or
518+
/// a tilde, so the first run of either is the fence.
519+
fn find_fence_marker(line: &str) -> Option<(usize, &str)> {
520+
let bytes = line.as_bytes();
521+
let start = bytes.iter().position(|&b| b == b'`' || b == b'~')?;
522+
let fence_char = bytes[start];
523+
let len = bytes[start..].iter().take_while(|&&b| b == fence_char).count();
524+
Some((start, &line[start..start + len]))
525+
}
526+
527+
/// Byte offset within `line` where `fence_marker` begins.
528528
///
529-
/// Accounts for an optional blockquote prefix (`>`, `> >`, `>>`, etc.) followed
530-
/// by indentation. For a plain or list-indented fence the blockquote prefix is
531-
/// empty, so this reduces to the leading-whitespace length.
532-
fn 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
529+
/// Returns `None` when the line's fence run is not the expected marker, so
530+
/// callers offer no fix rather than one anchored at a guessed position.
531+
fn fence_marker_offset(line: &str, fence_marker: &str) -> Option<usize> {
532+
let (start, marker) = find_fence_marker(line)?;
533+
(marker == fence_marker).then_some(start)
537534
}
538535

539536
/// Find the byte span of the language label in a fence line.
540537
fn 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()..];
538+
let marker_offset = fence_marker_offset(line, fence_marker)?;
539+
let after_fence = &line[marker_offset + fence_marker.len()..];
547540

548541
let label_start_rel = after_fence
549542
.char_indices()
@@ -701,6 +694,68 @@ another block without
701694
assert_eq!(spaced, "> > ```text\n> > code\n> > ```\n");
702695
}
703696

697+
#[test]
698+
fn test_fix_list_marker_empty_fence() {
699+
// A fence opened on a list marker line must become `- ```text`, not a
700+
// corrupted `` - `text `` `` inline span. The marker sits after the list
701+
// bullet, so the fix has to locate it rather than assume it starts at the
702+
// first non-whitespace byte.
703+
let content = "# Title\n\n- ```\n root/\n └── nested/\n └── file.txt\n ```\n";
704+
let fixed = run_fix(content).unwrap();
705+
let expected = "# Title\n\n- ```text\n root/\n └── nested/\n └── file.txt\n ```\n";
706+
assert_eq!(fixed, expected);
707+
}
708+
709+
#[test]
710+
fn test_fix_list_marker_fence_across_marker_styles() {
711+
// Every list marker form pushes the fence a different distance into the
712+
// line, including nested markers on one line and a marker inside a
713+
// blockquote.
714+
for (input, expected) in [
715+
("- ```\n code\n ```\n", "- ```text\n code\n ```\n"),
716+
("* ```\n code\n ```\n", "* ```text\n code\n ```\n"),
717+
("+ ```\n code\n ```\n", "+ ```text\n code\n ```\n"),
718+
("1. ```\n code\n ```\n", "1. ```text\n code\n ```\n"),
719+
("1) ```\n code\n ```\n", "1) ```text\n code\n ```\n"),
720+
(" - ```\n code\n ```\n", " - ```text\n code\n ```\n"),
721+
("- - ```\n code\n ```\n", "- - ```text\n code\n ```\n"),
722+
("> - ```\n> code\n> ```\n", "> - ```text\n> code\n> ```\n"),
723+
] {
724+
assert_eq!(run_fix(input).unwrap(), expected, "input: {input:?}");
725+
}
726+
}
727+
728+
#[test]
729+
fn test_fix_list_marker_tilde_and_longer_fences() {
730+
// The marker is derived from the line, so a tilde fence or a run longer
731+
// than three characters must be measured at its real position instead of
732+
// falling back to a three-backtick default.
733+
let tilde = run_fix("- ~~~\n code\n ~~~\n").unwrap();
734+
assert_eq!(tilde, "- ~~~text\n code\n ~~~\n");
735+
736+
let longer_tilde = run_fix("- ~~~~\n code\n ~~~~\n").unwrap();
737+
assert_eq!(longer_tilde, "- ~~~~text\n code\n ~~~~\n");
738+
739+
let longer_backtick = run_fix("- ````\n code\n ````\n").unwrap();
740+
assert_eq!(longer_backtick, "- ````text\n code\n ````\n");
741+
}
742+
743+
#[test]
744+
fn test_fix_list_marker_fence_is_idempotent() {
745+
let content = "- ```\n root/\n nested\n ```\n";
746+
let once = run_fix(content).unwrap();
747+
let twice = run_fix(&once).unwrap();
748+
assert_eq!(once, twice);
749+
assert_eq!(once, "- ```text\n root/\n nested\n ```\n");
750+
}
751+
752+
#[test]
753+
fn test_fix_list_marker_fence_with_language_untouched() {
754+
let content = "- ```rust\n code\n ```\n";
755+
assert!(run_check(content).unwrap().is_empty());
756+
assert_eq!(run_fix(content).unwrap(), content);
757+
}
758+
704759
#[test]
705760
fn test_fix_blockquote_empty_fence_is_idempotent() {
706761
// Re-running the fix on its own output must be a no-op.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//! Regression test: `MD040` auto-fix corrupts a fenced code block opened on a
2+
//! list marker line.
3+
//!
4+
//! A fence without a language written directly after a list bullet (`- ```)
5+
//! used to be rewritten to an invalid `` - `text `` `` inline span, because the
6+
//! fix anchored its replacement range at the first non-whitespace byte of the
7+
//! line rather than at the fence itself. The block then stopped being a code
8+
//! block, so the rules that normalize list continuation lines flattened its
9+
//! indentation and appended stray fences. `rumdl fmt` reported `Fixed 1/1
10+
//! issues` and exited 0 while destroying the content.
11+
//!
12+
//! This is the same defect that issue #684 fixed for blockquotes; the
13+
//! list-marker prefix was never covered.
14+
//!
15+
//! These tests run the real `rumdl fmt` pipeline (all default rules) through the
16+
//! binary, so they exercise the production path including the interaction with
17+
//! the list rules that produced the original corruption.
18+
19+
use std::fs;
20+
use tempfile::tempdir;
21+
22+
/// Run `rumdl fmt --no-config --no-cache` on `content` and return the rewritten file.
23+
fn fmt_with_defaults(content: &str) -> String {
24+
let dir = tempdir().unwrap();
25+
let file_path = dir.path().join("input.md");
26+
fs::write(&file_path, content).unwrap();
27+
28+
let output = std::process::Command::new(env!("CARGO_BIN_EXE_rumdl"))
29+
.arg("fmt")
30+
.arg("--no-config")
31+
.arg("--no-cache")
32+
.arg(&file_path)
33+
.output()
34+
.expect("Failed to execute rumdl");
35+
36+
let status = output.status.code();
37+
assert!(
38+
status == Some(0) || status == Some(1),
39+
"rumdl fmt should succeed, got status {status:?}; stderr: {}",
40+
String::from_utf8_lossy(&output.stderr)
41+
);
42+
43+
fs::read_to_string(&file_path).unwrap()
44+
}
45+
46+
#[test]
47+
fn test_list_item_directory_tree_preserved() {
48+
let input = "# Title\n\n- ```\n root/\n └── nested/\n └── file.txt\n ```\n";
49+
let expected = "# Title\n\n- ```text\n root/\n └── nested/\n └── file.txt\n ```\n";
50+
51+
let fixed = fmt_with_defaults(input);
52+
assert_eq!(
53+
fixed, expected,
54+
"MD040 must produce a valid `- ```text` fence and leave the indented tree intact"
55+
);
56+
}
57+
58+
#[test]
59+
fn test_ordered_list_marker_fence_preserved() {
60+
let input = "# Title\n\n1. ```\n code\n ```\n";
61+
let expected = "# Title\n\n1. ```text\n code\n ```\n";
62+
63+
assert_eq!(fmt_with_defaults(input), expected);
64+
}
65+
66+
#[test]
67+
fn test_tilde_fence_on_list_marker_preserved() {
68+
// The fence marker has to be measured where it actually starts; a default of
69+
// three backticks turned `- ~~~` into `- ~text ~~`.
70+
let input = "# Title\n\n- ~~~\n code\n ~~~\n";
71+
let expected = "# Title\n\n- ~~~text\n code\n ~~~\n";
72+
73+
assert_eq!(fmt_with_defaults(input), expected);
74+
}
75+
76+
#[test]
77+
fn test_long_fence_on_list_marker_preserved() {
78+
let input = "# Title\n\n- ````\n code\n ````\n";
79+
let expected = "# Title\n\n- ````text\n code\n ````\n";
80+
81+
assert_eq!(fmt_with_defaults(input), expected);
82+
}
83+
84+
#[test]
85+
fn test_list_marker_fence_fix_is_idempotent() {
86+
let input = "# Title\n\n- ```\n root/\n └── nested/\n └── file.txt\n ```\n";
87+
let expected = "# Title\n\n- ```text\n root/\n └── nested/\n └── file.txt\n ```\n";
88+
89+
let once = fmt_with_defaults(input);
90+
// The corrupting output was itself stable across a second pass, so pin what
91+
// the first pass produced: convergence alone does not prove correctness.
92+
assert_eq!(once, expected, "the first pass must produce a valid fence");
93+
94+
let twice = fmt_with_defaults(&once);
95+
assert_eq!(once, twice, "Formatting the fixed output again must be a no-op");
96+
}
97+
98+
#[test]
99+
fn test_list_marker_fence_with_language_untouched() {
100+
// A fence that already has a language must be left exactly as-is, including
101+
// the extra indentation inside the block.
102+
let input = "# Title\n\n- ```text\n code\n indented\n ```\n";
103+
104+
assert_eq!(
105+
fmt_with_defaults(input),
106+
input,
107+
"A valid fence on a list marker must not be rewritten"
108+
);
109+
}

tests/regressions/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ mod md037_multiline_span_false_positive_test;
3131
mod md037_xxxx_regression_test;
3232
mod md038_false_positive_test;
3333
mod md040_blockquote_fence_issue_684_test;
34+
mod md040_list_marker_fence_test;
3435
mod md051_issue_39_regression_test;
3536
mod md051_readme_bug_test;
3637
mod md051_toc_bug_test;

0 commit comments

Comments
 (0)