Files
oxc-project__oxc/crates/oxc_formatter/AGENTS.md
leaysgur 9f81002229 fix(formatter): re-add suppressed statement terminators per options.semi (#26220)
```js
// input (semi: true)
// oxfmt-ignore
const a = x

// before
// oxfmt-ignore
const a = x

// after
// oxfmt-ignore
const a = x;
```

```js
// input (semi: false)
let a = 1;
// oxfmt-ignore
[b].sort();

// before
let a = 1
// oxfmt-ignore
[b].sort(); // <- ASI failure

// after
let a = 1
// oxfmt-ignore
;[b].sort() // <- OK
```
2026-09-01 07:25:43 +00:00

14 KiB

Coding agent guides for crates/oxc_formatter

Follow @../oxc_formatter_core/FORMATTER_POLICY.md , this file holds only the JS/TS-specific rules and translations. Known divergences live in DIVERGENCES.md (not yet an exhaustive audit against the conformance snapshots).

Overview

Prettier compatible JS/TS formatter (oxfmt's Tier 1 backend), ported from Biome's biome_js_formatter. It turns a parsed AST into an IR ("Document") of FormatElements, then prints that IR via the shared oxc_formatter_core Printer.

  • Built on oxc_formatter_core for the language-agnostic IR + Printer + builders + macros
    • See crates/oxc_formatter_core/AGENTS.md for the IR/pipeline details
  • This crate holds only the JS/TSX-specific layer
  • Parses with oxc_parser
    • Comments and other JS-specific concerns live here, not in core

Public API

Call-sites use text-in entry points; The AST-wrapping IR primitives (AstNode, Format, Buffer, …) are pub(crate) and not part of the contract.

  • format: Format a whole file (text-in)
  • format_fragment: Format a JS/TS fragment for js-in-xxx embedding, parameterized by FragmentContext
    • Drives context-dependent decisions like forced parentheses / quote style
    • The formatter knows nothing about Prettier/Vue vocabulary, callers pass wrapped source
  • format_program: Special-purpose AST-in entry point
  • format_with_session: session-aware entry whose FormatSession carries the host-supplied SessionServices
    • the dispatcher (IR channel), the string embedder (JSDoc fences), and the Tailwind sorter (plain format / format_program wrap a service-less PhysicalFile session)

Generated code

ast_nodes/generated/ and the Format glue are generated by tasks/ast_tools. After changing AST shapes or the generators, regenerate with just ast, never hand-edit files under generated/.

JS formatter specific features

Sort imports (ir_transform/sort_imports/)

  • Inspired by eslint-plugin-perfectionist, but not a 1:1 match (default group definitions differ too)
  • Implemented purely as a Rust IR transform; requires no JS callback

Format JSDoc (formatter/jsdoc/)

  • Derived from prettier-plugin-jsdoc, but not fully compatible
  • Covered by plain fixture-pair tests (--test jsdoc, committed input/expected pairs); a mismatch is a failing test, not a tracked conformance-report entry
  • See tests/jsdoc/fixtures for the covered behavior
    • See also tests/jsdoc/upstream-jsdoc-bugs.md

Sort Tailwind CSS

  • Backed by prettier-plugin-tailwindcss
  • Classes are collected during IR construction and sorted in one batch when the IR is stringified
  • Requires the session's TailwindSorter service (the sort itself is delegated to the host)

Embedded language formatting

Two directions: xxx-in-js (css/graphql/html in template literals) and js-in-xxx (vue/svelte).

  • xxx-in-js goes through the FormatDispatcher Oxfmt assembles Rust based formatter, Prettier Doc→IR fallback otherwise
  • As the JS host, this crate also owns the parent-side concerns in print/template/embed/:
    • template-literal escape on returned IR,
    • placeholder marker insertion / survival validation / ${expr} substitution,
    • and .raw vs .cooked selection
    • Language formatter crates stay free of these rules
      • See embed/mod.rs for the shared helpers and embed/{css,html,graphql,markdown}.rs for each site's wiring
  • js-in-xxx works with prettier-plugin-oxfmt which uses format_fragment

Fixing IR construction

  • Always keep the big picture in mind so a fix is not a one-off patch
    • Comments and parentheses are especially prone to side effects, handle them with care
  • Biome works on a CST rather than an AST, so its code and strategy differ in detail from ours (and both differ from Prettier's)

Comment placement invariants

The shared two-layer split (invariants vs compat tables) and the base invariants live in FORMATTER_POLICY.md "Comment placement invariants"; this section records their JS/TS translation and the measured compat tables.

Four token classes decide a comment's freedom of movement. Replaceability does not separate them (only the separator is replaceable); the guaranteed flush point does: a delimiter flushes on the comment's own side, a terminator only right BEHIND itself, so a riding line comment lands just past it and nothing else:

  • (1) Statement terminator: ; after statements and class members (property/accessor via FormatClassElementWithSemicolon, bodyless methods in MethodDefinition). Formatter-owned: a same-line trailing comment moves behind it, block and line comments alike (the move-behind table below)
  • (2) In-head terminator: the for-head's ;s, a label's or a single-block case test's :. Block and own-line comments keep their slot (the uniform rule); only a same-line line comment moves, just behind the token (for (a // c\n; b;) -> a; // c, foo // c\n: b(); -> foo: // c + break)
  • (3) Separator: interchangeable with another token (interface / type literal members and index signatures (; ⇄ ,), enum members' ,) the comment stays before it, same as any list separator. This matches Prettier and is the principled line, not an emulated quirk
  • (4) Delimiter: bounds a region, a body's { }, a head's ( ), the }-to-keyword gap. User content: comments never cross it in either direction (the head-body comment policy below)

Known gap, intentionally not covered: TS-only statements (import A = B; / export = x; / export as namespace X; / declare function f(): void; / declare module "m";).

They are terminators by the rule above, but their comments currently stay in place (coinciding with Prettier). Uniformizing them is OUR call, deferred for impact: each is a small mechanical FormatContentWithSemicolon adoption plus a fixture pin and a DIVERGENCES entry.

When the content's source parentheses survive in the output (return/throw arguments, sequence/assignment in the prettier#19263 positions), comments inside them belong to the content and stay there; only comments after the closing paren may move behind the terminator (see Comments::end_including_source_parens).

The "which positions keep their parens" table is intentionally encoded twice:

  • keeps_trailing_comment_inside_parens (statement side, walks down the rightmost expression)
  • and write_trailing_comments_inside_parens (expression side, matches on the parent) in print/semicolon.rs

The statement side promises not to hide/move the comment; the expression side actually prints it. Change them together: if they drift, the comment silently lands elsewhere (no assert catches it), so pin every position change in a fixture.

One rule decides WHAT moves, at every semicolon-terminated site: trailing is the same-line run only. A comment preceded by a newline is never trailing, it defers (stays unprinted) to the next node's leading pass, which keeps it own-line with its blank lines. The leading/trailing vocabulary itself (the newline as the semantic boundary, and why deferred comments belong to the NEXT node) is defined in formatter/trivia.rs's module doc.

The defer contract: a deferred comment crosses only already-printed statement terminators. The terminator set (;, and the ) of dropped or re-printed parens) is encoded twice. Comments::has_semicolon_or_closing_paren_in_range decides whether the same-line run moves, lines_after_skipping_terminators makes the same tokens transparent to the deferred comment's break measurement. So change them together, and pin any extension in a fixture. (get_lines_before's ; skip is a different token: the ASI-guard ; the next statement's own format emitted, sitting immediately before it.) A downstream pass must exist to print a deferred comment. (the next sibling's leading pass or the container's dangling pass.)

WHEN the same-line run moves is the per-site compat table (extend by measuring Prettier, not by analogy):

Site Source ; required?
Statements, class property/accessor, bodyless methods (FormatContentWithSemicolon, FormatClassElementWithSemicolon, MethodDefinition) ; or a ) (bare ASI: no move)
return/throw (ReturnAndThrowStatement) no (moves even under ASI)

A ) in the gap counts as a terminator (dropped or re-printed parens; the rationale lives in trailing_comments_to_move_behind_semicolon's rustdoc, prettier#19930 family). The chain-leaf walk (assignment_chain_leaf_end) bounds the content at every semicolon-terminated content site (expression statements, export defaults, variable declarations, class property values, return arguments); which arrow bodies stop it (and the flat-JSX-body known limitation) live in arrow_body_keeps_trailing_comment_inside_parens and DIVERGENCES.md#paren-comment-fixpoint.

JS-side mechanics of the shared "never cross" invariants:

  • Line boundary: line comments are printed via line_suffix, and own-line comments stay own-line (they become the next node's leading comments)
    • Both are structural guarantees, keep them
    • One known violation, kept for Prettier compat: an own-line comment claimed mid-line inlines onto that line (const // c + break), see the NOTE in FormatLeadingComments (formatter/trivia.rs)
  • User content: attachment artifacts that relocate a comment across tokens are divergences to pin, never rules to emulate (e.g. DIVERGENCES.md#array-hole-trailing-comment)
  • Suppression: when hiding comments from a node (limit_comments_up_to), check has_trailing_suppression_comment first, or the node loses its suppression

Head-body comment policy ("never cross user content" applied to statement/declaration heads): a body's { } and a head's ( ) are delimiters, and an empty-statement body's ; is content, not a terminator (the verbatim empty-statement note in "Statement terminators and suppression"), so a comment between a head and its body keeps its side of each, uniformly across constructs:

  • same-line block comment stays inline (while (x) /* c */ {)
  • same-line line comment keeps its line, forcing the { onto the next line (while (x) // c + break)
  • own-line comment keeps its own line
  • comments before a }-to-keyword gap (else/catch/finally/while) split at the keyword and keep their side

The as/satisfies operator gap follows the same policy (see as_or_satisfies_expression.rs and DIVERGENCES.md#binary-cast-own-line-comment), with two additions of its own:

  • the pre-operator slot is grammar-bounded (no line terminator may precede the operator, a multiline comment's interior counts), so once the expression's source parens are dropped, only same-line single-line block comments stay on the expression side;
    • everything else normalizes to the type side so the output re-parses
  • paragraph-like promotion: a line-ending multiline block goes own-line above the type.
    • =/: reach the same outputs through their own machinery (AssignmentLike, see DIVERGENCES.md#eol-comment-after-assign-colon for their line-comment glue);
    • the head-body write_* helpers split on preceded_by_newline alone and do not have the promotion.

Implemented by the write_* helpers in utils/statement_body.rs and FormatParenHeadExpression (print/mod.rs); their rustdocs cover the trailing-pass suppression mechanics.

Prettier's comment attachment is position-heuristic and sometimes asymmetric; that is FORMATTER_POLICY's uniform-rule ground (reason 3): one rule over the emulated asymmetry, pinned as a divergence (e.g. DIVERGENCES.md#type-alias-trailing-comment-move).

Statement terminators and suppression

The formatter owns statement terminators (and the trivia up to them); the user owns content. Prettier encodes "the trailing ; is outside the statement" once, in locEnd; deciding on the spot, we encode the same policy per site, so keep them in step:

  • print side: FormatContentWithSemicolon and the move-behind table above
  • return/throw: the same-line-prefix dangling split in ReturnAndThrowStatement
  • capture side: Comments::get_trailing_comments lets deferred own-line comments escape when the statement shares its distant ; with the enclosing statement (a single-statement body, if (1) foo\n// c\n;); a block's last statement keeps them inside instead
  • suppressed side: suppressed_statement_content_end (print/mod.rs) ends the ignored range at the content, so even a prettier-ignored statement gets the formatter's terminator (per semi) instead of its source one
  • suppressed expression side: write_suppressed_expression (utils/suppressed.rs) owns the whole sequence for expression-shaped nodes (the generated fmt and the arrow sequence-body site call it before anything of the node is printed), so a cast target keeps its source cast parens (excluded from its span, utils/typecast.rs's write_suppressed_cast_target) and in-paren comments print in place via the verbatim range

Accepted edges (byte-identical to Prettier, semantically inert, idempotent):

  • Whether a suppressed statement re-adds ; is a compat table, not a principle: keyword statements (debugger/break/continue) and variable declarations (ignored range ends at the last declarator) always re-add; content-terminated ones, including export const (measured, keep the asymmetry), only when a source ; was stripped. A for head declaration instead stays verbatim (DIVERGENCES.md#suppressed-for-head-declaration)
  • The semi: false ASI guard is decided from the guarded statement alone, never from the previous statement's output; sound because no statement leaves its own trailing ;, except a verbatim empty-statement body (with (1) ;, that ; IS the body, i.e. content), where guard plus verbatim ; re-parse as one extra inert EmptyStatement

Verification

Feature configs to check:

cargo c -p oxc_formatter
cargo c -p oxc_formatter --features detect_code_removal

Fixture tests

See tests/README.md for the full workflow.

# Run all fixtures tests
cargo test -p oxc_formatter --test mod
# Run a subset by module path (directory structure = module hierarchy)
cargo test -p oxc_formatter fixtures::js::comments