Skip to content

Commit fd695f4

Browse files
committed
fix(formatter_markdown): fix more mismatches found in ecosystem-ci repos (#27003)
- Stop CJK whitespace rules at inline node edges - Keep a kept line break before a lone `|` row under `preserve` - Escape a fake setext underline only when it ends its line - Ignore backslash-escaped backticks when choosing a code span fence
1 parent e0b1f9f commit fd695f4

18 files changed

Lines changed: 399 additions & 27 deletions

‎crates/oxc_formatter_markdown/DIVERGENCES.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,34 @@ Prettier drops the alignment when the html starts at another column than the par
183183
An HTML block closed by its container's end carries that line ending as content (micromark's `trailing_newline`); it is not printed.
184184
Prettier prints it as a blank line of the blockquote, and inside a list item that line is not a fixpoint: every pass adds another.
185185
186+
## lazy-html-block
187+
188+
- Why: semantics
189+
- Pin: `tests/fixtures/markdown/lazy-html-block.md`
190+
191+
```markdown
192+
<!-- input -->
193+
> a
194+
<a>
195+
> c
196+
197+
<!-- ours -->
198+
> a
199+
>
200+
> <a>
201+
> c
202+
203+
<!-- prettier (the html becomes paragraph text) -->
204+
> a
205+
> <a>
206+
> c
207+
```
208+
209+
A complete type 7 tag on a lazy line opens an HTML block inside the container (micromark's rule, oxc-markdown-parser DIVERGENCES.md),
210+
though no type 7 block can interrupt a paragraph on a regular line.
211+
Printed with the container's prefix the line is no longer lazy, so a blank line keeps the block apart;
212+
Prettier prints it adjacent, and the next parse reads it as part of the paragraph.
213+
186214
## list-marker-after-ignored-list
187215
188216
- Why: semantics
@@ -239,6 +267,9 @@ The parser opens one on any name-like fence line (`:::name[label]{attrs}`, `:::
239267
240268
Prettier has no directive construct (#19662 would add micromark's grammar only), so under `always` / `never` the fences are words of the paragraph and wrapping merges them into the text, which breaks the container for every dialect that reads it.
241269
270+
An opener interrupts a paragraph (as in micromark's directive extension and markdown-it-container),
271+
so like any block it is printed after a blank line, under `preserve` too; Prettier keeps it on the paragraph's next line.
272+
242273
## line-shapes
243274
244275
- Why: semantics
@@ -263,6 +294,28 @@ a stray `:::`, or `[!` as the first line of a blockquote (GitHub / Obsidian aler
263294
Prettier joins and wraps them like any word: the alert above loses its marker line (GitHub needs `[!NOTE]` alone on it),
264295
a wrapped `<<<` line loses its `[title]`, and a component tag wrapped to column 0 opens an HTML block in MDX / VitePress.
265296
297+
## definition-shaped-first-line
298+
299+
- Why: semantics
300+
- Pin: `tests/fixtures/markdown/prose-wrap/definition-shaped-first-line.md`
301+
302+
```markdown
303+
<!-- input -->
304+
[core]: https://example.com/a/very/long/path/that/fills/most/xxxxxxxxxxxxxxxxxxxxxxxx then more
305+
306+
<!-- ours, proseWrap always -->
307+
[core]: https://example.com/a/very/long/path/that/fills/most/xxxxxxxxxxxxxxxxxxxxxxxx then more
308+
309+
<!-- prettier, proseWrap always (a definition and a paragraph) -->
310+
[core]:
311+
https://example.com/a/very/long/path/that/fills/most/xxxxxxxxxxxxxxxxxxxxxxxx
312+
then more
313+
```
314+
315+
A paragraph's first line shaped like a definition (`[label]: dest text`) is a paragraph only because of what follows the destination on the line,
316+
so it keeps its line boundaries and is never re-wrapped, under every `proseWrap`.
317+
Prettier wraps it like any line: a break right after the destination makes it a definition on the next parse.
318+
266319
## wrapped-block-starts
267320
268321
- Why: semantics (prettier/prettier#13634, prettier/prettier#19112, prettier/prettier#19847)
@@ -355,6 +408,8 @@ resumes at the container's content column, the only indentation the parser strip
355408
whatever whitespace follows is the node's content and is printed as is.
356409
Prettier prints code span lines from column 0 and the others from its own alignment (a task item's checkbox counts there, not for the parser),
357410
so the content of `` `x y` `` loses two spaces per pass and an HTML comment gains four.
411+
With no whitespace past the container's column the same column-0 line is a lazy continuation and reads the same,
412+
so there the difference is layout only (most real-world cases).
358413
359414
## wiki-link-code-span
360415

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ fn double_gap(
132132
// A type 7 HTML block can only follow a paragraph without a blank line as a lazy line of a container
133133
// (a micromark quirk, oxc-markdown-parser DIVERGENCES.md);
134134
// printed with the container's prefix it needs the blank line to stay a block.
135-
// Prettier prints it adjacent, where it becomes paragraph text.
135+
// Prettier prints it adjacent, where it becomes paragraph text (DIVERGENCES.md#lazy-html-block).
136136
let html_after_paragraph =
137137
matches!((previous, node), (Block::Paragraph(_), Block::HtmlBlock(h)) if h.kind != 7);
138138
let liquid = matches!(previous, Block::Liquid(_)) || matches!(node, Block::Liquid(_));

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use oxc_formatter_core::arena_cow_str;
1111
use oxc_markdown_parser::{
1212
Span,
1313
ast::{CodeSpan, Emphasis, HardBreakKind, Inline, LinkKind, Strong},
14-
unicode,
14+
escapes_next, unicode,
1515
};
1616

1717
use crate::{
@@ -124,7 +124,7 @@ fn code_span_literal_runs<'a>(
124124
let mut at = first.span().start;
125125
for &(span, is_code) in opaque {
126126
if span.start > at {
127-
mask |= run_mask(source.slice_range(at, span.start).as_bytes(), b'`');
127+
mask |= run_mask(source.slice_range(at, span.start).as_bytes(), b'`', true);
128128
}
129129
if is_code {
130130
out.push((span.start, mask));
@@ -550,23 +550,33 @@ fn mark_delimiter(open: u32, parts: &mut Parts<'_>, strong: bool) {
550550
}
551551

552552
/// Bit `n - 1` set: a run of exactly `n` `ch`s occurs in `bytes` (runs past 64 count as 64).
553-
fn run_mask(bytes: &[u8], ch: u8) -> u64 {
553+
/// `escapes`: `bytes` is markdown text, where a backslash-escaped `ch` is not part of a run.
554+
fn run_mask(bytes: &[u8], ch: u8, escapes: bool) -> u64 {
554555
let mut present: u64 = 0;
555556
let mut run = 0usize;
556-
for &b in bytes.iter().chain(std::iter::once(&0)) {
557-
if b == ch {
557+
let mut i = 0;
558+
loop {
559+
let b = bytes.get(i).copied();
560+
if b == Some(ch) {
558561
run += 1;
559-
} else if run > 0 {
562+
i += 1;
563+
continue;
564+
}
565+
if run > 0 {
560566
present |= 1u64 << (run.min(64) - 1);
561567
run = 0;
562568
}
569+
match b {
570+
None => return present,
571+
Some(b'\\') if escapes && escapes_next(bytes, i) => i += 2,
572+
Some(_) => i += 1,
573+
}
563574
}
564-
present
565575
}
566576

567577
/// The smallest run length of `ch` absent from `text`.
568578
fn min_absent_run(text: &str, ch: u8) -> usize {
569-
run_mask(text.as_bytes(), ch).trailing_ones() as usize + 1
579+
run_mask(text.as_bytes(), ch, false).trailing_ones() as usize + 1
570580
}
571581

572582
/// Runs of tabs / newlines become one space (wiki link contents under wrapping).
@@ -606,15 +616,16 @@ fn next_word_of<'a>(
606616
let word = first_word(line)?;
607617
// A text's leading run after a soft break is escaped inside emphasis
608618
// (as `push_text` decides: a space before it, and after it the text's next character or the node edge)
609-
let escaped = matches!(next, Inline::Text(_))
619+
let in_sentence = matches!(next, Inline::Text(_));
620+
let escaped = in_sentence
610621
&& f.context().delimiter_depth().get() > 0
611622
&& !f.context().literal_markers().get()
612623
&& escape::leading_run_escaped(
613624
word,
614625
Some(' '),
615626
edge_char(children, i + 1, delimiter, false, f),
616627
);
617-
Some(words::NextWord { word, line, escaped })
628+
Some(words::NextWord { word, line, escaped, in_sentence })
618629
}
619630

620631
fn first_word(line: &str) -> Option<&str> {

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use super::{
2525
print_delimited_word,
2626
},
2727
is_split_whitespace,
28-
line_shape::{is_line_shape_start, may_open_block},
28+
line_shape::{is_line_shape_start, line_opens_block, may_open_block},
2929
parts::{Parts, Sep},
3030
};
3131

@@ -39,6 +39,9 @@ pub struct NextWord<'a> {
3939
/// The word's leading `*` / `_` run gets escaped (inside emphasis):
4040
/// printed, it starts with `\` and cannot open a block.
4141
pub escaped: bool,
42+
/// The word starts a text of the same sentence: the CJK rules see it.
43+
/// Past another node's edge they do not (Prettier's sentence ends there), so the whitespace may break and stays a space.
44+
pub in_sentence: bool,
4245
}
4346

4447
#[derive(Clone, Copy, Default)]
@@ -116,7 +119,7 @@ pub fn push_text<'a>(
116119
Some(glued) if is_last => glued,
117120
_ => word,
118121
};
119-
Some(NextWord { word, line: "", escaped })
122+
Some(NextWord { word, line: "", escaped, in_sentence: true })
120123
};
121124
let cx = TextContext {
122125
next_word: next,
@@ -152,6 +155,8 @@ pub fn push_text<'a>(
152155
&& is_last
153156
&& !leading_ws
154157
&& cx.after_soft_break
158+
// Alone on its line: a node after it (`== `x``) makes the line text
159+
&& cx.next_word.is_none()
155160
&& is_fake_setext_underline(word)
156161
{
157162
Cow::Owned(format!("\\{word}"))
@@ -208,7 +213,9 @@ pub fn push_whitespace<'a>(
208213
let (prev, next) = if cx.cj_spaces.is_some() {
209214
(
210215
cx.prev_word.and_then(|w| cjk::edges(w).map(|(_, last)| last)),
211-
cx.next_word.and_then(|w| cjk::edges(w.word).map(|(first, _)| first)),
216+
cx.next_word
217+
.filter(|w| w.in_sentence)
218+
.and_then(|w| cjk::edges(w.word).map(|(first, _)| first)),
212219
)
213220
} else {
214221
(None, None)
@@ -355,15 +362,16 @@ fn looks_like_block_start(next: NextWord<'_>, exact: bool) -> bool {
355362
if !target.as_bytes().first().is_some_and(|&b| may_open_block(b)) {
356363
return false;
357364
}
365+
if exact {
366+
// A `|` row is inert on its own: the delimiter row after it is what makes a table, and that break is kept apart
367+
return line_opens_block(target, true) || is_line_shape_start(target);
368+
}
358369
let constructs = Constructs::markdown();
359370
// A dialect line shape at a line start is printed raw from then on: never create one
360371
// (asked about the word alone, a `:::` word counts even where `::: note` would be an opener)
361372
if lexical::line_start(&constructs, target, true).is_some() || is_line_shape_start(target) {
362373
return true;
363374
}
364-
if exact {
365-
return false;
366-
}
367375
let mut buf = [0u8; 64];
368376
let len = next.word.len();
369377
if len + 2 > buf.len() {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
A lone `==` / `--` line that stayed text by its indentation
2+
==
3+
is escaped, one with more after it on the line
4+
== `code` or -- **strong**
5+
is text as it stands.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs
3+
---
4+
==================== Input ====================
5+
A lone `==` / `--` line that stayed text by its indentation
6+
==
7+
is escaped, one with more after it on the line
8+
== `code` or -- **strong**
9+
is text as it stands.
10+
11+
==================== Output ====================
12+
------------------
13+
{ printWidth: 80 }
14+
------------------
15+
A lone `==` / `--` line that stayed text by its indentation
16+
\==
17+
is escaped, one with more after it on the line
18+
== `code` or -- **strong**
19+
is text as it stands.
20+
21+
-------------------
22+
{ printWidth: 100 }
23+
-------------------
24+
A lone `==` / `--` line that stayed text by its indentation
25+
\==
26+
is escaped, one with more after it on the line
27+
== `code` or -- **strong**
28+
is text as it stands.
29+
30+
===================== End =====================
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
> a
2+
<a>
3+
> c
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs
3+
---
4+
==================== Input ====================
5+
> a
6+
<a>
7+
> c
8+
9+
==================== Output ====================
10+
------------------
11+
{ printWidth: 80 }
12+
------------------
13+
> a
14+
>
15+
> <a>
16+
> c
17+
18+
-------------------
19+
{ printWidth: 100 }
20+
-------------------
21+
> a
22+
>
23+
> <a>
24+
> c
25+
26+
===================== End =====================

‎crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/cjk.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,12 @@ Englishの前でも消えます。
2121

2222
括弧の前 (paren) と後ろ、ASCII 記号の
2323
:: 前後は空白になります。
24+
25+
<!-- The rules stop at another node's edge, as Prettier's sentence does:
26+
the whitespace there may break and a line break stays a space
27+
(joined, `:www.` would no longer be an autolink literal) -->
28+
29+
使用方法请参考文档中的说明,如果需要了解更多信息,请参考 [应用部署](./deployment.md)。
30+
31+
更多细节,请参考文档:
32+
www.example.com 和 `code`。

0 commit comments

Comments
 (0)