Skip to content

Commit 71e400b

Browse files
committed
fix(formatter_markdown): fixed remaining issues found by fuzz (#27334)
- keep a lazy HTML block's continuation lines in the list item - print a footnote paragraph on its marker line only when it prints as one line
1 parent 72f42a3 commit 71e400b

10 files changed

Lines changed: 275 additions & 72 deletions

File tree

‎crates/oxc_formatter_markdown/AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,8 @@ Extend the ignore set only with a reason written next to it.
9696
`tests/invariants.rs` formats a deterministic token-soup corpus (the parser repo's differential generator) under every `proseWrap` and checks the fingerprint and idempotency.
9797
The default corpus must fail exactly on `KNOWN_FAILURES` (the documents and their classes are listed there);
9898
remove an entry when its class is fixed, any other failure is a regression.
99+
100+
Other seeds pass (3 / 5 / 7 / 8 / 13 / 21 / 42 / 99) except one known class, seed 11 #5:
101+
a liquid tag continued on a lazy line (`2. {% x` + `y %}`) is inline in a paragraph,
102+
re-indented into the item it becomes a liquid flow block (micromark reads the two the same way).
99103
`MD_FUZZ_SEED` / `MD_FUZZ_COUNT` run other corpora, `MD_FUZZ_FILE` prints one document's fingerprints.

‎crates/oxc_formatter_markdown/DIVERGENCES.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,20 @@ though no type 7 block can interrupt a paragraph on a regular line.
214214
Printed with the container's prefix the line is no longer lazy, so a blank line keeps the block apart;
215215
Prettier prints it adjacent, and the next parse reads it as part of the paragraph.
216216
217+
In a tight list item the line stays lazy, and the block's other lines keep the item's content column:
218+
219+
```markdown
220+
<!-- input, ours -->
221+
- a
222+
<a>
223+
b
224+
225+
<!-- prettier (the html loses the 2 columns before `b`) -->
226+
- a
227+
<a>
228+
b
229+
```
230+
217231
## list-after-html-block
218232
219233
- Why: semantics (prettier/prettier#17690)
@@ -636,3 +650,34 @@ a
636650
A fenced code block whose info string has a backtick keeps its `~~~` fence; any other fence is printed with backticks.
637651
A backtick fence cannot have a backtick in its info string (CommonMark),
638652
so Prettier's opener is a paragraph on the next parse and its closer opens a code block that runs to the end of the document.
653+
654+
## footnote-kept-line-break
655+
656+
- Why: semantics
657+
- Pin: `tests/fixtures/markdown/prose-wrap/footnote-kept-line-break.md`
658+
659+
```markdown
660+
<!-- input -->
661+
[^1]: a b\
662+
=
663+
664+
<!-- ours (every proseWrap) -->
665+
[^1]:
666+
a b\
667+
=
668+
669+
<!-- prettier, preserve (a setext heading on the next parse) -->
670+
[^1]:
671+
a b\
672+
=
673+
674+
<!-- prettier, never -->
675+
[^1]: a b\
676+
=
677+
```
678+
679+
A footnote's single paragraph follows the marker only when it prints as one line, under every `proseWrap`:
680+
`always` also breaks it for the width, the other modes only for a line break the paragraph keeps.
681+
Prettier asks `preserve` whether the source is one line, and its block form above moves the lazy `=` into the footnote,
682+
where it underlines the paragraph.
683+
Its `never` keeps the marker line whatever the paragraph holds (admissible here); ours follows the same rule as the other modes.

‎crates/oxc_formatter_markdown/src/print/block.rs‎

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Block dispatch and the blank-line policy between siblings.
22
33
use oxc_formatter_core::{
4-
Buffer,
4+
Buffer, FormatElements, MemoizeFormat,
55
builders::{
66
exact_line_breaks, group, hard_line_break, literal_line_break, mark_as_root,
77
soft_line_break_or_space, space, space_align, text, token,
@@ -72,7 +72,7 @@ pub fn write_blocks<'a>(
7272
}
7373
}
7474

75-
pub fn write_gap(
75+
fn write_gap(
7676
siblings: &[Block<'_>],
7777
index: usize,
7878
parent: Parent,
@@ -82,7 +82,7 @@ pub fn write_gap(
8282
}
8383

8484
/// Whether `siblings[index]` gets a blank line before it.
85-
fn double_gap(
85+
pub fn double_gap(
8686
siblings: &[Block<'_>],
8787
index: usize,
8888
parent: Parent,
@@ -322,47 +322,33 @@ fn write_heading<'a>(
322322
}
323323
}
324324

325-
/// `[^label]: ` + content.
326-
/// A lone paragraph stays on the label's line under `never` (and under `preserve` when it is one source line);
327-
/// otherwise the content is aligned by 4 and the first block moves to its own line when it does not fit
328-
/// (or is multi-line).
325+
/// `[^label]: ` + content, aligned by 4.
326+
/// A lone paragraph that prints as one line stays on the label's line (under `always` the group decides),
327+
/// anything else starts on the next line (DIVERGENCES.md#footnote-kept-line-break).
329328
fn write_footnote_definition<'a>(
330329
def: &'a FootnoteDefinition<'a>,
331330
f: &mut MarkdownFormatter<'_, 'a>,
332331
) {
333332
write!(f, [token("[^"), text(f.context().slice(def.label)), token("]:")]);
334333
let children = &def.children;
335-
let inline = match children.as_slice() {
336-
[Block::Paragraph(p)] => match f.options().prose_wrap {
337-
ProseWrap::Never => true,
338-
// Prettier asks whether the source paragraph is one line;
339-
// asking whether it prints as one keeps the answer stable across passes
340-
// (a line break joined away would flip it).
341-
ProseWrap::Preserve => !inline::keeps_a_line_break(&p.children, f),
342-
ProseWrap::Always => false,
343-
},
344-
_ => false,
345-
};
346-
if inline {
347-
write!(f, space());
348-
write_blocks(children, Parent::Container, f);
349-
return;
350-
}
351334
let parent = Parent::Container;
352-
write!(
353-
f,
354-
space_align(
355-
4,
356-
&mark_as_root(&format_with(|f| {
357-
let first = format_with(|f| write_block(&children[0], children, 0, parent, f));
358-
write!(f, group(&format_args!(soft_line_break_or_space(), first)));
359-
for index in 1..children.len() {
360-
write_gap(children, index, parent, f);
361-
write_block(&children[index], children, index, parent, f);
362-
}
363-
}))
364-
)
365-
);
335+
let first = format_with(|f| write_block(&children[0], children, 0, parent, f)).memoized();
336+
let on_label_line = matches!(children.as_slice(), [Block::Paragraph(_)])
337+
&& f.options().prose_wrap != ProseWrap::Always
338+
&& !first.inspect(f).will_break();
339+
let body = format_with(|f| {
340+
if on_label_line {
341+
write!(f, [space(), first]);
342+
} else {
343+
write!(f, group(&format_args!(soft_line_break_or_space(), first)));
344+
}
345+
for index in 1..children.len() {
346+
write_gap(children, index, parent, f);
347+
write_block(&children[index], children, index, parent, f);
348+
}
349+
});
350+
// Also on the label's line: the 4-space guard of a block-start-looking line counts from the content column
351+
write!(f, space_align(4, &mark_as_root(&body)));
366352
}
367353

368354
/// `---`, except inside a list

‎crates/oxc_formatter_markdown/src/print/inline.rs‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -203,23 +203,6 @@ fn markers_as_written(f: &MarkdownFormatter<'_, '_>) -> bool {
203203
f.context().raw_text().get() == Raw::Line || f.context().literal_markers().get()
204204
}
205205

206-
/// Under `proseWrap: preserve`, whether any soft break of this paragraph survives printing
207-
/// (one before a block-start-looking word is joined away; one next to a dialect shape line stays).
208-
pub fn keeps_a_line_break<'a>(children: &'a [Inline<'a>], f: &MarkdownFormatter<'_, 'a>) -> bool {
209-
let last = children.len().wrapping_sub(1);
210-
children.iter().enumerate().any(|(i, child)| {
211-
matches!(child, Inline::SoftBreak(_))
212-
&& i != 0
213-
&& i != last
214-
&& (line_shape(children, i + 1, false, false, f)
215-
|| !words::prevents_break(
216-
true,
217-
next_word_of(children, i, None, f),
218-
ProseWrap::Preserve,
219-
))
220-
})
221-
}
222-
223206
/// Prettier's `riskyParagraphPositions`: a `[[` in text, followed by `]]` (text or a wiki link's).
224207
/// Wrapping such a paragraph could merge `[[foo\n[[wiki link]]` into one link,
225208
/// so its text is printed as written.

‎crates/oxc_formatter_markdown/src/print/list.rs‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
use oxc_formatter_core::{
77
Buffer,
8-
builders::{mark_as_root, space_align, text, token},
8+
builders::{dedent, hard_line_break, mark_as_root, space_align, text, token},
99
write,
1010
};
1111
use oxc_markdown_parser::{
@@ -87,24 +87,10 @@ pub fn write_list<'a>(
8787
let prefix: &'a str = f.allocator().alloc_str(&prefix);
8888
write!(f, text(prefix));
8989

90-
// A `[paragraph, html]` item whose html sits left of the content column is
91-
// micromark's lazy type 7 quirk (oxc-markdown-parser DIVERGENCES.md): the html stays where it was, unaligned.
92-
// (Prettier drops the alignment whenever the two columns differ,
93-
// which moves an html block indented past a task checkbox out of the item:
94-
// DIVERGENCES.md#list-html-block-alignment)
95-
let skip_align =
96-
item.children.len() == 2 && matches!(item.children[1], Block::HtmlBlock(_)) && {
97-
let source = f.context().source_text().as_str();
98-
let content_column = item
99-
.checkbox
100-
.map_or(item.children[0].span().start, |checkbox| checkbox.span.start);
101-
column_of(source, item.children[1].span().start) < column_of(source, content_column)
102-
};
10390
let body = format_with(|f| write_list_item(item, checkbox, prefix.len(), loose, f));
104-
let width = if skip_align { 0 } else { prefix.len() };
10591
// The item's content column is the root that verbatim continuation lines return to.
10692
// Its columns are syntax, spaces under `useTabs` too (see `space_align`).
107-
write!(f, space_align(width, &mark_as_root(&body)));
93+
write!(f, space_align(prefix.len(), &mark_as_root(&body)));
10894
}
10995

11096
f.context().lists().borrow_mut().pop();
@@ -127,7 +113,19 @@ fn write_list_item<'a>(
127113

128114
for (i, child) in item.children.iter().enumerate() {
129115
if i > 0 {
130-
block::write_gap(&item.children, i, parent, f);
116+
let blank = block::double_gap(&item.children, i, parent, f);
117+
// A type 7 HTML block right after a paragraph starts on a lazy line
118+
// (micromark's quirk, oxc-markdown-parser DIVERGENCES.md): that line stays in the parent's column,
119+
// the block's other lines are in the item (DIVERGENCES.md#lazy-html-block)
120+
let lazy_html = matches!(
121+
(&item.children[i - 1], child),
122+
(Block::Paragraph(_), Block::HtmlBlock(h)) if h.kind == 7
123+
);
124+
if lazy_html && !blank {
125+
write!(f, dedent(&hard_line_break()));
126+
} else {
127+
write_gap(blank, f);
128+
}
131129
}
132130
// The first block follows the checkbox on its line: its first line is not a line start
133131
let child_parent = if i == 0 && !checkbox.is_empty() {

‎crates/oxc_formatter_markdown/src/print/text.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ pub fn push_whitespace<'a>(
241241
}
242242

243243
/// Never break before a word that would start a block.
244-
pub fn prevents_break(newline: bool, next: Option<NextWord<'_>>, prose_wrap: ProseWrap) -> bool {
244+
fn prevents_break(newline: bool, next: Option<NextWord<'_>>, prose_wrap: ProseWrap) -> bool {
245245
let Some(next) = next else { return false };
246246
if next.escaped {
247247
return false;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
> a
22
<a>
33
> c
4+
5+
- a
6+
<a>
7+
b
8+
9+
* a\
10+
# x
11+
<a>
12+
b
13+
14+
1. a
15+
<a>
16+
b
17+
18+
2. c
19+
20+
- # h
21+
a
22+
<a>
23+
b

‎crates/oxc_formatter_markdown/tests/fixtures/markdown/lazy-html-block.md.snap‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs
66
<a>
77
> c
88

9+
- a
10+
<a>
11+
b
12+
13+
* a\
14+
# x
15+
<a>
16+
b
17+
18+
1. a
19+
<a>
20+
b
21+
22+
2. c
23+
24+
- # h
25+
a
26+
<a>
27+
b
28+
929
==================== Output ====================
1030
------------------
1131
{ printWidth: 80 }
@@ -15,6 +35,27 @@ source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs
1535
> <a>
1636
> c
1737

38+
- a
39+
<a>
40+
b
41+
42+
* a\
43+
# x
44+
<a>
45+
b
46+
47+
1. a
48+
49+
<a>
50+
b
51+
52+
2. c
53+
54+
- # h
55+
a
56+
<a>
57+
b
58+
1859
-------------------
1960
{ printWidth: 100 }
2061
-------------------
@@ -23,4 +64,25 @@ source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs
2364
> <a>
2465
> c
2566

67+
- a
68+
<a>
69+
b
70+
71+
* a\
72+
# x
73+
<a>
74+
b
75+
76+
1. a
77+
78+
<a>
79+
b
80+
81+
2. c
82+
83+
- # h
84+
a
85+
<a>
86+
b
87+
2688
===================== End =====================
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[^1]: a b\
2+
=
3+
4+
[^2]: *a\
5+
b*
6+
7+
[^3]: a
8+
b

0 commit comments

Comments
 (0)