```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 ```
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_corefor the language-agnostic IR + Printer + builders + macros- See
crates/oxc_formatter_core/AGENTS.mdfor the IR/pipeline details
- See
- 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 byFragmentContext- 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 pointformat_with_session: session-aware entry whoseFormatSessioncarries the host-suppliedSessionServices- the dispatcher (IR channel), the string embedder (JSDoc fences), and the Tailwind sorter (plain
format/format_programwrap a service-lessPhysicalFilesession)
- the dispatcher (IR channel), the string embedder (JSDoc fences), and the Tailwind sorter (plain
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/fixturesfor the covered behavior- See also
tests/jsdoc/upstream-jsdoc-bugs.md
- See also
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
TailwindSorterservice (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
FormatDispatcherOxfmt 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
.rawvs.cookedselection - Language formatter crates stay free of these rules
- See
embed/mod.rsfor the shared helpers andembed/{css,html,graphql,markdown}.rsfor each site's wiring
- See
- js-in-xxx works with
prettier-plugin-oxfmtwhich usesformat_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 viaFormatClassElementWithSemicolon, bodyless methods inMethodDefinition). 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) inprint/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 inFormatLeadingComments(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), checkhas_trailing_suppression_commentfirst, 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 onpreceded_by_newlinealone 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:
FormatContentWithSemicolonand the move-behind table above - return/throw: the same-line-prefix dangling split in
ReturnAndThrowStatement - capture side:
Comments::get_trailing_commentslets 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 aprettier-ignored statement gets the formatter's terminator (persemi) instead of its source one - suppressed expression side:
write_suppressed_expression(utils/suppressed.rs) owns the whole sequence for expression-shaped nodes (the generatedfmtand 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'swrite_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, includingexport const(measured, keep the asymmetry), only when a source;was stripped. Aforhead declaration instead stays verbatim (DIVERGENCES.md#suppressed-for-head-declaration) - The
semi: falseASI 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 inertEmptyStatement
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