Skip to content

Commit b939645

Browse files
committed
fix(formatter_markdown): keep a line break before an inline liquid tag under preserve (#26998)
Follow Prettier 3.9.8 changes: prettier/prettier#20087
1 parent 4043bfb commit b939645

4 files changed

Lines changed: 37 additions & 81 deletions

File tree

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

Lines changed: 14 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::borrow::Cow;
77

88
use cow_utils::CowUtils;
99

10+
use oxc_formatter_core::arena_cow_str;
1011
use oxc_markdown_parser::{
1112
Span,
1213
ast::{CodeSpan, Emphasis, HardBreakKind, Inline, LinkKind, Strong},
@@ -23,15 +24,15 @@ use super::{
2324
HTML_WHITESPACE, MarkdownFormatter, backticks, escape, is_split_whitespace, join_pieces,
2425
line_shape::{
2526
is_line_shape_start, line_from, line_opens_block, line_or_prefix_opens_block, line_shape,
26-
printed_line_opens_block, source_line_at,
27+
printed_line_at, printed_line_opens_block, source_line_at,
2728
},
2829
link,
2930
parts::{Atom, Parts, Sep},
3031
text as words, with_depth,
3132
};
3233

3334
/// Where the inline children hang; a few rules depend on the immediate parent.
34-
#[derive(Clone, Copy)]
35+
#[derive(Clone, Copy, Default)]
3536
pub struct InlineParent {
3637
/// The children are a paragraph's (its first / last text gets trimmed).
3738
pub paragraph: bool,
@@ -48,22 +49,6 @@ pub struct InlineParent {
4849
pub first_in_document: bool,
4950
/// The paragraph follows a task list checkbox on its line: its first line is not a line start.
5051
pub after_checkbox: bool,
51-
/// Nothing follows the parent node on its source line (its last child ends the line).
52-
pub ends_line: bool,
53-
}
54-
55-
impl Default for InlineParent {
56-
fn default() -> Self {
57-
Self {
58-
paragraph: false,
59-
delimiter: None,
60-
strong_neighbor: None,
61-
first_in_container: false,
62-
first_in_document: false,
63-
after_checkbox: false,
64-
ends_line: true,
65-
}
66-
}
6752
}
6853

6954
/// Paragraph or heading content as one fill.
@@ -375,7 +360,6 @@ pub fn collect_inlines<'a>(
375360
),
376361
after_liquid: follows_liquid(children, i),
377362
before_soft_break: matches!(children.get(i + 1), Some(Inline::SoftBreak(_))),
378-
ends_line: ends_line(children, i, parent.ends_line),
379363
next_word: next_word_of(children, i, parent.delimiter, f),
380364
glued_last_word: glued_last_word(children, i, raw, f),
381365
cj_spaces,
@@ -432,7 +416,6 @@ pub fn collect_inlines<'a>(
432416
parts.push_str(style);
433417
let inner = InlineParent {
434418
delimiter: Some(style.as_bytes()[0]),
435-
ends_line: ends_line(children, i, parent.ends_line),
436419
..InlineParent::default()
437420
};
438421
with_depth(f, MarkdownFormatContext::emphasis_depth, |f| {
@@ -450,7 +433,6 @@ pub fn collect_inlines<'a>(
450433
let inner = InlineParent {
451434
delimiter: Some(style.as_bytes()[0]),
452435
strong_neighbor: Some(has_word_neighbor(children, i, f)),
453-
ends_line: ends_line(children, i, parent.ends_line),
454436
..InlineParent::default()
455437
};
456438
with_depth(f, MarkdownFormatContext::delimiter_depth, |f| {
@@ -463,10 +445,7 @@ pub fn collect_inlines<'a>(
463445
let style = &"~~"[..usize::from(s.tildes)];
464446
let open = parts.len();
465447
parts.push_str(style);
466-
let inner = InlineParent {
467-
ends_line: ends_line(children, i, parent.ends_line),
468-
..InlineParent::default()
469-
};
448+
let inner = InlineParent::default();
470449
collect_inlines(&s.children, inner, parts, f);
471450
mark_delimiter(open, parts, false);
472451
parts.push_str(style);
@@ -596,12 +575,10 @@ fn next_word_of<'a>(
596575
if matches!(next, Inline::SoftBreak(_) | Inline::HardBreak(_)) {
597576
return None;
598577
}
599-
// The word runs across nodes (`[^1]:` is a footnote reference and a text)
600-
let (mut word, alone_on_line) = first_word_at(children, next.span().start, f)?;
601-
// A code span's fence is recomputed: the page shows the printed one, not the source's
602-
if let Inline::CodeSpan(c) = next {
603-
word = print_code_span(c, f).split(is_split_whitespace).next().unwrap_or(word);
604-
}
578+
// The word runs across nodes (`[^1]:` is a footnote reference and a text);
579+
// a code span's fence is recomputed, so the line is taken as it prints
580+
let line: &'a str = arena_cow_str(&printed_line_at(children, i + 1, f).text, f);
581+
let word = first_word(line)?;
605582
// A text's leading run after a soft break is escaped inside emphasis
606583
// (as `push_text` decides: a space before it, and after it the text's next character or the node edge)
607584
let escaped = matches!(next, Inline::Text(_))
@@ -612,29 +589,11 @@ fn next_word_of<'a>(
612589
Some(' '),
613590
edge_char(children, i + 1, delimiter, false, f),
614591
);
615-
Some(words::NextWord { word, alone_on_line, escaped })
592+
Some(words::NextWord { word, line, escaped })
616593
}
617594

618-
/// The first word of the source line starting at `start`, and whether it is the line's only one.
619-
fn first_word_at<'a>(
620-
children: &[Inline<'_>],
621-
start: u32,
622-
f: &MarkdownFormatter<'_, 'a>,
623-
) -> Option<(&'a str, bool)> {
624-
let (_, line) = source_line_at(children, start, f);
625-
let mut it = line.split(is_split_whitespace).filter(|w| !w.is_empty());
626-
let word = it.next()?;
627-
Some((word, it.next().is_none()))
628-
}
629-
630-
/// Nothing follows node `i` on its source line (a node glued after it would be part of the line);
631-
/// past the last sibling, whether the parent ends its line.
632-
fn ends_line(children: &[Inline<'_>], i: usize, parent_ends_line: bool) -> bool {
633-
match children.get(i + 1) {
634-
None => parent_ends_line,
635-
Some(Inline::SoftBreak(_) | Inline::HardBreak(_)) => true,
636-
Some(_) => false,
637-
}
595+
fn first_word(line: &str) -> Option<&str> {
596+
line.split(is_split_whitespace).find(|w| !w.is_empty())
638597
}
639598

640599
/// The character right before (`before`) or after the text at `i`, as it will be printed:
@@ -663,13 +622,13 @@ fn edge_char<'a>(
663622
}
664623

665624
/// The last word of text `i` (its printed `raw`) extended by the nodes glued after it
666-
/// on its source line (a backslash hard break's `\` included), and whether it is the line's last word.
625+
/// on its source line (a backslash hard break's `\` included).
667626
fn glued_last_word<'a>(
668627
children: &'a [Inline<'a>],
669628
i: usize,
670629
raw: &str,
671630
f: &MarkdownFormatter<'_, 'a>,
672-
) -> Option<(&'a str, bool)> {
631+
) -> Option<&'a str> {
673632
// Nothing glues after whitespace, a line end, or a two-space hard break
674633
match children.get(i + 1) {
675634
None | Some(Inline::SoftBreak(_)) => return None,
@@ -679,7 +638,7 @@ fn glued_last_word<'a>(
679638
}
680639
let last = raw.rsplit(is_split_whitespace).next()?;
681640
let start = children[i].span().end - u32::try_from(last.len()).unwrap_or(0);
682-
first_word_at(children, start, f).filter(|(word, _)| word.len() != last.len())
641+
first_word(source_line_at(children, start, f).1).filter(|word| word.len() != last.len())
683642
}
684643

685644
/// The last word of the text before node `i`.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub fn collect_link<'a>(
3636
// A full reference's text wraps like any text: its `][label]` tail is short.
3737
// An inline link's does not: its `](url)` tail is unbreakable and usually what overflows,
3838
// so a break inside the text would only split it without fitting the line.
39-
let inner = InlineParent { ends_line: false, ..InlineParent::default() };
39+
let inner = InlineParent::default();
4040
let text_start = parts.len();
4141
if matches!(link.kind, LinkKind::Inline { .. }) {
4242
with_depth(f, MarkdownFormatContext::no_wrap_depth, |f| {

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

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ use super::{
3333
#[derive(Clone, Copy)]
3434
pub struct NextWord<'a> {
3535
pub word: &'a str,
36-
/// The word is its text's only one.
37-
pub alone_on_line: bool,
36+
/// The source line from the word on, as it prints: the line a kept break puts the word at the start of.
37+
/// Only `preserve` reads it (a text never spans a line, so only `next_word_of` fills it).
38+
pub line: &'a str,
3839
/// The word's leading `*` / `_` run gets escaped (inside emphasis):
3940
/// printed, it starts with `\` and cannot open a block.
4041
pub escaped: bool,
@@ -61,13 +62,11 @@ pub struct TextContext<'a> {
6162
pub after_liquid: bool,
6263
/// The next sibling is a soft break (the text ends a source line).
6364
pub before_soft_break: bool,
64-
/// Nothing follows the text on its source line (a node glued after it would be part of the line).
65-
pub ends_line: bool,
6665
/// The first word the next sibling starts, for the whitespace that ends this text.
6766
pub next_word: Option<NextWord<'a>>,
6867
/// The text's last word with the nodes glued after it on the source line (`:-:` + `[[w]]`),
69-
/// the word a break before it would put at a line start, and whether it ends the line.
70-
pub glued_last_word: Option<(&'a str, bool)>,
68+
/// the word a break before it would put at a line start.
69+
pub glued_last_word: Option<&'a str>,
7170
/// The last word of the previous sibling text (a soft break's other side), for the CJK rules only;
7271
/// a trailing `\` there was escaped by the text (see `push_text`).
7372
pub prev_word: Option<&'a str>,
@@ -113,11 +112,11 @@ pub fn push_text<'a>(
113112
let next = if is_last { cx.edge_next } else { Some(' ') };
114113
leading_run_escaped(word, prev, next)
115114
};
116-
let (word, alone) = match cx.glued_last_word {
115+
let word = match cx.glued_last_word {
117116
Some(glued) if is_last => glued,
118-
_ => (word, is_last && cx.ends_line),
117+
_ => word,
119118
};
120-
Some(NextWord { word, alone_on_line: is_first && alone, escaped })
119+
Some(NextWord { word, line: "", escaped })
121120
};
122121
let cx = TextContext {
123122
next_word: next,
@@ -340,35 +339,31 @@ pub fn prevents_break(newline: bool, next: Option<NextWord<'_>>, prose_wrap: Pro
340339
// A lone `---` / `===` after a newline is going to be escaped as a fake setext underline instead
341340
!(prose_wrap == ProseWrap::Preserve
342341
&& newline
343-
&& is_fake_setext_underline(next.word)
344-
&& next.alone_on_line)
342+
&& is_fake_setext_underline(next.line.trim_end_matches(is_split_whitespace)))
345343
}
346344

347345
/// The parser's line-start classification (`lexical::line_start`), asked about the word alone:
348346
/// list markers, `#`s, `>`, thematic breaks and setext underlines, fences, HTML and directive openers,
349347
/// table rows, footnote definitions.
350348
/// (Prettier tests `/^>|^(?:[*+-]|#{1,6}|\d+[).])$/` only, which is how a wrapped `<div>` or `***` opens a block.)
351349
fn looks_like_block_start(next: NextWord<'_>, exact: bool) -> bool {
352-
// Asked about the line the wrap would produce:
350+
// `exact` (a kept break under `preserve`): the line is known, ask about it.
351+
// Otherwise the line depends on the wrapping itself, so both candidates count:
353352
// the word alone (`***` is a break, `-|-` a delimiter row)
354-
// or the word with content after it (`- x` interrupts a paragraph where an empty `-` does not).
355-
// `exact`: the word's position is known; otherwise it depends on the wrapping itself, so both count.
356-
if !may_open_block(next.word.as_bytes()[0]) {
353+
// and the word with content after it (`- x` interrupts a paragraph where an empty `-` does not).
354+
let target = if exact { next.line } else { next.word };
355+
if !target.as_bytes().first().is_some_and(|&b| may_open_block(b)) {
357356
return false;
358357
}
359358
let constructs = Constructs::markdown();
360-
if (!exact || next.alone_on_line) && lexical::line_start(&constructs, next.word, true).is_some()
361-
{
359+
// A dialect line shape at a line start is printed raw from then on: never create one
360+
// (asked about the word alone, a `:::` word counts even where `::: note` would be an opener)
361+
if lexical::line_start(&constructs, target, true).is_some() || is_line_shape_start(target) {
362362
return true;
363363
}
364-
if exact && next.alone_on_line {
364+
if exact {
365365
return false;
366366
}
367-
// A dialect line shape at a line start is printed raw from then on: never create one
368-
// (asked about the word alone, so a `:::` word counts even where `::: note` would be an opener)
369-
if is_line_shape_start(next.word) {
370-
return true;
371-
}
372367
let mut buf = [0u8; 64];
373368
let len = next.word.len();
374369
if len + 2 > buf.len() {

‎crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/liquid-inline-wrap.md.snap‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ Actions policies let you govern how {% data variables.product.prodname_actions %
8080
A tag at the end of the paragraph never ends a line on its own, so it stays glued {% data variables.product.prodname_actions %}
8181

8282
The {{jsxref("a")}} statement creates (including
83-
{{jsxref("Array")}}, {{jsxref("Map")}}, {{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
83+
{{jsxref("Array")}}, {{jsxref("Map")}},
84+
{{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
8485

8586
> [!NOTE]
8687
> {% data reusables.x %} For more information.
@@ -93,7 +94,8 @@ Actions policies let you govern how {% data variables.product.prodname_actions %
9394
A tag at the end of the paragraph never ends a line on its own, so it stays glued {% data variables.product.prodname_actions %}
9495

9596
The {{jsxref("a")}} statement creates (including
96-
{{jsxref("Array")}}, {{jsxref("Map")}}, {{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
97+
{{jsxref("Array")}}, {{jsxref("Map")}},
98+
{{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
9799

98100
> [!NOTE]
99101
> {% data reusables.x %} For more information.

0 commit comments

Comments
 (0)