diff --git a/crates/oxc_formatter_core/src/builders.rs b/crates/oxc_formatter_core/src/builders.rs index 7d85e65777..1e2567ff17 100644 --- a/crates/oxc_formatter_core/src/builders.rs +++ b/crates/oxc_formatter_core/src/builders.rs @@ -199,7 +199,7 @@ impl std::fmt::Debug for Token { /// Creates a text from a dynamic string and a range of the input source pub fn text(text: &str) -> Text<'_> { debug_assert_no_cr_line_break(text); - Text { text, width: None } + Text { text, width: None, expand_parent: true } } /// Creates a text from a dynamic string that contains no whitespace characters @@ -208,13 +208,27 @@ pub fn text_without_whitespace(text: &str) -> Text<'_> { text.as_bytes().iter().all(|&b| !b.is_ascii_whitespace()), "The content '{text}' contains whitespace characters but text must not contain any whitespace characters." ); - Text { text, width: Some(TextWidth::from_non_whitespace_str(text)) } + Text { text, width: Some(TextWidth::from_non_whitespace_str(text)), expand_parent: true } } #[derive(Eq, PartialEq)] pub struct Text<'a> { + #[expect(clippy::struct_field_names)] // Keep the name the same as it is in the original source text: &'a str, width: Option, + expand_parent: bool, +} + +impl Text<'_> { + /// Prints embedded newlines literally but does NOT force enclosing groups to expand + /// (Prettier's `replaceEndOfLine(..., literallineWithoutBreakParent)`). + /// + /// Fits measurement still only counts the first line, and each newline resets the line width. + #[must_use] + pub fn without_expand_parent(mut self) -> Self { + self.expand_parent = false; + self + } } impl<'a, C> Format<'a, C> for Text<'a> @@ -222,12 +236,11 @@ where C: FormatContext, { fn fmt(&self, f: &mut Formatter<'_, 'a, C>) { - f.write_element(FormatElement::Text { - text: self.text, - width: self - .width - .unwrap_or_else(|| TextWidth::from_text(self.text, f.options().indent_width())), - }); + let width = self + .width + .unwrap_or_else(|| TextWidth::from_text(self.text, f.options().indent_width())); + let width = if self.expand_parent { width } else { width.without_expand_parent() }; + f.write_element(FormatElement::Text { text: self.text, width }); } } diff --git a/crates/oxc_formatter_core/src/format_element/document.rs b/crates/oxc_formatter_core/src/format_element/document.rs index 700af7814e..a2a6c702de 100644 --- a/crates/oxc_formatter_core/src/format_element/document.rs +++ b/crates/oxc_formatter_core/src/format_element/document.rs @@ -54,7 +54,8 @@ impl<'a> Document<'a> { impl Document<'_> { /// Sets [`expand`](tag::Group::expand) to [`GroupMode::Propagated`] if the group contains any of: /// * a group with [`expand`](tag::Group::expand) set to [GroupMode::Propagated] or [GroupMode::Expand]. - /// * a non-soft [line break](FormatElement::Line) with mode [LineMode::Hard], [LineMode::Empty], or [LineMode::Literal]. + /// * a non-soft [line break](FormatElement::Line) with mode [LineMode::Hard] or [LineMode::Empty]. + /// * a multiline [FormatElement::Text] whose `TextWidth` is not marked `without_expand_parent`. /// * a [FormatElement::ExpandParent] /// /// [`BestFitting`] elements act as expand boundaries, meaning that the fact that a @@ -146,7 +147,7 @@ impl Document<'_> { false } // `FormatElement::Token` cannot contain line breaks - FormatElement::Text { text: _, width } => width.is_multiline(), + FormatElement::Text { text: _, width } => width.propagates_expand(), FormatElement::ExpandParent | FormatElement::Line(LineMode::Hard | LineMode::Empty) => true, _ => false, diff --git a/crates/oxc_formatter_core/src/format_element/mod.rs b/crates/oxc_formatter_core/src/format_element/mod.rs index f8923732e3..d8e425dcd2 100644 --- a/crates/oxc_formatter_core/src/format_element/mod.rs +++ b/crates/oxc_formatter_core/src/format_element/mod.rs @@ -276,7 +276,7 @@ impl FormatElements for FormatElement<'_> { FormatElement::ExpandParent => true, FormatElement::Tag(Tag::StartGroup(group)) => !group.mode().is_flat(), FormatElement::Line(line_mode) => line_mode.will_break(), - FormatElement::Text { text: _, width } => width.is_multiline(), + 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 // the most flat version contains some content that forces a break. @@ -423,13 +423,14 @@ pub trait FormatElements { /// /// ## Representation /// -/// Uses a single `u32` to efficiently store both the width value and a multiline flag: +/// Uses a single `u32` to efficiently store the width value and two flags: /// /// - Bit 31: Multiline flag (1 = multiline, 0 = single line) -/// - Bits 0-30: Width value (stored directly) +/// - Bit 30: No-expand flag (1 = multiline text that does not expand enclosing groups) +/// - Bits 0-29: Width value (stored directly) /// /// This encoding allows `TextWidth` to fit in 4 bytes while supporting both a width value -/// and a boolean flag. The maximum representable width is 2^31 - 1 (0x7FFFFFFF). +/// and boolean flags. The maximum representable width is 2^30 - 1 (0x3FFFFFFF). /// /// The maximum value is sufficient in practice as texts that long would exceed any /// reasonable line width configuration (typically < 500 columns). @@ -446,8 +447,13 @@ impl TextWidth { /// Bit mask for the multiline flag (highest bit) const MULTILINE_MASK: u32 = 1 << 31; - /// Bit mask for extracting the width value (all bits except the highest) - const WIDTH_MASK: u32 = Self::MULTILINE_MASK - 1; + /// Bit mask for the "multiline but does not expand enclosing groups" flag. + /// Equivalent to Prettier's `literallineWithoutBreakParent`: the newlines still + /// print literally, but `Document::propagate_expand` / `will_break` ignore them. + const NO_EXPAND_MASK: u32 = 1 << 30; + + /// Bit mask for extracting the width value (all bits except the flags) + const WIDTH_MASK: u32 = Self::NO_EXPAND_MASK - 1; /// Encodes width and multiline flag into a single u32. const fn encode(width: u32, multiline: bool) -> u32 { @@ -525,6 +531,19 @@ impl TextWidth { pub const fn is_multiline(self) -> bool { (self.0 & Self::MULTILINE_MASK) != 0 } + + /// Marks a multiline width so its newlines do NOT expand enclosing groups + /// (Prettier's `literallineWithoutBreakParent`). No-op for single-line widths. + #[must_use] + pub const fn without_expand_parent(self) -> Self { + if self.is_multiline() { Self(self.0 | Self::NO_EXPAND_MASK) } else { self } + } + + /// Returns true if the text forces enclosing groups to expand: + /// it contains newlines and is not marked [`Self::without_expand_parent`]. + pub const fn propagates_expand(self) -> bool { + (self.0 & (Self::MULTILINE_MASK | Self::NO_EXPAND_MASK)) == Self::MULTILINE_MASK + } } #[cfg(test)] @@ -580,6 +599,24 @@ mod tests { debug_assert!(multi.is_multiline()); } + #[test] + fn without_expand_parent_disables_propagation_keeping_width() { + let multi = TextWidth::multiline(3); + debug_assert!(multi.propagates_expand()); + + let no_expand = multi.without_expand_parent(); + debug_assert!(no_expand.is_multiline()); + debug_assert!(!no_expand.propagates_expand()); + debug_assert_eq!(no_expand.value(), 3); + } + + #[test] + fn without_expand_parent_is_noop_for_single_line() { + let single = TextWidth::single(3); + debug_assert!(!single.propagates_expand()); + debug_assert_eq!(single.without_expand_parent(), single); + } + #[test] fn from_text_handles_unicode() { // Emoji width