- Restore wrongly consolidated fixtures - State new fixtures policy and apply - Check `endOfLine` option and output at test harness level
12 KiB
Coding agent guides for apps/oxfmt
Oxfmt is the integration layer that dispatches between the Rust formatters built on oxc_formatter_core and Prettier.
Overview
The oxfmt implemented under this directory serves several purposes.
- JS/Rust hybrid CLI using
napi-rs- Full feature set like CLI, Stdin, LSP, and more
- Format many languages with embedded language formatting support like Prettier
- Entry point:
src-js/cli.tswhich usesrun_cli()fromsrc/main_napi.rs - Build with
pnpm build
- Pure Rust CLI
- Limited feature set, CLI usage only, no LSP, no Stdin support
- Formats only languages which we rewrite formatter in Rust
- Entry point:
main()insrc/main.rs - Build with
cargo build --no-default-features
- Node.js API using napi-rs
- Caller-supplied options, no config discovery
- Entry point:
src-js/index.tswhich usesformat()fromsrc/main_napi.rs - Build with
pnpm build
When making changes, consider the impact on all paths.
Cross-cutting behavior is applied through several entry points:
- CLI:
src/cli/walk_runner.rsandsrc/cli/walk.rs - Stdin:
src/cli/stdin_runner.rs - LSP:
src/lsp/server_formatter.rs - NAPI direct-document API:
src/api/format_api.rs - NAPI
textToDoc()API forprettier-plugin-oxfmt:src/api/text_to_doc_api.rs
Check the relevant path before assuming that behavior is shared across entry points.
Platform considerations
Oxfmt is built for multiple platforms (Linux, macOS, Windows) and architectures.
When working with file paths in CLI code, be aware of Windows path differences:
- Use
std::path::Path/PathBufinstead of manual string manipulation with/ - Be cautious with path comparisons and normalization across platforms
- Avoid hardcoding
/as a path separator; preferPath::join() - Windows uses
\as a path separator and has drive letter prefixes (e.g.,C:\)
- Avoid hardcoding
CLI implementations
Oxfmt shares code with Oxlint regarding its CLI implementation.
- Rust implementation:
crates/oxc_config - JS implementation:
apps/shared
Please exercise extra caution when making changes to these files.
Ignore architecture
Ignore handling is intentionally split across entry points. Use the entry-point map above; keep detailed behavior and rationale next to the relevant implementation and tests.
The stable distinction is:
- Formatter-owned ignores control formatting eligibility where the entry point supports them
- These include
.prettierignore, CLI--ignore-path,!patterns and configignorePatterns - They exclude an explicitly requested document
- These include
- Git-derived ignores scope filesystem discovery
.gitignoreand.git/info/exclude- They do not exclude an explicitly requested document
Formatter implementations
Oxfmt utilizes different implementations depending on the file extension and filename:
- Tier 1: Rust implementations using
oxc_formatter,oxc_formatter_json, etc found in this repository - Tier 2: Rust implementations using external libraries like
oxc_toml - Tier 3: Delegations to Prettier via NAPI-JS calls (e.g., for Vue or Markdown)
- Tier 4: Delegations to Prettier that require additional Prettier plugins (e.g., for Svelte)
NOTE: Rust written formatters never fall back to Prettier, since they exist to reduce the dependency on Prettier.
Divergence vs Prettier
Known divergences live in DIVERGENCES.md (embedding / dispatch layer only; single-language ones live in the owning crate's DIVERGENCES.md).
- Pin convention: a minimal repro lives in
conformance/fixtures/edge-cases/any external fixture containing the same diff carries the same NOTE inconformance/run.ts; the duplication is deliberate, not a consolidation target - All current entries are embedding-layer decisions that outlive the Prettier delegation; if an entry about the Prettier-fallback boundary itself is ever added, group it under a separate heading in DIVERGENCES.md so the removal scope stays obvious when the fallback goes away
Embedded language formatting
Embedded languages (e.g. css-in-js, CSS front matter YAML) go through the FormatDispatcher (defined in oxc_formatter_core) assembled by src/core/embed/dispatcher.rs.
Routing is ONE table (dispatcher::route): Native languages (css/graphql/yaml/json/...) get a Rust branch, the Prettier set (html/angular/markdown) goes to the Prettier Doc→IR channel (embed/prettier_doc.rs, napi only), everything else is deliberately preserved.
Vocabulary: "fallback" = the dispatcher's optional PrettierDocFallback slot (a build/root may not install one).
The pure Rust build runs fallback-less, so non-native embeds (html-in-js, TOML/custom front matter) deliberately stay verbatim.
Three roots install SessionServices, all via embed/services.rs::for_root (one definition per build; the napi one takes the ExternalServices transport, and adds the Prettier fallback / string embedder / Tailwind sorter to the registry dispatcher): the JS/TS and CSS file roots (core/format.rs, PhysicalFile sessions, both builds) and the Vue/Svelte <script> root (api/text_to_doc_api.rs, VirtualDocument session, napi only).
Which languages may dispatch AT ALL from a given host is the host crate's own gate (e.g. oxc_formatter_css dispatches only yaml/toml front matter); the shared route() table then decides who serves the language. Adding a dispatch call to a host crate is therefore a routing decision (check route() and the embedded conformance when doing so. A root needing a bespoke service set would assemble the SessionServices struct literally), none does today.
embeddedLanguageFormatting: off installs no dispatcher, every builder consults the same off-gate, ResolvedDispatchConfig::is_embedded_formatting_enabled.
Tracing span namespaces: oxfmt::embed:: = pure Rust work, oxfmt::external:: = napi-crossing calls.
Per-language options are NOT built up front: ResolvedDispatchConfig maps them lazily at dispatch time (OnceLock-memoized) from the host file's resolved config, including the Prettier options JSON for the JS-side consumers. src/core/external_services.rs bridges the napi callbacks into these factories.
A separate string-out channel (the session's string_embedder service, NOT the dispatcher) carries JSDoc's string-in/string-out consumer:
- JSDoc fenced code blocks: routing follows ONE rule, the same
dispatcher::routetable- a
Nativefence language formats throughFormatSession::dispatch_to_stringvia a thin string adapter (embed/jsdoc_fence.rs::format_native_fence, EVERY build, the pure Rust build wires it viaservices::for_root) - md/html/angular fences stay on the Prettier string path (
embed/prettier_string.rs, napi only; their Doc→IR conversion has unrepresentable cases); - everything else stays verbatim
- the embedder carries the caller's effective print width; both branches honor it (native via
PrintWidthoverride, Prettier viaprintWidthin the options JSON), so a fence prints at the same width a JS/TS snippet in the same position would (seeupstream-jsdoc-bugs.md#11 for the deliberate divergence from upstream's flatprintWidth - 4)
- a
NOTE: The string-out channel outlives the md/html/angular rewrites; its full exit criterion is owned by oxc_formatter_core's AGENTS.md (domain (4)).
The half owned here: JSDoc's string-out is NOT structural, fences can move to IR-out (session dispatch inside the comment IR) once the printer grows a per-line prefix mechanism for the * continuation, deferred for verification time, not by design.
Tailwind CSS class sorting
Tailwind class sorting (sortTailwindcss) splits responsibilities:
- Rust collects classes into
FormatElement::TailwindClassclassNameetc. in JS/TS,@applyin CSS — incl. css-in-js and JSDoc fenced blocks
- and the JS side sorts them in one batch
sortTailwindClasses→ tailwind'sgetClassOrder, which needs the resolved Tailwind config
Embedded boundaries carry classes through DispatchPayload::tailwind_classes;
each embed site consumes the doc via DispatchPayload::into_doc(collector), which merges them into the parent's class space.
The four data paths (JS/TS top-level / standalone CSS / embedded CSS / JSDoc fenced CSS) are documented at embed::services::for_root (napi definition).
No CSS goes to Prettier for this; the pure Rust build never collects at all (both mappers gate collection behind napi, since no sorter exists there).
Consequently, managing these various formatter implementations and handling their respective options are also part of Oxfmt's responsibilities.
Verification
cargo c
cargo c --no-default-features
cargo c --features detect_code_removal
Also run clippy for the same configurations and resolve all warnings.
Run tests with:
# Run unit test in Rust
cargo t
# Run E2E test
pnpm build-dev && pnpm t
# Update snapshots
pnpm t -u
# Run conformance test for xxx-in-js and js-in-xxx
pnpm build-dev && pnpm download-fixtures && pnpm conformance
To manually verify the CLI behavior after building:
pnpm build-test
# Show help
node ./dist/cli.js --help
# Stdin (Prettier's `--config=<cfg> <file>` equivalent)
cat <file> | node ./dist/cli.js --config=<cfg> --stdin-filepath=<file>
# With log
OXC_LOG=debug node ./dist/cli.js --threads=1 <file>
NOTE: pnpm build-dev combines pnpm build-js and pnpm build-napi, so you don't need to run them separately.
To compare formatting output with Prettier:
# Use a shared config file (e.g., fmt.json) because Oxfmt and Prettier have different default printWidth.
# Example fmt.json: { "printWidth": 80 }
cat <file> | node ./dist/cli.js --config=fmt.json --stdin-filepath=<file>
node ./node_modules/prettier/bin/prettier.cjs --config=fmt.json <file>
Test organization (test/ directory)
Tests are organized into specific domains, each with its own structure.
test/api/: Formatting result tests
Focuses on verifying formatting output. Use the Node.js API. No fixtures, test inputs are inline in each test file.
- Multiple
*.test.tsfiles coexist in a flat directory (no subdirectories) - Snapshots are colocated in
__snapshots__/by Vitest
test/cli/: CLI fixture-driven tests
A single cli.test.ts auto-discovers and runs all fixture directories via utils.ts.
- Each fixture directory contains:
options.json— array of test cases (args, cwd, env, stdin, etc.)fixtures/— input files for the test cases*.snap.md— file snapshots (one per test case, named0.snap.md,1.snap.md, …)
- Adding a new CLI test: create a new directory with
options.jsonandfixtures/, then run the test to generate snapshotsfixtures/represents a single project structure- Multiple test cases (different args, cwd, etc.) against the same structure go in one
options.json - If a scenario needs a different project layout, create a separate directory
- Name related directories with a shared prefix (e.g.,
nested_config/,nested_config_no_root/)
- If exceptional test cases are required, place a separate
*.test.tsfile for them
test/lsp/: LSP integration tests
Each test directory follows the 1:1:1 rule:
- 1 test file (
*.test.tswith the same name as the directory) - 0 or 1
fixtures/directory - Snapshots are colocated in
__snapshots__/by Vitest
Shared helpers are in utils.ts at the test/lsp/ level.
After updating Oxfmtrc (src/core/oxfmtrc.rs)
When modifying the Oxfmtrc struct (and configuration options):
- Run
just formatter-schema-jsonto updatenpm/oxfmt/configuration_schema.json - Run
just formatter-config-tsto regeneratesrc-js/config.generated.tsfrom the schema - Run
cargo test -p website_formatterto update schema markdown snapshots- Then,
cargo insta accept
- Then,