mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
feat(formatter_core): add hardlineWithoutBreakParent equivalent IR (#25273)
builder: `hard_line_break().without_expand_parent()`
This commit is contained in:
1 parent
51224a7fb1
commit
ab12665f34
9 files changed
+200
-17
No files matched your search
@@ -165,9 +165,16 @@ fn convert_line<'a>(
|
||||
// after a COLUMN-0 literal line is absorbed (Prettier prints both newlines).
|
||||
// This mechanical conversion cannot apply the `empty_line()` workaround;
|
||||
// see `hard_line_after_column_zero_literal_line_is_absorbed` in `oxc_formatter_core`.
|
||||
// Known gap: a bare `{line, hard, literal}` (Prettier's `literallineWithoutBreakParent`)
|
||||
// also lands here and over-propagates.
|
||||
// `Literal` expands enclosing groups and no non-propagating literal mode exists
|
||||
// (the paired `literalline` form is unaffected, its propagation rides the following `break-parent`).
|
||||
out.push(FormatElement::Line(LineMode::Literal));
|
||||
} else if hard {
|
||||
out.push(FormatElement::Line(LineMode::Hard));
|
||||
// `{line, hard}` alone is Prettier's `hardlineWithoutBreakParent`;
|
||||
// its `hardline` arrives as the `[{line, hard}, {break-parent}]` pair,
|
||||
// whose propagation the following `break-parent` → `ExpandParent` carries.
|
||||
out.push(FormatElement::Line(LineMode::HardWithoutExpand));
|
||||
} else if soft {
|
||||
out.push(FormatElement::Line(LineMode::Soft));
|
||||
} else {
|
||||
@@ -400,7 +407,7 @@ fn extract_group_id(
|
||||
/// the finishing step of the Doc→IR conversion (Prettier-fallback path only;
|
||||
/// Rust formatters write IR that never needs it):
|
||||
/// - strip trailing hardline (useless for embedded parts)
|
||||
/// - collapse double-hardlines `[Hard, ExpandParent, Hard, ExpandParent]` → `[Empty, ExpandParent]`
|
||||
/// - collapse double-hardlines `[HardWithoutExpand, ExpandParent, HardWithoutExpand, ExpandParent]` → `[Empty, ExpandParent]`
|
||||
/// - merge consecutive Text nodes (the Prettier Doc path can emit adjacent `Text`s)
|
||||
/// - trim a Text's trailing spaces/tabs when a hard/empty line follows:
|
||||
/// Prettier's own printer trims at every line break,
|
||||
@@ -413,7 +420,7 @@ pub fn postprocess<'a>(ir: &mut ArenaVec<'a, FormatElement<'a>>, allocator: &'a
|
||||
// Strip trailing hardline
|
||||
if ir.len() >= 2
|
||||
&& matches!(ir[ir.len() - 1], FormatElement::ExpandParent)
|
||||
&& matches!(ir[ir.len() - 2], FormatElement::Line(LineMode::Hard))
|
||||
&& matches!(ir[ir.len() - 2], FormatElement::Line(LineMode::HardWithoutExpand))
|
||||
{
|
||||
let new_len = ir.len() - 2;
|
||||
ir.truncate(new_len);
|
||||
@@ -424,9 +431,9 @@ pub fn postprocess<'a>(ir: &mut ArenaVec<'a, FormatElement<'a>>, allocator: &'a
|
||||
while read < ir.len() {
|
||||
// Collapse double-hardline → empty line
|
||||
if read + 3 < ir.len()
|
||||
&& matches!(ir[read], FormatElement::Line(LineMode::Hard))
|
||||
&& matches!(ir[read], FormatElement::Line(LineMode::HardWithoutExpand))
|
||||
&& matches!(ir[read + 1], FormatElement::ExpandParent)
|
||||
&& matches!(ir[read + 2], FormatElement::Line(LineMode::Hard))
|
||||
&& matches!(ir[read + 2], FormatElement::Line(LineMode::HardWithoutExpand))
|
||||
&& matches!(ir[read + 3], FormatElement::ExpandParent)
|
||||
{
|
||||
ir[write] = FormatElement::Line(LineMode::Empty);
|
||||
@@ -454,14 +461,18 @@ pub fn postprocess<'a>(ir: &mut ArenaVec<'a, FormatElement<'a>>, allocator: &'a
|
||||
sb.into_str()
|
||||
};
|
||||
// Prettier's own printer trims at every line break regardless of the doc structure around it,
|
||||
// so a break hiding behind tags (`Text("a "), StartIndent, Hard` from `["a ", indent([hardline, ..])]`) still trims,
|
||||
// so a break hiding behind tags (`Text("a "), StartIndent, <hard line>`
|
||||
// from `["a ", indent([hardline, ..])]`) still trims,
|
||||
// look through tag/expand-parent markers for it (only when there is anything to trim in the first place).
|
||||
let trimmed = if text.ends_with([' ', '\t'])
|
||||
&& ir[read..]
|
||||
.iter()
|
||||
.find(|el| !matches!(el, FormatElement::Tag(_) | FormatElement::ExpandParent))
|
||||
.is_some_and(|el| {
|
||||
matches!(el, FormatElement::Line(LineMode::Hard | LineMode::Empty))
|
||||
matches!(
|
||||
el,
|
||||
FormatElement::Line(LineMode::HardWithoutExpand | LineMode::Empty)
|
||||
)
|
||||
}) {
|
||||
text.trim_end_matches([' ', '\t'])
|
||||
} else {
|
||||
|
||||
@@ -184,7 +184,7 @@ fn convert_elements(
|
||||
push_line(current_children_mut(&mut stack)?, *mode);
|
||||
printer.line = LineState::Content;
|
||||
}
|
||||
LineMode::Hard | LineMode::Empty => {
|
||||
LineMode::Hard | LineMode::HardWithoutExpand | LineMode::Empty => {
|
||||
printer.flush_pending_space(&mut stack)?;
|
||||
// Mimic the printer's `line_width > 0` guard and `has_empty_line` cap:
|
||||
// the printer only emits a newline when the line has content,
|
||||
@@ -462,6 +462,9 @@ fn push_line(children: &mut Vec<Value>, mode: LineMode) {
|
||||
children.push(json!({"type": "line", "hard": true}));
|
||||
children.push(json!({"type": "break-parent"}));
|
||||
}
|
||||
LineMode::HardWithoutExpand => {
|
||||
children.push(json!({"type": "line", "hard": true}));
|
||||
}
|
||||
LineMode::Empty => {
|
||||
// hardline x2
|
||||
children.push(json!({"type": "line", "hard": true}));
|
||||
|
||||
@@ -19,7 +19,16 @@ Key IR pieces are all exported from the crate root.
|
||||
The semantics of each building block live in the `builders.rs` rustdocs.
|
||||
e.g. the mechanisms for verbatim multi-line content (`exact_line_breaks()` for blank runs exempt from newline collapsing, `literal_line_break()`, multiline `text()`, `text(..).without_expand_parent()`, and `mark_as_root` / `dedent_to_root`), with the non-obvious behaviors pinned by printer tests verified against Prettier's `printDocToString`.
|
||||
|
||||
Prettier doc primitives are ported on demand; still missing: `hardlineWithoutBreakParent` (for markdown tables) and the `trim`.
|
||||
Prettier doc primitives are ported on demand; still missing: the `trim`.
|
||||
|
||||
Composite primitives translate rather than port 1:1:
|
||||
|
||||
- `hardlineWithoutBreakParent` is `hard_line_break().without_expand_parent()`
|
||||
- `conditionalGroup` is `best_fitting!`: same expansion boundary, same flat-first variant trial.
|
||||
Prettier's own doesn't switch variants on inner breaks either ("the user is expected to manually handle what breaks");
|
||||
that manual wiring is `ifBreak({groupId})` there and `if_group_breaks(..).with_group_id(..)` here
|
||||
See `oxc_formatter_yaml`'s `mapping_item.rs` for the full pattern.
|
||||
Only the Doc→IR mechanical conversion has no mapping (oxfmt's `prettier_compat` rejects `expandedStates`).
|
||||
|
||||
### The printer never trims
|
||||
|
||||
|
||||
@@ -111,6 +111,24 @@ impl Line {
|
||||
const fn new(mode: LineMode) -> Self {
|
||||
Self { mode }
|
||||
}
|
||||
|
||||
/// Prints as a line break but does NOT force enclosing groups to expand
|
||||
/// (Prettier's `hardlineWithoutBreakParent`);
|
||||
/// pair with an explicit [expand_parent] where propagation IS wanted.
|
||||
///
|
||||
/// The primitive for a line break inside conditional content
|
||||
/// (see the [if_group_breaks] docs) and for layouts like markdown tables
|
||||
/// that break lines without expanding the surrounding structure.
|
||||
///
|
||||
/// Must only be called on [hard_line_break] (debug-asserted).
|
||||
#[must_use]
|
||||
pub const fn without_expand_parent(mut self) -> Self {
|
||||
match self.mode {
|
||||
LineMode::Hard => self.mode = LineMode::HardWithoutExpand,
|
||||
_ => debug_assert!(false, "without_expand_parent is only valid on hard_line_break()"),
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> Format<'_, C> for Line {
|
||||
@@ -793,6 +811,16 @@ impl<C> Format<'_, C> for ExpandParent {
|
||||
|
||||
/// Adds a conditional content that is emitted only if it isn't inside an enclosing `Group` that
|
||||
/// is printed on a single line.
|
||||
///
|
||||
/// NOTE: A conditional tag is NOT an expansion boundary.
|
||||
/// An expanding element inside the content (a hard line break above all) expands
|
||||
/// the enclosing groups at build time (see [crate::Document::propagate_expand]) even when the branch is never printed.
|
||||
///
|
||||
/// Prettier's `ifBreak` bodies don't have this problem only because they typically sit inside a `conditionalGroup`,
|
||||
/// whose boundary swallows the propagation;
|
||||
/// a 1:1 port of `ifBreak(...hardline...)` here silently breaks every group around it.
|
||||
/// For a line break inside the body use [`hard_line_break().without_expand_parent()`](Line::without_expand_parent),
|
||||
/// keeping a plain [hard_line_break] only when every enclosing group must break whenever this construct is emitted at all.
|
||||
#[inline]
|
||||
pub fn if_group_breaks<'ast, C, Content>(content: &Content) -> IfGroupBreaks<'_, 'ast, C>
|
||||
where
|
||||
|
||||
@@ -167,6 +167,9 @@ where
|
||||
LineMode::Hard => {
|
||||
w!(f, [token("hard_line_break")]);
|
||||
}
|
||||
LineMode::HardWithoutExpand => {
|
||||
w!(f, [token("hard_line_break_without_expand_parent")]);
|
||||
}
|
||||
LineMode::Empty => {
|
||||
w!(f, [token("empty_line")]);
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ impl Document<'_> {
|
||||
// `FormatElement::Token` cannot contain line breaks
|
||||
FormatElement::Text { text: _, width } => width.propagates_expand(),
|
||||
FormatElement::ExpandParent => true,
|
||||
FormatElement::Line(mode) => mode.will_break(),
|
||||
FormatElement::Line(mode) => mode.propagates_expand(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -193,10 +193,10 @@ impl FormatElements for [FormatElement<'_>] {
|
||||
FormatElement::Interned(interned) if ignore_depth == 0 && interned.will_break() => {
|
||||
return true;
|
||||
}
|
||||
// No `ignore_depth` guard on purpose:
|
||||
// like Prettier's `hardline` (which carries a `breakParent`),
|
||||
// a hard line written directly inside a line suffix still propagates the break
|
||||
// to the enclosing groups.
|
||||
// No `ignore_depth` guard on purpose: like Prettier's `willBreak`,
|
||||
// any always-breaking line counts — even directly inside a line suffix,
|
||||
// and independently of whether it propagates expansion
|
||||
// (`HardWithoutExpand` answers "will this print a newline" with yes too).
|
||||
FormatElement::Line(line) if line.will_break() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ pub enum LineMode {
|
||||
Soft,
|
||||
/// See [crate::builders::hard_line_break] for documentation.
|
||||
Hard,
|
||||
/// See [crate::builders::Line::without_expand_parent] for documentation.
|
||||
HardWithoutExpand,
|
||||
/// See [crate::builders::empty_line] for documentation.
|
||||
Empty,
|
||||
/// See [crate::builders::exact_line_breaks] for documentation.
|
||||
@@ -125,16 +127,30 @@ pub enum LineMode {
|
||||
}
|
||||
|
||||
impl LineMode {
|
||||
/// Exactly [Self::Hard], excludes [Self::HardWithoutExpand];
|
||||
/// ask [Self::will_break] for "does this always print a newline".
|
||||
pub const fn is_hard(self) -> bool {
|
||||
matches!(self, LineMode::Hard)
|
||||
}
|
||||
|
||||
/// The line always prints as a line break, regardless of print mode.
|
||||
pub const fn will_break(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
LineMode::Hard | LineMode::Empty | LineMode::ExactLineBreaks(_) | LineMode::Literal
|
||||
LineMode::Hard
|
||||
| LineMode::HardWithoutExpand
|
||||
| LineMode::Empty
|
||||
| LineMode::ExactLineBreaks(_)
|
||||
| LineMode::Literal
|
||||
)
|
||||
}
|
||||
|
||||
/// The line forces enclosing groups to expand at build time.
|
||||
/// See [crate::Document::propagate_expand]:
|
||||
/// every always-breaking mode except [Self::HardWithoutExpand].
|
||||
pub const fn propagates_expand(self) -> bool {
|
||||
self.will_break() && !matches!(self, LineMode::HardWithoutExpand)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
@@ -267,6 +283,9 @@ impl FormatElements for FormatElement<'_> {
|
||||
FormatElement::ExpandParent => true,
|
||||
FormatElement::Tag(Tag::StartGroup(group)) => !group.mode().is_flat(),
|
||||
FormatElement::Line(line_mode) => line_mode.will_break(),
|
||||
// NOTE: intentionally `propagates_expand`, not a will-break analogue:
|
||||
// a `without_expand_parent` text's embedded newlines don't count as breaking here,
|
||||
// while a `HardWithoutExpand` LINE does (the line above)
|
||||
FormatElement::Text { text: _, width } => width.propagates_expand(),
|
||||
FormatElement::Interned(interned) => interned.will_break(),
|
||||
// Traverse into the most flat version because the content is guaranteed to expand when even
|
||||
|
||||
@@ -295,8 +295,17 @@ macro_rules! dbg_write {
|
||||
/// the content up to the first non-soft line break without exceeding the configured print width.
|
||||
/// This definition differs from groups as that non-soft line breaks make group expand.
|
||||
///
|
||||
/// [crate::BestFitting] acts as a "break" boundary, meaning that it is considered to fit
|
||||
/// A consequence of that first-line measurement:
|
||||
/// variant selection CANNOT depend on whether a variant's own content breaks,
|
||||
/// a variant holding a forced break (a hard line, an expanded group) is still selected when its first line fits.
|
||||
/// To flip layouts on "does this content break",
|
||||
/// put the content in a [group with an id](crate::builders::IfGroupBreaks::with_group_id)
|
||||
/// and switch with [crate::builders::if_group_breaks] instead.
|
||||
///
|
||||
/// [crate::BestFitting] acts as an expansion boundary:
|
||||
/// an expanding element inside a variant never expands the groups enclosing the [crate::BestFitting]
|
||||
/// (see [crate::Document::propagate_expand]), it is the ONLY boundary;
|
||||
/// conditional content tags are transparent to propagation.
|
||||
///
|
||||
/// [`Flat`]: crate::format_element::PrintMode::Flat
|
||||
/// [`Expanded`]: crate::format_element::PrintMode::Expanded
|
||||
|
||||
@@ -158,6 +158,7 @@ impl<'a> Printer<'a> {
|
||||
return Ok(());
|
||||
}
|
||||
LineMode::Hard
|
||||
| LineMode::HardWithoutExpand
|
||||
| LineMode::Empty
|
||||
| LineMode::ExactLineBreaks(_)
|
||||
| LineMode::Literal => {
|
||||
@@ -1115,6 +1116,7 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
|
||||
}
|
||||
LineMode::Soft => {}
|
||||
LineMode::Hard
|
||||
| LineMode::HardWithoutExpand
|
||||
| LineMode::Empty
|
||||
| LineMode::ExactLineBreaks(_)
|
||||
| LineMode::Literal => {
|
||||
@@ -1476,7 +1478,7 @@ mod tests {
|
||||
|
||||
use crate::{
|
||||
Argument, Arguments, Buffer, Document, Format, FormatState, IndentStyle, LineEnding,
|
||||
Printed, Printer, PrinterOptions, SimpleFormatContext, VecBuffer,
|
||||
Printed, Printer, PrinterOptions, SimpleFormatContext, VecBuffer, best_fitting,
|
||||
builders::{
|
||||
align, block_indent, dedent_to_root, empty_line, exact_line_breaks, group,
|
||||
hard_line_break, if_group_breaks, if_group_fits_on_line, indent, line_suffix,
|
||||
@@ -2163,6 +2165,105 @@ two lines`,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_content_is_transparent_to_expand_propagation() {
|
||||
// A hard line inside a conditional body expands the enclosing group at build time
|
||||
// even when the body never prints (see the `if_group_breaks` docs): conditional tags are not expansion boundaries.
|
||||
let allocator = Allocator::default();
|
||||
let content = test_format_with(|f| {
|
||||
let group_id = f.state().group_id("watched");
|
||||
write!(f, [group(&token("short")).with_group_id(Some(group_id)), space()]);
|
||||
write!(
|
||||
f,
|
||||
[group(&format_args!(
|
||||
token("a"),
|
||||
soft_line_break_or_space(),
|
||||
if_group_breaks(&hard_line_break()).with_group_id(Some(group_id)),
|
||||
token("b"),
|
||||
))]
|
||||
);
|
||||
});
|
||||
|
||||
let printed = format_simple(&allocator, &content);
|
||||
|
||||
// The watched group is flat, so the conditional body is skipped — yet its hard
|
||||
// line already expanded the enclosing group: `a b` would fit, but prints broken.
|
||||
assert_eq!(printed.as_code(), "short a\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_line_without_expand_parent_breaks_but_does_not_propagate() {
|
||||
// Prettier's `hardlineWithoutBreakParent`: always prints a line break,
|
||||
// yet the enclosing group still measures flat and its soft lines stay flat.
|
||||
let allocator = Allocator::default();
|
||||
let content = test_format_with(|f| {
|
||||
write!(
|
||||
f,
|
||||
[group(&format_args!(
|
||||
token("a"),
|
||||
soft_line_break_or_space(),
|
||||
token("b"),
|
||||
hard_line_break().without_expand_parent(),
|
||||
token("c"),
|
||||
))]
|
||||
);
|
||||
});
|
||||
|
||||
let printed = format_simple(&allocator, &content);
|
||||
|
||||
assert_eq!(printed.as_code(), "a b\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_fitting_is_an_expansion_boundary() {
|
||||
// A hard line inside a `best_fitting` variant does not expand enclosing groups
|
||||
// (see `Document::propagate_expand`): the group around it still measures flat
|
||||
// and the most-flat variant is chosen.
|
||||
let allocator = Allocator::default();
|
||||
let content = test_format_with(|f| {
|
||||
write!(
|
||||
f,
|
||||
[group(&format_args!(
|
||||
token("a"),
|
||||
soft_line_break_or_space(),
|
||||
best_fitting![
|
||||
format_args!(token("first")),
|
||||
format_args!(token("expanded"), hard_line_break(), token("tail")),
|
||||
],
|
||||
))]
|
||||
);
|
||||
});
|
||||
|
||||
let printed = format_simple(&allocator, &content);
|
||||
|
||||
assert_eq!(printed.as_code(), "a first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_fitting_selects_a_variant_whose_content_force_breaks() {
|
||||
// Variant measurement early-exits `Yes` at the first line break: a variant
|
||||
// holding a forced break is still selected when its first line fits, so
|
||||
// "does this content break" cannot drive variant selection
|
||||
// (watch a group id with `if_group_breaks` for that).
|
||||
let allocator = Allocator::default();
|
||||
let content = test_format_with(|f| {
|
||||
write!(
|
||||
f,
|
||||
[best_fitting![
|
||||
format_args!(
|
||||
token("first line"),
|
||||
group(&format_args!(token(" broken"), hard_line_break(), token("tail")))
|
||||
),
|
||||
format_args!(token("never chosen")),
|
||||
]]
|
||||
);
|
||||
});
|
||||
|
||||
let printed = format_simple(&allocator, &content);
|
||||
|
||||
assert_eq!(printed.as_code(), "first line broken\ntail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_group_ids() {
|
||||
let allocator = Allocator::default();
|
||||
|
||||
Reference in new issue
Block a user