fix(formatter_markdown): keep container columns as spaces under useTabs (#27277)

Added new IR builder `space_align()`.

It is a similar one to `prefix_align()`, but `space` prefix never converted to TAB with `useTabs: true`.

This is useful for cases like:

````md
# JS-in-MD

In markdown, structures are indented by SPACE.

- Indented
  ```js
  // But here, may be TAB indented
  ```
````
This commit is contained in:
leaysgur committed 2026-10-02 06:14:36 +00:00
1 parent 16860185da
commit dd72fbf1e8
14 files changed
+340 -81

No files matched your search

@@ -349,43 +349,24 @@ fn convert_align<'a>(
out.push(FormatElement::Tag(Tag::EndDedent(DedentMode::Root)));
Ok(())
}
// A visible string align is a prefix on every line
// (markdown's blockquote `"> "` is the only one Prettier's own printers emit):
// intern here if a plugin brings another.
Value::String(s) if s == "> " => {
out.push(FormatElement::Tag(Tag::StartPrefix(Prefix::new(&"> "))));
// A string align is a prefix on every line, never turned into a tab under `useTabs`:
// markdown's blockquote `"> "` (the only visible one Prettier's own printers emit, intern here if a plugin brings another)
// and its container columns `" ".repeat(n)` (`""` aligns nothing)
Value::String(s) if s == "> " || s.bytes().all(|b| b == b' ') => {
let prefixes: Vec<Prefix> = if s == "> " {
vec![Prefix::new(&"> ")]
} else {
Prefix::spaces(s.len()).collect()
};
for &prefix in &prefixes {
out.push(FormatElement::Tag(Tag::StartPrefix(prefix)));
}
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
}
out.push(FormatElement::Tag(Tag::EndPrefix));
out.extend(prefixes.iter().map(|_| FormatElement::Tag(Tag::EndPrefix)));
Ok(())
}
Value::String(s) if s.trim().is_empty() => {
// Whitespace alignment (e.g., " " for markdown list continuation indent).
// Prettier uses the string length as the number of spaces to align by.
if s.is_empty() {
// Empty string → no alignment, just render contents
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
}
return Ok(());
}
debug_assert!(
s.len() <= 255,
"align string length {} exceeds NonZeroU8 range",
s.len()
);
#[expect(clippy::cast_possible_truncation)]
if let Some(nz) = NonZeroU8::new(s.len() as u8) {
out.push(FormatElement::Tag(Tag::StartAlign(Align::new(nz))));
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
}
out.push(FormatElement::Tag(Tag::EndAlign));
return Ok(());
}
Err(format!("Unsupported align value: {n}"))
}
Value::Object(obj_val) => {
// `align({type: "root"}, ...)` = Prettier's `markAsRoot()`:
// records the current indent position so that literal lines and
@@ -598,21 +579,46 @@ pub fn postprocess<'a>(ir: &mut ArenaVec<'a, FormatElement<'a>>, allocator: &'a
#[cfg(test)]
mod tests {
use oxc_allocator::Allocator;
use oxc_formatter_core::{Document, PrintWidth, PrinterOptions, UniqueGroupIdBuilder};
use oxc_formatter_core::{
Document, IndentStyle, PrintWidth, PrinterOptions, UniqueGroupIdBuilder,
};
use serde_json::{Value, json};
use super::{convert_envelope, postprocess};
fn print_doc(doc: &Value, print_width: u32) -> String {
print_doc_with(
doc,
PrinterOptions::default().with_print_width(PrintWidth::new(print_width)),
)
}
fn print_doc_with(doc: &Value, options: PrinterOptions) -> String {
let allocator = Allocator::default();
let group_id_builder = UniqueGroupIdBuilder::default();
let (mut ir, _) =
convert_envelope(json!([doc, {}]), &allocator, &group_id_builder).unwrap();
postprocess(&mut ir, &allocator);
Document::new(ir, vec![])
.print(0, PrinterOptions::default().with_print_width(PrintWidth::new(print_width)))
.unwrap()
.into_code()
Document::new(ir, vec![]).print(0, options).unwrap().into_code()
}
#[test]
fn space_string_align_stays_spaces_under_tabs() {
let doc = json!([
"a",
{
"type": "align",
"n": " ",
"contents": [
{ "type": "line", "hard": true },
"b",
{ "type": "indent", "contents": [{ "type": "line", "hard": true }, "c"] }
]
}
]);
let options = PrinterOptions::default().with_indent_style(IndentStyle::Tab);
// Prettier's markdown list item: the item's columns, then the child's tab
assert_eq!(print_doc_with(&doc, options), "a\n b\n \tc");
}
#[test]
+1 -1
View File
@@ -29,7 +29,7 @@ Every Prettier doc primitive its own language printers emit has a counterpart he
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.
Oxfmt's Doc→IR mechanical conversion maps `expandedStates` to the same `BestFitting` primitive.
- `align` is `prefix_align()`
- A string `align` is `prefix_align()` (a visible token) or `space_align()` (spaces, which stay spaces under `useTabs` where a number `align` becomes a tab)
### The printer never trims
@@ -323,11 +323,16 @@ where
}
StartPrefix(prefix) => {
let (name, arg) = if prefix.is_spaces() {
("space_align(", prefix.text().len().to_string())
} else {
("prefix_align(", format!("{:?}", prefix.text()))
};
w!(
f,
[
token("prefix_align("),
text(f.allocator().alloc_str(&format!("{:?}", prefix.0))),
token(name),
text(f.allocator().alloc_str(&arg)),
token(","),
space(),
]
@@ -22,7 +22,7 @@ pub enum Tag {
EndAlign,
/// Prints a string after the indention on every line break inside the content
/// (Prettier's `align("> ", doc)`), see [crate::builders::prefix_align].
/// (Prettier's string `align("> ", doc)`), see [crate::builders::prefix_align] / [crate::builders::space_align].
StartPrefix(Prefix),
EndPrefix,
@@ -260,16 +260,48 @@ impl Align {
/// The string a [Tag::StartPrefix] prints after the indention on every new line.
///
/// Prefixes are syntax tokens (`"> "`, `" * "`), so `'static`;
/// Prefixes are syntax tokens (`"> "`, `" * "`) or syntax columns ([Self::spaces]), so `'static`;
/// the double reference keeps the payload one pointer wide (see the size assertion in `format_element/mod.rs`).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Prefix(pub(crate) &'static &'static str);
/// The widest single space prefix, wider runs chain several (see [Prefix::spaces]).
const MAX_SPACES: usize = 32;
/// `SPACE_RUNS[n]` is `n` spaces, the `'static` storage [Prefix::spaces] points into.
static SPACE_RUNS: [&str; MAX_SPACES + 1] = {
const SPACES: &str = " ";
let mut runs = [""; MAX_SPACES + 1];
let mut n = 0;
while n <= MAX_SPACES {
runs[n] = SPACES.split_at(n).0;
n += 1;
}
runs
};
impl Prefix {
/// A visible token prefix (`"> "`); spaces alone are [Self::spaces].
pub fn new(text: &'static &'static str) -> Self {
debug_assert!(
!text.trim().is_empty() && !text.contains(['\n', '\t']),
"a prefix is a visible token on its line (spaces alone are `Prefix::spaces`)"
);
Self(text)
}
/// `n` spaces that stay spaces under `useTabs`, unlike an `align` (Prettier's string align `" ".repeat(n)`),
/// as the prefixes to nest, outermost first (none for `0`).
pub fn spaces(n: usize) -> impl ExactSizeIterator<Item = Self> {
(0..n.div_ceil(MAX_SPACES))
.map(move |i| Self(&SPACE_RUNS[(n - i * MAX_SPACES).min(MAX_SPACES)]))
}
/// Whether this is a [Self::spaces] prefix, which a blank line trims entirely.
pub fn is_spaces(self) -> bool {
self.0.bytes().all(|b| b == b' ')
}
pub fn text(self) -> &'static str {
self.0
}
@@ -1,6 +1,9 @@
use std::{fmt::Debug, num::NonZeroU8};
use crate::{IndentStyle, InvalidDocumentError, PrintError, PrintMode, PrintResult, TagKind};
use crate::{
IndentStyle, InvalidDocumentError, PrintError, PrintMode, PrintResult, TagKind,
format_element::tag::Prefix,
};
use super::{
Indention, PrefixNode,
@@ -248,12 +251,7 @@ pub(super) trait IndentStack {
self.current_stack_mut().push(next_indent);
}
/// `Tag::StartPrefix`; `nodes` is the printer's prefix arena.
fn prefix(
&mut self,
prefix: &'static str,
nodes: &mut Vec<PrefixNode>,
indent_style: IndentStyle,
) {
fn prefix(&mut self, prefix: Prefix, nodes: &mut Vec<PrefixNode>, indent_style: IndentStyle) {
let next_indent = self.indention().set_prefix(prefix, nodes, indent_style);
self.current_stack_mut().push(next_indent);
}
+71 -9
View File
@@ -14,6 +14,7 @@ use unicode_width::UnicodeWidthChar;
use crate::{
ActualStart, BestFittingElement, Condition, DedentMode, FormatElement, GroupId, IndentStyle,
IndentWidth, InvalidDocumentError, LineMode, PrintError, PrintMode, Tag, TagKind, TextWidth,
format_element::tag::Prefix,
};
use self::call_stack::{
@@ -300,7 +301,7 @@ impl<'a> Printer<'a> {
FormatElement::Tag(StartPrefix(prefix)) => {
indent_stack.prefix(
prefix.0,
*prefix,
&mut self.state.prefix_nodes,
self.options.indent_style(),
);
@@ -762,16 +763,24 @@ impl<'a> Printer<'a> {
}
/// Writes `indention`'s prefix chain up to and including its last prefix
/// (`trim_last`: that one without its trailing whitespace).
/// (`trim_last`: up to its last visible one, without its trailing whitespace;
/// the space prefixes after it are trailing whitespace too).
/// Width is the caller's business.
fn emit_prefix_chain(&mut self, indention: Indention, trim_last: bool) {
let node =
PrefixNode::get(&self.state.prefix_nodes, indention.prefix.expect("has a prefix"));
if trim_last && node.prefix.is_spaces() {
if node.before.prefix.is_some() {
self.emit_prefix_chain(node.before, true);
}
return;
}
if node.before.prefix.is_some() {
self.emit_prefix_chain(node.before, false);
}
self.emit_level_and_align(node.before);
self.state.buffer.print_str(if trim_last { node.prefix.trim_end() } else { node.prefix });
let prefix = node.prefix.text();
self.state.buffer.print_str(if trim_last { prefix.trim_end() } else { prefix });
}
#[inline]
@@ -787,7 +796,7 @@ impl<'a> Printer<'a> {
/// Ends the current line; `next` becomes the pending indention of the new one.
///
/// Ending an EMPTY line inside `prefix_align` still prints its prefixes, the last one trimmed:
/// Ending an EMPTY line inside `prefix_align` still prints its prefixes, up to the last visible one trimmed:
/// Prettier writes the whole indention at every newline and trims trailing whitespace at the next,
/// so a blank line in a blockquote is `>`.
/// The levels / aligns after the last prefix are that whitespace.
@@ -992,7 +1001,7 @@ type PrefixId = std::num::NonZeroU32;
struct PrefixNode {
/// What prints before the prefix: the indention the prefix was added to (its own chain included).
before: Indention,
prefix: &'static str,
prefix: Prefix,
}
impl PrefixNode {
@@ -1042,7 +1051,7 @@ impl Indention {
/// and the new indention starts empty after it.
fn set_prefix(
self,
prefix: &'static str,
prefix: Prefix,
nodes: &mut Vec<PrefixNode>,
indent_style: IndentStyle,
) -> Self {
@@ -1070,7 +1079,7 @@ impl Indention {
let own =
node.before.level as usize * indent_width.value() as usize + node.before.align as usize;
let before = node.before.prefix.map_or(0, |id| Self::chain_width(nodes, id, indent_width));
before + own + TextWidth::from_text(node.prefix, indent_width).value() as usize
before + own + TextWidth::from_text(node.prefix.text(), indent_width).value() as usize
}
}
@@ -1371,7 +1380,7 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
FormatElement::Tag(StartPrefix(prefix)) => {
self.indent_stack.prefix(
prefix.0,
*prefix,
&mut self.printer.state.prefix_nodes,
self.printer.options.indent_style(),
);
@@ -1656,7 +1665,7 @@ mod tests {
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,
literal_line_break, mark_as_root, prefix_align, soft_block_indent, soft_line_break,
soft_line_break_or_space, space, text, token,
soft_line_break_or_space, space, space_align, text, token,
},
format_args,
printer::PrintWidth,
@@ -1775,6 +1784,59 @@ mod tests {
assert_eq!("a\n > b\na\n> b\na\n> > b", formatted.as_code());
}
#[test]
fn space_align_stays_spaces_under_tabs() {
let allocator = Allocator::default();
let inner = test_format_with(|f| {
write!(f, [token("a"), indent(&format_args!(hard_line_break(), token("b")))]);
});
let quoted = test_format_with(|f| {
write!(
f,
[
token("> "),
prefix_align(&"> ", &format_args!(token("q"), empty_line(), token("r")))
]
);
});
let options =
PrinterOptions { indent_style: IndentStyle::Tab, ..PrinterOptions::default() };
// `align` before an `indent` becomes a tab, `space_align` keeps its columns
let result = format_simple_with_options(
&allocator,
&format_args!(
align(2, &inner),
hard_line_break(),
space_align(2, &inner),
hard_line_break(),
space_align(2, &space_align(2, &inner))
),
options.clone(),
);
assert_eq!("a\n\t\tb\na\n \tb\na\n \tb", result.as_code());
// Before a prefix too; a blank line trims the spaces with the prefix
let result = format_simple_with_options(
&allocator,
&format_args!(
token("x"),
space_align(2, &format_args!(hard_line_break(), quoted)),
hard_line_break(),
prefix_align(
&"> ",
&space_align(2, &format_args!(token("y"), empty_line(), token("z")))
)
),
options.clone(),
);
assert_eq!("x\n > q\n >\n > r\ny\n>\n> z", result.as_code());
// Wider than one space run: the runs chain, no column is lost
let result = format_simple_with_options(&allocator, &space_align(40, &inner), options);
assert_eq!(format!("a\n{}\tb", " ".repeat(40)), result.as_code());
}
#[test]
fn prefix_align_nests_and_survives_root_and_suffix_mechanics() {
let allocator = Allocator::default();
@@ -653,7 +653,7 @@ impl<C> std::fmt::Debug for Align<'_, '_, C> {
/// Inside the prefix, `indent` / `align` add to the right of it,
/// so `prefix_align(&"> ", indent(..))` prints `> x`
/// while `align(2, prefix_align(&"> ", ..))` prints ` > x`.
/// A blank line inside prints the prefixes up to the last one with its trailing whitespace trimmed (`>`),
/// A blank line inside prints the prefixes up to the last visible one with its trailing whitespace trimmed (`>`),
/// as Prettier's end-of-line trimming leaves them.
#[inline]
pub fn prefix_align<'a, 'ast, C, Content>(
@@ -663,24 +663,56 @@ pub fn prefix_align<'a, 'ast, C, Content>(
where
Content: Format<'ast, C>,
{
debug_assert!(
!prefix.trim().is_empty() && !prefix.contains(['\n', '\t']),
"a prefix is a visible token on its line (spaces alone are `align`)"
);
PrefixAlign { prefix, content: Argument::new(content) }
PrefixAlign {
prefix: PrefixKind::Token(tag::Prefix::new(prefix)),
content: Argument::new(content),
}
}
/// Aligns `content` by `count` spaces that stay spaces under `useTabs`.
///
/// Unlike [align], whose spaces become a tab when an `indent` or a prefix follows them in tab mode
/// (Prettier's number align), these columns are syntax:
/// a markdown container's content column, whose width the next parse counts (a tab is up to 4 columns).
/// An `indent` inside adds to the right of them (`␣␣\t`), like [prefix_align].
/// `0` aligns nothing.
#[inline]
pub fn space_align<'a, 'ast, C, Content>(
count: usize,
content: &'a Content,
) -> PrefixAlign<'a, 'ast, C>
where
Content: Format<'ast, C>,
{
PrefixAlign { prefix: PrefixKind::Spaces(count), content: Argument::new(content) }
}
#[derive(Copy, Clone, Debug)]
enum PrefixKind {
Token(tag::Prefix),
Spaces(usize),
}
#[derive(Copy, Clone)]
pub struct PrefixAlign<'a, 'ast, C> {
prefix: &'static &'static str,
prefix: PrefixKind,
content: Argument<'a, 'ast, C>,
}
impl<'ast, C> Format<'ast, C> for PrefixAlign<'_, 'ast, C> {
fn fmt(&self, f: &mut Formatter<'_, 'ast, C>) {
f.write_element(FormatElement::Tag(StartPrefix(tag::Prefix(self.prefix))));
let prefixes: &mut dyn ExactSizeIterator<Item = tag::Prefix> = match self.prefix {
PrefixKind::Token(prefix) => &mut std::iter::once(prefix),
PrefixKind::Spaces(count) => &mut tag::Prefix::spaces(count),
};
let depth = prefixes.len();
for prefix in prefixes {
f.write_element(FormatElement::Tag(StartPrefix(prefix)));
}
Arguments::from(&self.content).fmt(f);
f.write_element(FormatElement::Tag(EndPrefix));
for _ in 0..depth {
f.write_element(FormatElement::Tag(EndPrefix));
}
}
}
+5
View File
@@ -14,6 +14,11 @@ Style facts (markers, fence kind, setext, break kind, list padding, ...) are AST
so the printer never re-derives layout from the source text.
Formatter policy (sentence splitting, CJK, aligned lists, escaping) stays here.
Container columns (list item, footnote, indented code) are `space_align`, never `align`:
the next parse strips exactly those columns and a tab spans up to 4,
while an `align` becomes a tab under `useTabs` once an `indent` or a blockquote prefix follows it.
`tests/fixtures/markdown/use-tabs/` pins it.
### Escaping
Text is printed as written. Escapes are added in three places only:
@@ -3,8 +3,8 @@
use oxc_formatter_core::{
Buffer,
builders::{
align, exact_line_breaks, group, hard_line_break, literal_line_break, mark_as_root,
soft_line_break_or_space, space, text, token,
exact_line_breaks, group, hard_line_break, literal_line_break, mark_as_root,
soft_line_break_or_space, space, space_align, text, token,
},
format_args,
spec::{is_suppression_marker, parse_front_matter},
@@ -351,7 +351,7 @@ fn write_footnote_definition<'a>(
let parent = Parent::Container;
write!(
f,
align(
space_align(
4,
&mark_as_root(&format_with(|f| {
let first = format_with(|f| write_block(&children[0], children, 0, parent, f));
@@ -5,7 +5,7 @@ use cow_utils::CowUtils;
use oxc_formatter_core::{
Buffer, arena_cow_str,
builders::{align, exact_line_breaks, hard_line_break, text, token},
builders::{exact_line_breaks, hard_line_break, space_align, text, token},
write,
};
use oxc_markdown_parser::{
@@ -24,7 +24,7 @@ pub fn write_code_block<'a>(code: &'a CodeBlock<'a>, f: &mut MarkdownFormatter<'
CodeBlockKind::Indented => {
write!(
f,
align(
space_align(
4,
&format_with(|f| {
write!(f, token(" "));
@@ -5,7 +5,7 @@
use oxc_formatter_core::{
Buffer,
builders::{align, mark_as_root, text, token},
builders::{mark_as_root, space_align, text, token},
write,
};
use oxc_markdown_parser::{
@@ -101,9 +101,10 @@ pub fn write_list<'a>(
column_of(source, item.children[1].span().start) < column_of(source, content_column)
};
let body = format_with(|f| write_list_item(item, checkbox, prefix.len(), loose, f));
let width = if skip_align { 0 } else { u8::try_from(prefix.len()).unwrap_or(u8::MAX) };
// The item's content column is the root that verbatim continuation lines return to
write!(f, align(width, &mark_as_root(&body)));
let width = if skip_align { 0 } else { prefix.len() };
// The item's content column is the root that verbatim continuation lines return to.
// Its columns are syntax, spaces under `useTabs` too (see `space_align`).
write!(f, space_align(width, &mark_as_root(&body)));
}
f.context().lists().borrow_mut().pop();
@@ -145,13 +146,11 @@ fn write_list_item<'a>(
}
// The first child (unless a list) sits right after the checkbox;
// other children get the tab-width alignment.
// `checkbox` is `[x] ` / `[ ] ` / empty, `alignment` is at most 3
#[expect(clippy::cast_possible_truncation)]
let (checkbox_width, alignment_width) = (checkbox.len() as u8, alignment as u8);
// `checkbox` is `[x] ` / `[ ] ` / empty, `alignment` is at most 3.
if i == 0 && !matches!(child, Block::List(_)) {
write!(f, align(checkbox_width, &body));
write!(f, space_align(checkbox.len(), &body));
} else {
write!(f, [token(&" "[..alignment]), align(alignment_width, &body)]);
write!(f, [token(&" "[..alignment]), space_align(alignment, &body)]);
}
}
}
@@ -0,0 +1,26 @@
- a
- b
> q1
> q2
1. x
> q1
>
> q2
- [ ] task
> quoted
> - in quote
>
> > nested
Footnote[^1].
[^1]: Note.
> quoted in footnote
> more
@@ -0,0 +1,93 @@
---
source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs
---
==================== Input ====================
- a
- b
> q1
> q2
1. x
> q1
>
> q2
- [ ] task
> quoted
> - in quote
>
> > nested
Footnote[^1].
[^1]: Note.
> quoted in footnote
> more
==================== Output ====================
---------------------------------
{ printWidth: 80, useTabs: true }
---------------------------------
- a
- b
> q1
> q2
1. x
> q1
>
> q2
- [ ] task
> quoted
> - in quote
>
> > nested
Footnote[^1].
[^1]: Note.
> quoted in footnote
> more
----------------------------------
{ printWidth: 100, useTabs: true }
----------------------------------
- a
- b
> q1
> q2
1. x
> q1
>
> q2
- [ ] task
> quoted
> - in quote
>
> > nested
Footnote[^1].
[^1]: Note.
> quoted in footnote
> more
===================== End =====================
@@ -0,0 +1 @@
[{ "useTabs": true }]