feat(formatter_core): add hardlineWithoutBreakParent equivalent IR (#25273)

builder: `hard_line_break().without_expand_parent()`
This commit is contained in:
leaysgur committed 2026-08-04 04:31:56 +00:00
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}));
+10 -1
View File
@@ -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
+28
View File
@@ -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
+10 -1
View File
@@ -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
+102 -1
View File
@@ -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();