mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(formatter_markdown): keep a line break before an inline liquid tag under preserve (#26998)
Follow Prettier 3.9.8 changes: https://github.com/prettier/prettier/pull/20087
This commit is contained in:
1 parent
4043bfb30e
commit
b939645e3d
4 files changed
+38
-82
No files matched your search
@@ -7,6 +7,7 @@ use std::borrow::Cow;
|
||||
|
||||
use cow_utils::CowUtils;
|
||||
|
||||
use oxc_formatter_core::arena_cow_str;
|
||||
use oxc_markdown_parser::{
|
||||
Span,
|
||||
ast::{CodeSpan, Emphasis, HardBreakKind, Inline, LinkKind, Strong},
|
||||
@@ -23,7 +24,7 @@ use super::{
|
||||
HTML_WHITESPACE, MarkdownFormatter, backticks, escape, is_split_whitespace, join_pieces,
|
||||
line_shape::{
|
||||
is_line_shape_start, line_from, line_opens_block, line_or_prefix_opens_block, line_shape,
|
||||
printed_line_opens_block, source_line_at,
|
||||
printed_line_at, printed_line_opens_block, source_line_at,
|
||||
},
|
||||
link,
|
||||
parts::{Atom, Parts, Sep},
|
||||
@@ -31,7 +32,7 @@ use super::{
|
||||
};
|
||||
|
||||
/// Where the inline children hang; a few rules depend on the immediate parent.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct InlineParent {
|
||||
/// The children are a paragraph's (its first / last text gets trimmed).
|
||||
pub paragraph: bool,
|
||||
@@ -48,22 +49,6 @@ pub struct InlineParent {
|
||||
pub first_in_document: bool,
|
||||
/// The paragraph follows a task list checkbox on its line: its first line is not a line start.
|
||||
pub after_checkbox: bool,
|
||||
/// Nothing follows the parent node on its source line (its last child ends the line).
|
||||
pub ends_line: bool,
|
||||
}
|
||||
|
||||
impl Default for InlineParent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
paragraph: false,
|
||||
delimiter: None,
|
||||
strong_neighbor: None,
|
||||
first_in_container: false,
|
||||
first_in_document: false,
|
||||
after_checkbox: false,
|
||||
ends_line: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph or heading content as one fill.
|
||||
@@ -375,7 +360,6 @@ pub fn collect_inlines<'a>(
|
||||
),
|
||||
after_liquid: follows_liquid(children, i),
|
||||
before_soft_break: matches!(children.get(i + 1), Some(Inline::SoftBreak(_))),
|
||||
ends_line: ends_line(children, i, parent.ends_line),
|
||||
next_word: next_word_of(children, i, parent.delimiter, f),
|
||||
glued_last_word: glued_last_word(children, i, raw, f),
|
||||
cj_spaces,
|
||||
@@ -432,7 +416,6 @@ pub fn collect_inlines<'a>(
|
||||
parts.push_str(style);
|
||||
let inner = InlineParent {
|
||||
delimiter: Some(style.as_bytes()[0]),
|
||||
ends_line: ends_line(children, i, parent.ends_line),
|
||||
..InlineParent::default()
|
||||
};
|
||||
with_depth(f, MarkdownFormatContext::emphasis_depth, |f| {
|
||||
@@ -450,7 +433,6 @@ pub fn collect_inlines<'a>(
|
||||
let inner = InlineParent {
|
||||
delimiter: Some(style.as_bytes()[0]),
|
||||
strong_neighbor: Some(has_word_neighbor(children, i, f)),
|
||||
ends_line: ends_line(children, i, parent.ends_line),
|
||||
..InlineParent::default()
|
||||
};
|
||||
with_depth(f, MarkdownFormatContext::delimiter_depth, |f| {
|
||||
@@ -463,10 +445,7 @@ pub fn collect_inlines<'a>(
|
||||
let style = &"~~"[..usize::from(s.tildes)];
|
||||
let open = parts.len();
|
||||
parts.push_str(style);
|
||||
let inner = InlineParent {
|
||||
ends_line: ends_line(children, i, parent.ends_line),
|
||||
..InlineParent::default()
|
||||
};
|
||||
let inner = InlineParent::default();
|
||||
collect_inlines(&s.children, inner, parts, f);
|
||||
mark_delimiter(open, parts, false);
|
||||
parts.push_str(style);
|
||||
@@ -596,12 +575,10 @@ fn next_word_of<'a>(
|
||||
if matches!(next, Inline::SoftBreak(_) | Inline::HardBreak(_)) {
|
||||
return None;
|
||||
}
|
||||
// The word runs across nodes (`[^1]:` is a footnote reference and a text)
|
||||
let (mut word, alone_on_line) = first_word_at(children, next.span().start, f)?;
|
||||
// A code span's fence is recomputed: the page shows the printed one, not the source's
|
||||
if let Inline::CodeSpan(c) = next {
|
||||
word = print_code_span(c, f).split(is_split_whitespace).next().unwrap_or(word);
|
||||
}
|
||||
// The word runs across nodes (`[^1]:` is a footnote reference and a text);
|
||||
// a code span's fence is recomputed, so the line is taken as it prints
|
||||
let line: &'a str = arena_cow_str(&printed_line_at(children, i + 1, f).text, f);
|
||||
let word = first_word(line)?;
|
||||
// A text's leading run after a soft break is escaped inside emphasis
|
||||
// (as `push_text` decides: a space before it, and after it the text's next character or the node edge)
|
||||
let escaped = matches!(next, Inline::Text(_))
|
||||
@@ -612,29 +589,11 @@ fn next_word_of<'a>(
|
||||
Some(' '),
|
||||
edge_char(children, i + 1, delimiter, false, f),
|
||||
);
|
||||
Some(words::NextWord { word, alone_on_line, escaped })
|
||||
Some(words::NextWord { word, line, escaped })
|
||||
}
|
||||
|
||||
/// The first word of the source line starting at `start`, and whether it is the line's only one.
|
||||
fn first_word_at<'a>(
|
||||
children: &[Inline<'_>],
|
||||
start: u32,
|
||||
f: &MarkdownFormatter<'_, 'a>,
|
||||
) -> Option<(&'a str, bool)> {
|
||||
let (_, line) = source_line_at(children, start, f);
|
||||
let mut it = line.split(is_split_whitespace).filter(|w| !w.is_empty());
|
||||
let word = it.next()?;
|
||||
Some((word, it.next().is_none()))
|
||||
}
|
||||
|
||||
/// Nothing follows node `i` on its source line (a node glued after it would be part of the line);
|
||||
/// past the last sibling, whether the parent ends its line.
|
||||
fn ends_line(children: &[Inline<'_>], i: usize, parent_ends_line: bool) -> bool {
|
||||
match children.get(i + 1) {
|
||||
None => parent_ends_line,
|
||||
Some(Inline::SoftBreak(_) | Inline::HardBreak(_)) => true,
|
||||
Some(_) => false,
|
||||
}
|
||||
fn first_word(line: &str) -> Option<&str> {
|
||||
line.split(is_split_whitespace).find(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// The character right before (`before`) or after the text at `i`, as it will be printed:
|
||||
@@ -663,13 +622,13 @@ fn edge_char<'a>(
|
||||
}
|
||||
|
||||
/// The last word of text `i` (its printed `raw`) extended by the nodes glued after it
|
||||
/// on its source line (a backslash hard break's `\` included), and whether it is the line's last word.
|
||||
/// on its source line (a backslash hard break's `\` included).
|
||||
fn glued_last_word<'a>(
|
||||
children: &'a [Inline<'a>],
|
||||
i: usize,
|
||||
raw: &str,
|
||||
f: &MarkdownFormatter<'_, 'a>,
|
||||
) -> Option<(&'a str, bool)> {
|
||||
) -> Option<&'a str> {
|
||||
// Nothing glues after whitespace, a line end, or a two-space hard break
|
||||
match children.get(i + 1) {
|
||||
None | Some(Inline::SoftBreak(_)) => return None,
|
||||
@@ -679,7 +638,7 @@ fn glued_last_word<'a>(
|
||||
}
|
||||
let last = raw.rsplit(is_split_whitespace).next()?;
|
||||
let start = children[i].span().end - u32::try_from(last.len()).unwrap_or(0);
|
||||
first_word_at(children, start, f).filter(|(word, _)| word.len() != last.len())
|
||||
first_word(source_line_at(children, start, f).1).filter(|word| word.len() != last.len())
|
||||
}
|
||||
|
||||
/// The last word of the text before node `i`.
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn collect_link<'a>(
|
||||
// A full reference's text wraps like any text: its `][label]` tail is short.
|
||||
// An inline link's does not: its `](url)` tail is unbreakable and usually what overflows,
|
||||
// so a break inside the text would only split it without fitting the line.
|
||||
let inner = InlineParent { ends_line: false, ..InlineParent::default() };
|
||||
let inner = InlineParent::default();
|
||||
let text_start = parts.len();
|
||||
if matches!(link.kind, LinkKind::Inline { .. }) {
|
||||
with_depth(f, MarkdownFormatContext::no_wrap_depth, |f| {
|
||||
|
||||
@@ -33,8 +33,9 @@ use super::{
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct NextWord<'a> {
|
||||
pub word: &'a str,
|
||||
/// The word is its text's only one.
|
||||
pub alone_on_line: bool,
|
||||
/// The source line from the word on, as it prints: the line a kept break puts the word at the start of.
|
||||
/// Only `preserve` reads it (a text never spans a line, so only `next_word_of` fills it).
|
||||
pub line: &'a str,
|
||||
/// The word's leading `*` / `_` run gets escaped (inside emphasis):
|
||||
/// printed, it starts with `\` and cannot open a block.
|
||||
pub escaped: bool,
|
||||
@@ -61,13 +62,11 @@ pub struct TextContext<'a> {
|
||||
pub after_liquid: bool,
|
||||
/// The next sibling is a soft break (the text ends a source line).
|
||||
pub before_soft_break: bool,
|
||||
/// Nothing follows the text on its source line (a node glued after it would be part of the line).
|
||||
pub ends_line: bool,
|
||||
/// The first word the next sibling starts, for the whitespace that ends this text.
|
||||
pub next_word: Option<NextWord<'a>>,
|
||||
/// The text's last word with the nodes glued after it on the source line (`:-:` + `[[w]]`),
|
||||
/// the word a break before it would put at a line start, and whether it ends the line.
|
||||
pub glued_last_word: Option<(&'a str, bool)>,
|
||||
/// the word a break before it would put at a line start.
|
||||
pub glued_last_word: Option<&'a str>,
|
||||
/// The last word of the previous sibling text (a soft break's other side), for the CJK rules only;
|
||||
/// a trailing `\` there was escaped by the text (see `push_text`).
|
||||
pub prev_word: Option<&'a str>,
|
||||
@@ -113,11 +112,11 @@ pub fn push_text<'a>(
|
||||
let next = if is_last { cx.edge_next } else { Some(' ') };
|
||||
leading_run_escaped(word, prev, next)
|
||||
};
|
||||
let (word, alone) = match cx.glued_last_word {
|
||||
let word = match cx.glued_last_word {
|
||||
Some(glued) if is_last => glued,
|
||||
_ => (word, is_last && cx.ends_line),
|
||||
_ => word,
|
||||
};
|
||||
Some(NextWord { word, alone_on_line: is_first && alone, escaped })
|
||||
Some(NextWord { word, line: "", escaped })
|
||||
};
|
||||
let cx = TextContext {
|
||||
next_word: next,
|
||||
@@ -340,8 +339,7 @@ pub fn prevents_break(newline: bool, next: Option<NextWord<'_>>, prose_wrap: Pro
|
||||
// A lone `---` / `===` after a newline is going to be escaped as a fake setext underline instead
|
||||
!(prose_wrap == ProseWrap::Preserve
|
||||
&& newline
|
||||
&& is_fake_setext_underline(next.word)
|
||||
&& next.alone_on_line)
|
||||
&& is_fake_setext_underline(next.line.trim_end_matches(is_split_whitespace)))
|
||||
}
|
||||
|
||||
/// The parser's line-start classification (`lexical::line_start`), asked about the word alone:
|
||||
@@ -349,26 +347,23 @@ pub fn prevents_break(newline: bool, next: Option<NextWord<'_>>, prose_wrap: Pro
|
||||
/// table rows, footnote definitions.
|
||||
/// (Prettier tests `/^>|^(?:[*+-]|#{1,6}|\d+[).])$/` only, which is how a wrapped `<div>` or `***` opens a block.)
|
||||
fn looks_like_block_start(next: NextWord<'_>, exact: bool) -> bool {
|
||||
// Asked about the line the wrap would produce:
|
||||
// `exact` (a kept break under `preserve`): the line is known, ask about it.
|
||||
// Otherwise the line depends on the wrapping itself, so both candidates count:
|
||||
// the word alone (`***` is a break, `-|-` a delimiter row)
|
||||
// or the word with content after it (`- x` interrupts a paragraph where an empty `-` does not).
|
||||
// `exact`: the word's position is known; otherwise it depends on the wrapping itself, so both count.
|
||||
if !may_open_block(next.word.as_bytes()[0]) {
|
||||
// and the word with content after it (`- x` interrupts a paragraph where an empty `-` does not).
|
||||
let target = if exact { next.line } else { next.word };
|
||||
if !target.as_bytes().first().is_some_and(|&b| may_open_block(b)) {
|
||||
return false;
|
||||
}
|
||||
let constructs = Constructs::markdown();
|
||||
if (!exact || next.alone_on_line) && lexical::line_start(&constructs, next.word, true).is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if exact && next.alone_on_line {
|
||||
return false;
|
||||
}
|
||||
// A dialect line shape at a line start is printed raw from then on: never create one
|
||||
// (asked about the word alone, so a `:::` word counts even where `::: note` would be an opener)
|
||||
if is_line_shape_start(next.word) {
|
||||
// (asked about the word alone, a `:::` word counts even where `::: note` would be an opener)
|
||||
if lexical::line_start(&constructs, target, true).is_some() || is_line_shape_start(target) {
|
||||
return true;
|
||||
}
|
||||
if exact {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 64];
|
||||
let len = next.word.len();
|
||||
if len + 2 > buf.len() {
|
||||
|
||||
Vendored
+4
-2
@@ -80,7 +80,8 @@ Actions policies let you govern how {% data variables.product.prodname_actions %
|
||||
A tag at the end of the paragraph never ends a line on its own, so it stays glued {% data variables.product.prodname_actions %}
|
||||
|
||||
The {{jsxref("a")}} statement creates (including
|
||||
{{jsxref("Array")}}, {{jsxref("Map")}}, {{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
|
||||
{{jsxref("Array")}}, {{jsxref("Map")}},
|
||||
{{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
|
||||
|
||||
> [!NOTE]
|
||||
> {% data reusables.x %} For more information.
|
||||
@@ -93,7 +94,8 @@ Actions policies let you govern how {% data variables.product.prodname_actions %
|
||||
A tag at the end of the paragraph never ends a line on its own, so it stays glued {% data variables.product.prodname_actions %}
|
||||
|
||||
The {{jsxref("a")}} statement creates (including
|
||||
{{jsxref("Array")}}, {{jsxref("Map")}}, {{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
|
||||
{{jsxref("Array")}}, {{jsxref("Map")}},
|
||||
{{jsxref("Functions/arguments", "arguments")}} object and so on), invoking.
|
||||
|
||||
> [!NOTE]
|
||||
> {% data reusables.x %} For more information.
|
||||
|
||||
Reference in new issue
Block a user