feat(codegen): add oxc-codegen package (#25488)

Add `oxc-codegen` package - a printer written in TS.

### Features

- Prints JS, JSX, TS, TSX.
- Matches `oxc_codegen` exactly on all Test262, TypeScript, Acorn-JSX fixtures.
- Very fast.

### Deficiencies

- Pretty-printing only (no compact/minified output).
- No support for printing comments.
- Source map support is semi-there, but largely untested, and poor API (design doc has some ideas on how to improve it).

### This PR adds

- The printer code.
- Conformance testing infra.
- Further tests for edge cases.
- README.
- Design doc - how it differs from `oxc_codegen` and why, what produces the perf.

### This PR doesn't add

- Not integrated into CI or release pipeline.

### Notes

This started as a slop port of `oxc_codegen`. I've iterated on that base, cleaned it up a lot, and at least skimmed through all of it. I think it's in fairly decent shape.

The design doc is I think worth a read. It explains the rationale for the choices, in particular the "1 codebase, 4 builds" approach.

I've put it in a new top-level directory called `packages`. It's not an app, so didn't seem to me to fit alongside `apps/oxlint` and `apps/oxfmt`. It's pure JS, so not like e.g. `napi/parser`. And it felt too heavyweight to put into `npm` dir. But maybe there's a better place for it.

Co-authored-by: Cameron <cameron.clark@hey.com>
This commit is contained in:
overlookmotelandCameron committed 2026-08-12 13:41:31 +00:00
1 parent 73acba93fb
commit a4478e960a
61 files changed
+10718 -4

No files matched your search

+39
View File
@@ -505,6 +505,7 @@ jobs:
run: cargo clippy --all-features --profile dev-no-debug-assertions
- run: pnpm --filter oxlint-app build-js
- run: pnpm --filter oxfmt-app build-js
- run: pnpm --filter oxc-codegen build
- run: node --run lint
doc:
@@ -566,6 +567,44 @@ jobs:
just coverage
git diff --exit-code
test-codegen:
name: Test Codegen
runs-on: namespace-profile-linux-x64-default
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- name: Check if codegen tests should run
id: filter
uses: ./.github/actions/check-changes
with:
packages: oxc_codegen_conformance,oxc_parser_napi
paths: .github/workflows/ci.yml,packages/codegen/,tasks/codegen_conformance/,napi/parser/,npm/oxc-types/,tasks/coverage/,pnpm-lock.yaml,pnpm-workspace.yaml,package.json
token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }}
- uses: ./.github/actions/clone-submodules
if: steps.filter.outputs.changed == 'true'
with:
node-compat-table: false
- uses: oxc-project/setup-node@d59b270414aa7d0eed947eed4eb5b0b8a675f01d # v1.4.0
if: steps.filter.outputs.changed == 'true'
- uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
if: steps.filter.outputs.changed == 'true'
with:
path: /home/runner/.rustup
cache: |
rust
pnpm
- name: Run codegen tests
if: steps.filter.outputs.changed == 'true'
run: |
pnpm --filter oxc-codegen build-test
pnpm --filter oxc-codegen test
git diff --exit-code
minification:
name: Minification Size
runs-on: namespace-profile-linux-x64-default
+1
View File
@@ -10,6 +10,7 @@ target/
/website/node_modules/
/benchmark/node_modules/
/tasks/benchmark/codspeed/node_modules/
/tasks/codegen_conformance/node_modules/
/tasks/transform_conformance/node_modules/
/tasks/compat_data/node_modules/
/tasks/compat_data/compat-table/
Generated
+16
View File
@@ -1907,6 +1907,22 @@ dependencies = [
"rustc-hash",
]
[[package]]
name = "oxc_codegen_conformance"
version = "0.0.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"oxc_allocator",
"oxc_ast",
"oxc_ast_visit",
"oxc_codegen",
"oxc_napi",
"oxc_parser",
"oxc_syntax",
]
[[package]]
name = "oxc_compat"
version = "0.144.0"
+2
View File
@@ -28,6 +28,8 @@
"apps/oxfmt/src-js/config.generated.ts",
"apps/oxlint/conformance/submodules/**",
"napi/parser/src-js/bindings.js",
"tasks/codegen_conformance/index.js",
"tasks/codegen_conformance/index.d.ts",
"napi/playground/playground.wasi-browser.js",
"napi/{transform,transform-react,minify,playground}/index.js",
"napi/{transform,transform-react,minify,playground}/index.d.ts",
+4 -4
View File
@@ -2,11 +2,11 @@
"name": "oxc",
"private": true,
"scripts": {
"build": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" build",
"build-dev": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" build-dev",
"build-test": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" build-test",
"build": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" --filter \"./packages/*\" build",
"build-dev": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" --filter \"./packages/*\" build-dev",
"build-test": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" --filter \"./packages/*\" build-test",
"build-wasm-dev": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" build-wasm-dev",
"test": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" test",
"test": "pnpm --workspace-concurrency=1 --filter \"./napi/*\" --filter \"./apps/*\" --filter \"./packages/*\" test",
"fmt": "oxfmt -c oxfmtrc.jsonc",
"lint": "oxlint -c oxlintrc.json --deny-warnings --report-unused-disable-directives"
},
+2
View File
@@ -0,0 +1,2 @@
/node_modules/
/dist/
+620
View File
@@ -0,0 +1,620 @@
# Design
Why `oxc-codegen` is shaped the way it is.
It is a port of Oxc's Rust [`oxc_codegen`] crate, and its output has to be byte-identical to it.
But it is not a transliteration. A straight port would not take into account differences between Rust and JS,
and would likely be several times slower.
This document is for someone about to change the printer, wondering which of its oddities are load bearing.
Everything here is also documented at the point it applies. This is an overview.
## Contents
- [Why not native?](#why-not-native)
- [What this is](#what-this-is)
- [What it is not at present](#what-it-is-not-at-present)
- [`state.last`](#statelast)
- [Four builds from one source tree](#four-builds-from-one-source-tree)
- [Other things which make it fast](#other-things-which-make-it-fast)
- [Where the ESTree AST differs](#where-the-estree-ast-differs)
- [How it is tested](#how-it-is-tested)
- [Rules to keep](#rules-to-keep)
## Why not native?
Oxc's other JS packages are bindings around a native library. This one deliberately is not.
The starting point was a hunch:
> **When the AST is already in JS, no native printer can beat a JS one.**
### The argument
- **Printing is cheap.** It is a linear walk of the AST, emitting strings. There is no parsing, no analysis,
no search. It doesn't play to the strengths of a faster language the way parsing or minifying does.
- **Crossing into native is not cheap.** A native printer cannot see a JS object graph.
The whole AST has to be serialized across the boundary before it can print a single character.
- **The boundary dominates.** The serialization is proportional to the size of the AST, just as the printing is -
but it is work a JS printer can skip entirely. It is added to a total that was small to begin with.
The conclusion: A native printer loses the game before it's even started - still shovelling data over to native side,
while the JS printer already finished work and has gone off to the pub.
### Where this applies
Only when the AST is **already** in JS - it came from `oxc-parser`, or a plugin built it, or something
transformed it on JS side. That is the common use case this package is for.
It does **not** apply when you are printing source text you also parsed natively. Then stay on native side -
the AST never has to leave native memory, and Rust `oxc_codegen` is the right tool. The two are complementary.
The benchmarks in the [README](./README.md) measure `oxc-codegen` over representative JS-held ASTs.
## What this is
A printer from an ESTree AST to source text, written in TypeScript.
### The output contract
Byte-identical to `oxc_codegen` with default options - tab indentation, double quotes, comments off.
The conformance suites check this on every fixture. It is what makes the two printers substitutable for one another.
## What it is not at present
`oxc_codegen` (Rust printer) is more capable than this one.
- No minify mode.
- No support for comments.
- No symbol mangling.
- Source map support is implemented, but not well-tested.
Many of these features can be added in future, and we should be able to do so without _much_ impact on performance.
## `state.last`
This is the largest divergence from Rust, and most of the others follow from it.
### The problem
Two adjacent tokens can merge into a different token when written straight up against each other.
`in` after an identifier becomes part of that identifier. `+` after `+` becomes `++`.
Deciding whether to insert a space means knowing what was written last.
### How Rust answers it
Rust just looks. Its output buffer is a `Vec<u8>`, so `code.last_byte()` is a pointer dereference - very cheap.
### Why this doesn't work in JS
**In JS, reading the last character is catastrophic for perf.** `state.output` is built by repeated `+=`,
which V8 represents as a **cons string** - a tree of the pieces, never actually concatenated. Reading any
character of it forces V8 to flatten that tree.
Do that after every append and the printer spends its time flattening ropes it is about to extend again.
Result is a **4x slow-down**.
So `oxc-codegen` has to find a different solution. Every time it pushes a string to output, it also records
in `state.last` what the output now ends with. `state.output` never needs to be read from.
### The answer: A category, not a character
It turns out the functions which decide whether spaces need to be inserted between keywords / identifiers
don't actually need to know what the last character was, they only need to know what _category_ of character
it was. i.e. It doesn't matter if the last character was `"a"` or `"Z"` or `"$"` - they're all equivalent.
Almost everywhere in the printer, it knows statically what _category_ it's writing, even if it doesn't know
the exact bytes until runtime. e.g. When writing an identifier, it's the `IDENT` category.
```ts
write(state, node.name, CAT_IDENT);
```
This has 3 advantages over storing the last character:
1. Categories are just small integers, the cheapest data type there is.
2. The categories are statically defined at call sites. No `node.name.at(-1)`.
3. Checking the category of last write is cheap - `last === CAT_IDENT` not `isIdentifierPart(lastChar)`.
That one `last` field replaces all of this:
| Rust | Question it answers | JS |
| :------------------------------------ | :---------------------------------- | :----------------------- |
| `last_byte()` + `is_identifier_part` | Would a following identifier merge? | `CAT_IDENT` |
| `last_byte() == Some(b'?')` | Would a following `?` make `??`? | `CAT_QUESTION` |
| `peek_nth_byte_back(1) == Some(b'<')` | Is this `!` the `!` of a `<!`? | `CAT_OP_UN_NOT_AFTER_LT` |
### Extending this scheme to operators
Rust `oxc_codegen` also holds state about what operator was last written. It stores this info as a pair:
- `prev_op` - Last operator that was written
- `prev_op_end` - Position of the end of that operator
Each time an operator is written, it sets these 2 fields. When other code wants to know "am I writing this straight
after an operator, and which operator?" it:
1. Checks if `code.len() == prev_op_end`. If true, then yes, it's right after an operator.
2. `prev_op` then says which operator.
In Rust printer, this makes sense. Checking these 2 fields, and comparing to `code.len()` costs 2 or 3 memory accesses,
and 1 or 2 comparisons. But that's fine! It only reads or writes these 2 fields infrequently - only when an operator
is being written. And operators are only a small fraction of code.
In JS codegen, we're in a different situation. We've been forced to update `state.last` after every write,
to avoid the huge perf hit of reading the last character from `output`.
So we might as well use `last` to record operators too, and also some similar character classes:
| Rust | Question it answers | JS |
| :-------------------------------------- | :------------------------------------------------------ | :---------------- |
| `code.len() == prev_op_end` + `prev_op` | Which operator came last, and was it right before this? | `CAT_OP_*` |
| `code.len() == need_space_before_dot` | Is space needed after digit? e.g. `0 .toExponential()` | `CAT_INT_DIGIT` |
| `code.len() == prev_reg_exp_end` | Did a flagless regex just close? | `CAT_REGEX_SLASH` |
All these Rust state fields, and every read of the output buffer, collapse into one field in the JS printer.
It costs no write barrier to store (because categories are represented by "SMI" small integers), and one compare
to test. Being touched by every single write, it is the hottest field in `State`, reliably in L1 cache,
and probably often also benefits from fast store-to-load forwarding.
#### The `<!--` case, as an example
Rust decides whether a `!` is the `!` of a `<!--` hazard by peeking at the _second_-to-last byte,
at the moment a `--` is about to be written.
Here the question is answered when the `!` is written, where the preceding character is already known,
and the answer is baked into which category gets stored. The reader has nothing left to look up.
### Position marks
Similar to operators, Rust printer also maintains state about the _context_. For example:
```js
// Function declaration
export default function foo () {}
// Function expression
export default (function bar () {});
```
These 2 are semantically different - `foo` is available in outer scope, `bar` is not.
When printing a `FunctionExpression`, print needs to know "am I the value of an `export default`?"
to know whether it needs to wrap the function in parentheses.
Similar to operators, Rust printer stores this info in field `start_of_default_export`.
When `code.len() == start_of_default_export` then, yes, what's currently being printed is the value of
an `export default` statement.
Again, JS codegen uses `last` to store this info. Except here `last` doesn't describe what _was_ written,
it says what the context is.
Note that the node which reads the mark need not be the direct child of the `export default`.
In `export default (function bar() {})();` the function is the callee of a call expression, and the parens
are still needed. It gets them because `printCallExpression` wrote nothing before descending into the callee,
so the mark was still there when `printFunction` looked. Whatever is printed leftmost sees the mark,
however deeply nested it is.
| Rust | Question it answers | JS |
| :-------------------------------------- | :------------------------------------ | :---------------------------- |
| `code.len() == start_of_stmt` | At the start of a statement? | `CAT_START_OF_STMT` |
| `code.len() == start_of_arrow_expr` | At the start of a concise arrow body? | `CAT_START_OF_ARROW_EXPR` |
| `code.len() == start_of_default_export` | At the start of an `export default`? | `CAT_START_OF_DEFAULT_EXPORT` |
The reason this works is that in every case the last character printed in these positions was a space, a line break,
or the start of the file, which all fall into the `CAT_OTHER` category. So these values of `last` aren't ambiguous
to any other code which judges what to do next based on what came last. They just treat these values as
"last character written was something I don't need to do anything special about".
#### Why the substitution is safe
Two facts, both checked across every printer file:
1. **Nothing writes output on the way down from a mark to its reader.** Every path from a mark to a
reader either writes nothing, or writes a real `(` - never a `writeNoLast`, which would leave the
mark visible past output which should have killed it.
2. **It can only ever add parentheses, never remove them.** A real `write` always grows the output
(empty writes are illegal), so the set of things which kill a mark under categories is a strict
subset of what killed the offsets.
So every possible failure is a spurious pair of parentheses - slightly longer output, but not _invalid_ output.
The byte-for-byte conformance tests would catch this, but even if there are gaps in the conformance suite
for crazy edge cases and a bug sneaks through, it can't produce invalid JavaScript.
### Why category numbering is load bearing
The full table is at the top of [`print/write.ts`] and is the authority.
Three properties are relied on. Adding a code without preserving them will silently space output wrongly.
1. **Identifier hazards are the lowest codes.** So `printSpaceBeforeIdentifier` is `last <= CAT_REGEX_SLASH` -
one compare, no table, no branch tree. The operators `printSpaceBeforeOperator` must distinguish are the highest,
for the same reason (`last >= CAT_OP_UN_NOT_AFTER_LT`).
2. **The `CAT_START_OF_*` codes sit between those two ranges**, which is what makes both range checks
treat them as "nothing to separate".
3. **`CAT_START_OF_STMT` is odd, with the other two marks either side.** The five reader sites each ask
about a _pair_ of marks, never all three, so both pairs fall out as one `|`-and-compare:
```
(last | 1) === CAT_START_OF_STMT // Statement or `export default`
((last - 1) | 1) === CAT_START_OF_STMT // Statement or concise arrow body
```
Each property has an `if (DEBUG)` block which iterates every category and asserts the compare selects
exactly the intended set and nothing else - in [`print/write.ts`] for the `|` identities, and in [`print/space.ts`]
for the two range checks.
Renumber freely. The assertions will tell you what you broke, and cost release builds nothing.
**One deliberate exception**: `CAT_OP_UN_NOT` is an operator code but sits _below_ the operator range.
No operator merges with a plain `!`, so storing it must not cost `printSpaceBeforeOperator` a call into its slow path.
### `writeNoLast`
Not every write needs to update `last`.
When one token is written in pieces - a string's opening quote, its contents, its closing quote -
only the last piece's category can possibly be read. Hence four write primitives in [`print/write.ts`]:
| Function | Updates `last` | Records a source mapping |
| :------------------- | :------------- | :----------------------- |
| `write` | Yes | No |
| `writeNoLast` | No | No |
| `writeWithMap` | Yes | Yes |
| `writeWithMapNoLast` | No | Yes |
- The `*NoLast` pair is used at 89 sites, against 596 for the other two.
- The rule is exact: **only sound where the value of `last` is provably dead**.
Another real write must follow before anything reads it.
- JSX carries the longest runs. Printing `<a.b x="1">hi</a.b>` updates `last` exactly once, on the final `>`.
- `writeNoLast` accepts an empty string, `write` does not.
A `write` of `""` would leave `last` claiming a category for a character that was never written.
### How the invariants are enforced
Every rule above fails quietly - a missing space in one construct out of thousands. So three debug-only
mechanisms guard them, all removed from release builds.
1. **`debugAssertCategoryMatches`** runs on every `write`, checking the category truthfully describes
the final character written. It cannot be a simple lookup - a category encodes the merge hazard of
the trailing _token_, not the trailing character - a digit legitimately ends both a `CAT_IDENT` write
and a `CAT_INT_DIGIT` one - so each final character has a permitted set.
2. **`lastIsStale` / `debugAssertLastFresh`** track the `writeNoLast` rule. `writeNoLast` sets the flag,
the next real write clears it, and every reader of `last` calls `debugAssertLastFresh` first,
to make sure they're not reading a stale value.
An author who was wrong about `last` being dead gets a clear failure, not a wrongly spaced construct.
3. **The numbering assertions** described above.
This is why the debug build is the one to run the conformance suites against - `pnpm run build-test`.
## Four builds from one source tree
`dist` holds the printer compiled four times, plus an entry point which picks between them.
| Build | `TS` | `SOURCEMAPS` | Size |
| :----------------- | :---- | :----------- | ----: |
| `print_js.js` | false | false | 25 KB |
| `print_js_maps.js` | false | true | 26 KB |
| `print_ts.js` | true | false | 39 KB |
| `print_ts_maps.js` | true | true | 40 KB |
`index.ts` picks one from the caller's `ts` and `sourceMap` options and `require`s it on first use -
`require` rather than `import()`, because `printSync` is synchronous.
A caller printing only JavaScript never loads, parses or compiles the TypeScript printers at all.
### Why separate TS and JS builds
**The obvious reason is code size and complexity.** JS-only builds have all TS-related code removed,
as it's all gated behind compile-time `TS` constant. Every `if (TS && node.declare) { ... }` block is removed
by minifier, and every `case "TSEnumDeclaration":` arm by the `strip_ts` TSDown plugin. With the arms gone,
the TS-only printer functions lose their last references and are tree-shaken away. That is a third of the code.
However, it has a surprisingly small impact on perf.
**The bigger reason is monomorphism.** `oxc-parser` produces differently-_shaped_ node objects for a
JS-shaped AST and a TS-shaped one - the TS shape carries `optional`, `declare`, `accessibility`,
`override` and the rest.
If both shapes flow through one `printFunction`, V8 sees two hidden classes at every property access,
the inline caches go polymorphic, and the whole printer slows down.
Two builds means each one only ever sees one shape. JS printer sees only JS AST-shaped objects,
TS printer sees only TS AST-shaped objects. This gives a perf boost of over 10%.
Additionally, the split helps branch prediction. If feeding a mix of JS and TS ASTs through a single printer,
for a JS AST branches for "does this node have a type annotation?" always find the answer is "no".
Branch predictor believes there's a pattern and when that code runs again, it expects no type annotation again.
But this time, the AST is a TS AST, and the answer is "yes". Mispredict! Go back 100 instructions, go directly back,
do not pass Go, do not collect $200!
With 2 different builds for JS and TS, this doesn't arise. In the JS build the branch isn't there at all -
it was compiled away. The TS build only ever sees TS ASTs, so what the branch predictor learns stays true.
### Why `State` lives outside `print` directory
The same argument, one level up.
`State` is defined in `src-js/state.ts`, which is **not** part of any printer build. The printers import
it as a type only; the entry point constructs it. So all four builds share one class, and therefore one
hidden class, for the object they thread through every single function.
This is why **a build-time flag must never add or remove a field on `State`**.
- The source-map fields are present in every build, and set to `null` when unused.
- The debug fields are present in every build, and only _initialised_ under `if (DEBUG)`.
Making either conditional would give each build a different object shape, undoing the whole arrangement.
### The build plugins
There are 4 TSDown plugins in `tsdown_plugins`. Order matters - `strip_ts` is a text transform,
so it must run before the plugins which parse.
#### `strip_ts`
Deletes regions fenced with `/* IF TS */` ... `/* END_IF */` from JS-only builds.
They exist because a minifier can fold `if (TS)` away, but cannot remove a `switch` `case` arm on the grounds
that its node type never occurs.
It fails the build if the fences are unbalanced - and also if it finds _no_ fences at all.
A plugin silently doing nothing is a failure mode worth guarding against.
#### `unmap_writes`
Rewrites `writeWithMap(state, code, cat, node)` into `write(state, code, cat)` in non-sourcemap builds,
drops the `node` argument, and rewrites the import to match.
Without it, the node argument would still be evaluated and held live across a call which ignores it.
It re-parses its own output and fails the build if anything still reaches a mapped write, or calls a name
it did not bring into scope.
#### `const_functions`
Rewrites top-level `function foo(...) { ... }` into `const foo = (...) => { ... }`.
A function declaration creates a binding which could be assigned later, so every call has to read
the current value out of module scope. A `const` cannot be, so V8 calls it directly.
- **Measured at ~3%** across all benchmark fixtures.
- It really is the `const`. The same rewrite with `let` measures ~4% _slower_ than this, and slower than
leaving the declarations alone.
- The source keeps its declarations, because they hoist and read better. Which is why this is a build step
rather than a house style.
#### `remove_asserts`
Removes `debugAssert` and `typeAssertIs` calls _and the expressions inside them_ from release builds.
The minifier can drop the calls on its own, but cannot prove `node.value` in `debugAssert(node.value !== null)`
has no getter, so it would leave `node.value !== null` behind as a bare expression statement.
This is what gives us Rust `debug_assert!`-style checks in tests and conformance, with zero cost in release build.
## Other things which make it fast
### Popularity-ordered dispatch
`printExpression` (34 arms) and `printStatement` (33 arms) are megamorphic string switches.
V8 lowers a switch on strings to what amounts to an `if / else if` chain, so arm order is execution order.
Both are ordered roughly by how often each node type occurs in real code - common variants like `Identifier`,
`MemberExpression`, `CallExpression` first.
This sped up the printer by ~30%.
It also means **adding an arm near the top of either switch has a cost**, however rare the node type is.
Even after ordering `case` arms, these "dispatch" functions remain a large part of runtime (~25%).
Much sweat and tokens were spilled trying to reduce it further:
- Perfect hash table to convert `node.type` to an integer ID.
- `switch`-ing on an ID to reduce the switch to a jump table.
- Branching on first character to narrow down the range - `switch (node.type.charCodeAt(0)) { case 69: ... }`.
- Many others...
All to no effect. Arm ordering worked, nothing else did.
If anyone else can crack this, it's probably the single largest unrealized perf gain on the table.
### Monomorphism
As much as possible, call paths are structured so that functions receive the same object shape consistently.
Once it has gathered type feedback from the first runs, V8 tiers up hot functions to TurboFan, which generates
code along these lines (pseudo-code):
```ts
function printThing(state, node): void {
if (SHAPE_OF(node) !== K_EXPECTED_SHAPE) {
DE_OPTIMIZE(printThing);
return printThing(state, node);
}
// Now it's known exactly what shape `node` is, it becomes static-typed like Rust.
// Working with it is very cheap.
// Original code: `const { left } = node;`
const left: Expression = GET_UNCHECKED(node, K_LEFT_OFFSET);
// ... fast code from here on ...
}
```
In particular, it is **essential** that `State` remains the same shape _everywhere_. Every function touches it,
so any inconsistency in shape would turn every access polymorphic and completely tank performance.
The obvious exception is the dispatch functions discussed above (`printExpression` etc), which are
unavoidably megamorphic.
We have not done a full audit of all functions to check if any are receiving polymorphic/megamorphic inputs
when that could be avoided. Such an audit might throw up potential improvements.
#### Moral of the story
Consistent object shapes are _really_ important for good perf in JS engines.
We should provide users with utility functions to construct AST nodes which produce the exact same object shape
as what `oxc-parser` produces. This would speed up not just `oxc-codegen`, but also their own code that
operates on ASTs.
```ts
// Good
const binary = oxc.binaryExpression(left, operator, right);
// Bad - different field order, different object shape
const binary = { left, right, operator };
```
### Intended parser pairing
The printer is meant to be used with `oxc-parser` with `experimentalRawTransfer` enabled.
Raw transfer builds the AST with a JS deserializer rather than via JSON. Node `type` strings then come out
as source literals, so those big `switch`es compare interned strings by pointer, rather than character by character.
This gives a 10% - 30% perf boost versus a JSON AST.
### Fast paths
- Pretty mode always puts a single space either side of a binary or assignment operator, so the whole
token is one constant string - `" + "`, `" ??= "` - and one `write`, instead of space-operator-space.
It also makes the token-glue checks unobservable around those operators, so they are not made at all.
See `PADDED_BIN_OPERATORS` in [`print/operators.ts`].
- `printNonNegativeFloat` prints integers below 1000 straight from `String()`. They are the overwhelming
majority, and three digits are too few for any shortening form to win.
- `printString` tests the whole string against one regex of characters needing escapes, and appends it
whole when there are none. The escaping loop is a separate function because it almost never runs.
- `printSpaceBeforeOperator` is one compare, with its three-clause slow path split out so the common path
stays small enough for V8 to inline.
- `printLiteral` switches on `typeof value` first, identifying strings and numbers without touching the
`regex` or `bigint` properties.
- `printNumericLiteral` tests `value > 0 && value < Infinity` first, reaching the same branch the full
chain would without the two builtin calls.
Each of these produced a small perf bump individually, ~10% gain in aggregate.
### Indent cache
`printIndent` reads a pre-built string for the current level out of an array.
- The cache is created once per _process_, alongside the module-level state in `state.ts`.
Every `State` carries a reference to it. So indent strings one printer run has grown are there for the next.
This cache is shared between JS and TS, sourcemap and no-sourcemap builds.
- `growIndents` deliberately forces each new indent string flat (a throwaway `charCodeAt(0)`) before caching it,
paying the flatten cost once rather than on every append.
### Source maps computed at the end
During printing, a mapped write records only an output _offset_ and the node's original position.
`emitMappings` then walks the output once at the end, counting newlines, to turn those offsets into
generated line/column. Tracking a line and column throughout would cost every write in every build.
The `Mapping` objects handed to `addMapping` are reused across calls rather than allocated per mapping.
#### Potential future improvement
Producing source maps is exactly the kind of raw number-crunching that native code excels at.
And, unlike the AST, it's just small integers - the kind of data that transfers across the JS-native
boundary cheaply.
It might be worthwhile assembling all the offsets on JS side in an `Int32Array`, instead of a plain `Array`,
and making a single JS-Rust-JS round-trip, with the sourcemap generation (VLQ encoding etc) happening on Rust side.
WASM might also be a good option for this.
### No allocation in the binary walk
`printBinaryish` walks the left spine of a binary/logical chain iteratively. `a + b + c + d` nests as deep
as it is long, so recursion would put the stack at the mercy of the input.
Rust does the same, but with an explicit `Stack` on `Codegen`. Here the pending levels are threaded
through a `parent` field on the visitor object, so a non-nested operand allocates nothing extra.
### Lookup tables are `__proto__: null`
So a property miss cannot walk up to `Object.prototype`.
## Where the ESTree AST differs
The two printers walk different ASTs, and some of the differences reach the output.
### Node types
- **One `Literal` node type** covers strings, numbers, booleans, `null`, regexes and bigints, where Rust
has six separate types. `printLiteral` tells them apart from `typeof value` and the presence of `regex` / `bigint`.
- **`#x in obj` arrives as a `BinaryExpression`** with a `PrivateIdentifier` on the left, not as its own node type.
Hence the extra test in `printExpression`'s `BinaryExpression` arm.
### Parenthesized function expressions
Oxc's Rust AST records that a function expression was parenthesized in a `pife` flag, and prints the parentheses
back from it.
ESTree has no such flag. The information is only there when the AST was parsed with `preserveParens`,
as a `ParenthesizedExpression` wrapper. `printExpression`'s arm for that node re-emits the parentheses
around a function or arrow, and is transparent around anything else.
This is why `printBinaryish` prints its operands **unstripped**. `withoutParens` is used for
the structural type tests, but the node handed to `printExpression` keeps its wrapper,
so that arm can run.
### Things ESTree cannot express
- `import "m";` versus `import {} from "m";`
- An absent `with` clause versus an empty `with {}`
- `assert {...}` versus `with {...}`
- A rest parameter's decorators (a deficiency in `oxc-parser`, fixable, not blocked by ESTree spec)
The conformance harness normalizes the Rust AST down to what ESTree can express before printing,
rather than expecting the JS side to reproduce information it was never given.
See `Normalize` in `tasks/codegen_conformance/src/lib.rs`.
### Positions, and other producers
- **`loc`, not spans.** Oxc's AST records byte spans. Source mappings need line/column, which other
producers put in `loc`. Mapped writes check the node for `loc` rather than checking the build.
- **Acorn and TS-ESLint ASTs are accepted**, and differ from Oxc's in a handful of places
(`TSMappedType`, `TSImportType`, `TSEnumDeclaration`, `TSModuleDeclaration`).
[`print/types.ts`] holds the widened node types and documents each difference.
## How it is tested
`oxc-codegen` is tested against all Test262, Acorn-JSX, and TypeScript test cases - about 62,000 fixtures.
Every fixture is printed twice, and the two must agree byte for byte:
1. In Rust, via the `oxc-codegen-conformance` NAPI addon (`tasks/codegen_conformance`).
2. In JS, by this package.
Both sides parse the same source text with the same `SourceType`, derived by the same function,
so the only thing under test is the printing. Fixtures which do not parse cleanly are skipped,
rather than passing quietly.
Every fixture is checked in **both `preserveParens` modes**. They are different paths through the printer -
with the wrappers present it must decide what to re-emit, without them it must re-derive every parenthesis
from precedence.
## Rules to keep
A checklist, all of it argued for above.
1. **Never read a character of `state.output`.**
2. **A `write` must declare a category which truthfully describes its final character**, and must never be
given an empty string.
3. **Only use `writeNoLast` where `last` is provably dead.** A real write must follow before any reader runs.
4. **Preserve the three numbering properties** when adding or renumbering a `CAT_*` code, and keep the
`if (DEBUG)` assertions which check them.
5. **Never let a build-time flag add or remove a field on `State`.**
6. **Do not add an arm near the top of `printExpression`, `printStatement` or other dispatch functions**
unless the node type really is that common.
7. **Do not relax `const_functions` to `let`.**
8. **Run the conformance suites against a debug build** (`pnpm run build-test`), so the assertions are live.
9. **Benchmark against a release build** (`pnpm run bench` rebuilds one first). A debug build keeps
`debugAssert` calls and does not represent shipped performance.
[`oxc_codegen`]: https://github.com/oxc-project/oxc/tree/main/crates/oxc_codegen
[`print/write.ts`]: https://github.com/oxc-project/oxc/blob/main/packages/codegen/src-js/print/write.ts
[`print/space.ts`]: https://github.com/oxc-project/oxc/blob/main/packages/codegen/src-js/print/space.ts
[`print/operators.ts`]: https://github.com/oxc-project/oxc/blob/main/packages/codegen/src-js/print/operators.ts
[`print/types.ts`]: https://github.com/oxc-project/oxc/blob/main/packages/codegen/src-js/print/types.ts
+75
View File
@@ -0,0 +1,75 @@
# oxc-codegen
A fast JavaScript code generator from an [ESTree](https://github.com/estree/estree)-compliant AST,
written in TypeScript.
It is a faithful port of Oxc's Rust [`oxc_codegen`](https://github.com/oxc-project/oxc/tree/main/crates/oxc_codegen)
crate (pretty-printing mode). Output is byte-identical to `oxc_codegen` with default options
(tab indentation, double quotes, comments off).
Unlike the rest of Oxc's JS packages, this package contains no Rust and no native bindings.
It is pure JavaScript, and consumes an AST which already lives on the JS side, so doesn't need
to serialize it across a JS/native boundary. This is the key to `oxc-codegen`'s speed -
along with many optimizations to hit JS engines' fast paths.
See [DESIGN.md](https://github.com/oxc-project/oxc/blob/main/packages/codegen/DESIGN.md) for more details
on the implementation, and what makes it fast.
100% tests passing against Test262, Acorn-JSX, and TypeScript conformance suites (62,000 tests in total).
## Usage
```js
import { parseSync } from "oxc-parser";
import { printSync } from "oxc-codegen";
const { program } = parseSync("foo.js", "let x = 1");
console.log(printSync(program));
```
## Supported language variants
- JS
- JSX
- TS
- TSX
## API
### `printSync(node, options?)`
Returns a string containing the code for the AST `node`.
`node` must be a whole AST (`Program` node) or a statement node.
### Options
| Option | Type | Default | Description |
| :-------------------- | :------------------- | :------ | :---------------------------------------------- |
| `indent` | `string` | `"\t"` | Indentation - spaces and/or tabs only |
| `startingIndentLevel` | `number` | `0` | Indent level to start from |
| `jsx` | `boolean` | `false` | `.tsx` mode - lone type parameters print `<T,>` |
| `ts` | `boolean` | `false` | AST may contain TypeScript syntax |
| `sourceMap` | `SourceMapGenerator` | - | If present, source mappings are emitted into it |
## Missing features
- There is currently no support for printing comments.
- Pretty-printing only, no compact/minified output.
- Source map support is only lightly tested, and API is likely to change.
## Benchmarks
| fixture | bytes | Oxc |
| :------------------------- | ------: | --------: |
| tiny.js | 26 | 0.0001ms |
| RadixUIAdoptionSection.jsx | 2518 | 0.0033ms |
| react.development.js | 72141 | 0.1138ms |
| binder.ts | 193077 | 0.2472ms |
| App.tsx | 415340 | 0.7490ms |
| lodash.js | 544096 | 0.4995ms |
| kitchen-sink.tsx | 732222 | 2.5682ms |
| antd.js | 6683633 | 11.3914ms |
Numbers above are from one machine and are not a regression baseline - the `antd.js` figures in
particular move by over 10% run to run.
+107
View File
@@ -0,0 +1,107 @@
// Benchmarks. Run with `pnpm run bench`.
//
// Source maps are not measured. `printSync` is given no `sourceMap`, so it selects the builds
// compiled without source map support, which is what most callers use.
//
// Imports from `dist`, which must hold a RELEASE build. `pnpm run build-dev` replaces `dist` with a debug build,
// which keeps `debugAssert` calls live and does not represent shipped performance.
// `pnpm run bench` builds a release build first, so benchmarking that way is always correct.
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join as pathJoin } from "node:path";
import { parseSync } from "oxc-parser";
import { bench, describe } from "vitest";
import { printSync as oxcPrintSync } from "./dist/index.js";
// `TestFiles::minimal()` from `tasks/common/src/test_file.rs`, which the Rust `codegen` benchmark
// uses, plus 2 larger pure-JS files from `TestFiles::minifier()` for throughput on real bundles.
const FIXTURE_URLS = [
"https://cdn.jsdelivr.net/gh/oxc-project/benchmark-files@main/RadixUIAdoptionSection.jsx",
"https://cdn.jsdelivr.net/npm/react@17.0.2/cjs/react.development.js",
"https://cdn.jsdelivr.net/gh/excalidraw/excalidraw@f6d85bc80fe328e8f472636eb0d541f7bb891aa0/packages/excalidraw/components/App.tsx",
"https://cdn.jsdelivr.net/gh/microsoft/TypeScript@v5.3.3/src/compiler/binder.ts",
"https://cdn.jsdelivr.net/gh/oxc-project/benchmark-files@ac5609a8fe9ae8d1a1de0f2ef251d562630c77e0/kitchen-sink.tsx",
"https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js",
"https://cdn.jsdelivr.net/npm/antd@4.16.1/dist/antd.js",
];
// Same directory the Rust benchmarks download to, so nothing is downloaded twice
const CACHE_DIR_PATH = pathJoin(import.meta.dirname, "../../target");
// `experimentalRawTransfer` is not in the published `ParserOptions` type - it is experimental
// and untyped. Declaring the object here rather than inline at the call site keeps TypeScript's
// excess property check (which only applies to object literals) from rejecting it, without a cast.
//
// `preserveParens: false` because this printer deliberately does not support the redundant
// `ParenthesizedExpression` / `TSParenthesizedType` nodes.
//
// `experimentalRawTransfer` builds the AST with the JS deserializer rather than via JSON,
// which is the pairing this package is meant to be used in - node `type` strings come out
// as source literals, so the printer's type switches compare pointers rather than characters.
const PARSE_OPTIONS = {
preserveParens: false,
experimentalRawTransfer: true,
};
// Timings, raised well above Vitest's defaults of 500ms timed and 100ms warmup.
//
// The defaults leave enough run-to-run variance to swamp a 1-2% change,
// which is the size of win a printer optimization typically produces.
// The long warmup matters just as much as the long run:
// V8 has to tier the printer up to optimized code before any of the timed iterations start,
// or the first results measure the interpreter instead.
const BENCH_OPTIONS = { time: 3000, warmupTime: 1000, iterations: 50, warmupIterations: 20 };
/**
* Load a fixture, downloading it and caching it on disk if it isn't there already.
*
* @param url - URL to download the fixture from
* @returns Fixture's filename and source text
*/
async function loadFixture(url: string): Promise<{ filename: string; code: string }> {
const filename = url.split("/").at(-1)!,
path = pathJoin(CACHE_DIR_PATH, filename);
let code: string;
try {
code = await readFile(path, "utf8");
} catch {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download ${url}: ${res.status}`);
code = await res.text();
await mkdir(CACHE_DIR_PATH, { recursive: true });
await writeFile(path, code);
}
return { filename, code };
}
// `tiny.js` is one line long, so its result is a comparison of the two printers' fixed overhead
// rather than of their throughput. Fixtures are ordered by size.
const fixtures = [
{ filename: "tiny.js", code: "export default printSync;\n" },
...(await Promise.all(FIXTURE_URLS.map(loadFixture))),
];
fixtures.sort((fixture1, fixture2) => fixture1.code.length - fixture2.code.length);
for (const { filename, code } of fixtures) {
const { program, errors } = parseSync(filename, code, PARSE_OPTIONS);
if (errors.length > 0) throw new Error(`Failed to parse ${filename}: ${errors[0].message}`);
// `.tsx`/`.jsx` fixtures need JSX-safe printing of type parameter lists (`<T,>`)
const options = { jsx: filename.endsWith("x"), ts: /\.tsx?$/.test(filename) };
// Print once before timing, so a benchmark can never measure the printer bailing out.
oxcPrintSync(program, options);
describe(`${filename} (${code.length} bytes)`, () => {
bench(
"oxc-codegen",
() => {
oxcPrintSync(program, options);
},
BENCH_OPTIONS,
);
});
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "oxc-codegen",
"version": "0.144.0",
"description": "Fast JavaScript code generator from an ESTree-compliant AST",
"keywords": [
"ast",
"code generator",
"codegen",
"estree",
"javascript",
"oxc",
"typescript"
],
"homepage": "https://oxc.rs",
"bugs": "https://github.com/oxc-project/oxc/issues",
"license": "MIT",
"author": "Boshen and oxc contributors",
"repository": {
"type": "git",
"url": "git+https://github.com/oxc-project/oxc.git",
"directory": "packages/codegen"
},
"files": [
"dist"
],
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "node scripts/build.ts",
"build-dev": "cross-env DEBUG=true node scripts/build.ts",
"build-test": "pnpm run build-dev && pnpm --filter oxc-parser run build-test && pnpm --filter oxc-codegen-conformance run build",
"test": "vitest run --dir ./test"
},
"devDependencies": {
"@types/node": "catalog:",
"cross-env": "catalog:",
"oxc-codegen-conformance": "workspace:*",
"oxc-parser": "workspace:*",
"rolldown": "catalog:",
"tsdown": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
// Build script.
import { execSync } from "node:child_process";
import { rmSync } from "node:fs";
import { join as pathJoin } from "node:path";
const packageDirPath = pathJoin(import.meta.dirname, "..");
// Delete `dist` directory.
// TSDown's `clean` option can't do it, because both builds output to the same directory,
// so whichever runs second would delete the other's output.
rmSync(pathJoin(packageDirPath, "dist"), { recursive: true, force: true });
// Build both flavours with TSDown
execSync("pnpm tsdown", { stdio: "inherit", cwd: packageDirPath });
+47
View File
@@ -0,0 +1,47 @@
// Assertions.
//
// Both of these are compiled away - `tsdown_plugins/remove_asserts.ts` removes every call to them,
// and the expressions inside it, so neither costs a release build anything at all.
/**
* Assert a value is of a certain type.
*
* Has no runtime effect - only for guiding the type-checker.
* Minification removes this function and all calls to it, so it has zero runtime cost.
*
* @param value - Value
*/
// oxlint-disable-next-line no-unused-vars
export function typeAssertIs<T>(value: unknown): asserts value is T {}
/**
* Assert a condition.
*
* In release builds, is a no-op. Only does runtime checks in debug builds.
* Minification removes this function and all calls to it in release builds, so it has zero runtime cost.
*
* Use this for testing conditions which would indicate a bug in the code.
* Do NOT use this for validating user input.
*
* If creating the error message is expensive, or potentially creating the message itself can result in an error
* when the assertion passes, pass a function which returns the message.
*
* ```ts
* debugAssert(condition, () => `Condition failed: ${getErrorMessage()}`);
* ```
*
* @param condition - Condition which is expected to be `true`
* @param message - Message to include in error if condition is `false`,
* or a function which returns the message to include in error if condition is `false` (optional).
*/
export function debugAssert(
condition: boolean,
message?: string | (() => string),
): asserts condition {
if (!DEBUG) return;
if (!condition) {
if (typeof message === "function") message = message();
throw new Error(message ?? "Assertion failed");
}
}
+14
View File
@@ -0,0 +1,14 @@
// Global constants defined at build time by TSDown.
// `true` if build emits source maps
declare const SOURCEMAPS: boolean;
// `true` if build supports TypeScript syntax.
// In builds where it's `false`, `tsdown_plugins/strip_ts.ts` also removes the switch arms
// fenced with `/* IF TS */` / `/* END_IF */` comments.
declare const TS: boolean;
// `true` if is debug build.
// Built with `pnpm run build-dev`, which sets `DEBUG=true`. Debug builds keep `debugAssert` calls
// (see `src/asserts.ts`). Release builds strip them in `tsdown_plugins/remove_asserts.ts`.
declare const DEBUG: boolean;
+85
View File
@@ -0,0 +1,85 @@
// Entry point. Dispatches to whichever build of the printer suits the options it's given.
//
// `DESIGN.md` in the package root is the overview of the whole printer - why a JS printer beats
// a native one here, where this port diverges from Rust `oxc_codegen` (notably `state.last`),
// and what makes it fast. Read it before changing anything below, or in `print`.
//
// The printer is built 4 times from `print/`, over 2 build-time feature flags.
//
// 1. Source map support - costs a little speed even when it's unused.
// 2. TypeScript support - costs JS-only ASTs a swathe of field checks and switch arms.
//
// The most important effect of separate JS and TS builds is not the reduced logic when printing a JS AST,
// but that they receive differently-shaped AST node objects. If both JS-shape and TS-shape ASTs
// pass through the same functions, every function becomes polymorphic, which is a large slow-down.
// Having specialized code paths for JS and TS-shape ASTs avoids this problem - most functions remain monomorphic.
//
// Each combination is its own build, lazy-loaded depending on the `sourceMap` and `ts` options.
//
// `require` rather than `import()` because the printer is synchronous.
// All builds are ESM, which `require` can load on the Node versions in this package's `engines` field.
import { createRequire } from "node:module";
import { State } from "./state.ts";
import type { Options } from "./print/options.ts";
import type * as ESTree from "../../../npm/oxc-types/types.d.ts";
export type { Mapping, Options, Position, SourceMapGenerator } from "./print/options.ts";
/**
* All printer builds are compiled from `print/index.ts`, so they share its exports.
*/
type PrintModule = typeof import("./print/index.ts");
const require = createRequire(import.meta.url);
/**
* Passed on when the caller supplies no options, so the printer never has to check for their absence.
*/
const EMPTY_OPTIONS: Options = {};
/**
* Printer builds, indexed by `(ts ? 1 : 0) + (sourceMap ? 2 : 0)`. Lazily loaded as needed.
*/
const PRINTER_PATHS = [
"./print_js.js",
"./print_ts.js",
"./print_js_maps.js",
"./print_ts_maps.js",
];
/**
* The loaded builds, in the same order as `PRINTER_PATHS`, and `null` until first used.
*/
const printers: (PrintModule["printSync"] | null)[] = [null, null, null, null];
/**
* Print `node`, returning the generated code.
*
* @param node - AST node to print, a `Program` or a single statement
* @param options - Printing options (optional)
* @returns Generated code
*/
export function printSync(node: ESTree.Node, options?: Options): string {
// The printer is built 4 times, over whether the AST may contain TypeScript and whether
// source mappings are wanted. This picks the build the options call for and loads it on first use,
// so a caller printing only JavaScript never pays for the TypeScript printers.
let index = 0;
if (options == null) {
options = EMPTY_OPTIONS;
} else {
if (options.ts === true) index = 1;
if (options.sourceMap != null) index |= 2;
}
let print = printers[index];
if (print === null) {
print = (require(PRINTER_PATHS[index]) as PrintModule).printSync;
printers[index] = print;
}
// State is created here, not in the printer, so that all 4 builds share one class
// and therefore see one object shape
return print(node, new State(options), options);
}
@@ -0,0 +1,163 @@
// Assignment targets.
import { typeAssertIs } from "../asserts.ts";
import { printPropertyKey } from "./binding_pattern.ts";
import { CAT_IDENT, CAT_OTHER, write, writeWithMap } from "./write.ts";
import { printExpression, printMemberExpression } from "./expression.ts";
import { printSpaceBeforeIdentifier } from "./space.ts";
import { CTX_NONE } from "./operators.ts";
import { PREC_COMMA, PREC_COMPARE, PREC_PREFIX } from "./precedence.ts";
import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print the left hand side of an assignment.
*
* Destructuring targets look like object and array literals but are a separate set of node types,
* with their own rules - which is why they are printed here rather than by the expression printer.
*/
export function printAssignmentTarget(node: ESTree.AssignmentTarget, state: State): void {
switch (node.type) {
// A simple target is only ever a name, a member access, or (in TS) one of the assertion expressions below.
// For speed, the two common ones are printed here instead of through the expression printer's own dispatch.
case "Identifier":
printSpaceBeforeIdentifier(state);
writeWithMap(state, node.name, CAT_IDENT, node);
break;
case "MemberExpression":
printMemberExpression(node, state, CTX_NONE);
break;
case "ObjectPattern":
printObjectAssignmentTarget(node, state);
break;
case "ArrayPattern":
printArrayAssignmentTarget(node, state);
break;
/* IF TS */
case "TSAsExpression":
case "TSSatisfiesExpression":
// `(x as T) = y` - the target must be parenthesized
printExpression(node, state, PREC_COMPARE, CTX_NONE);
break;
case "TSTypeAssertion":
printExpression(node, state, PREC_PREFIX, CTX_NONE);
break;
/* END_IF */
default:
printExpression(node, state, PREC_COMMA, CTX_NONE);
}
}
/**
* Print an object destructuring target, as in `({ a, b: c } = obj)`.
*/
function printObjectAssignmentTarget(node: ESTree.ObjectAssignmentTarget, state: State): void {
writeWithMap(state, "{", CAT_OTHER, node);
const { properties } = node;
const { length } = properties;
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const property = properties[i];
if (property.type === "RestElement") {
write(state, "...", CAT_OTHER);
printAssignmentTarget(property.argument, state);
} else {
printAssignmentTargetProperty(property, state);
}
}
write(state, "}", CAT_OTHER);
}
/**
* Print one property of an object destructuring target, in shorthand where the AST says so.
*/
function printAssignmentTargetProperty(node: ESTree.AssignmentTargetProperty, state: State): void {
// Shorthand from the AST flag (assignment targets are not re-derived).
if (node.shorthand) {
// `{ a }` or `{ a = 1 }`
const { value } = node;
if (value.type === "AssignmentPattern") {
typeAssertIs<ESTree.IdentifierReference>(value.left);
printSpaceBeforeIdentifier(state);
writeWithMap(state, value.left.name, CAT_IDENT, value.left);
write(state, " = ", CAT_OTHER);
printExpression(value.right, state, PREC_COMMA, CTX_NONE);
} else {
printSpaceBeforeIdentifier(state);
writeWithMap(state, value.name, CAT_IDENT, value);
}
} else {
const { key } = node;
if (node.computed) {
write(state, "[", CAT_OTHER);
typeAssertIs<ESTree.Expression>(key);
printExpression(key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printPropertyKey(key, state);
}
write(state, ": ", CAT_OTHER);
printAssignmentTargetMaybeDefault(node.value, state);
}
}
/**
* Print a destructuring target which may carry a default, as the `a = 1` in `({ a = 1 } = obj)`.
*/
function printAssignmentTargetMaybeDefault(
node: ESTree.AssignmentTargetMaybeDefault,
state: State,
): void {
if (node.type === "AssignmentPattern") {
printAssignmentTarget(node.left, state);
write(state, " = ", CAT_OTHER);
printExpression(node.right, state, PREC_COMMA, CTX_NONE);
} else {
printAssignmentTarget(node, state);
}
}
/**
* Print an array destructuring target, holes and rest element included.
*/
function printArrayAssignmentTarget(node: ESTree.ArrayAssignmentTarget, state: State): void {
const { elements } = node;
let { length } = elements;
let rest: ESTree.BindingRestElement | ESTree.AssignmentTargetRest | null = null;
if (length > 0) {
const lastElement = elements[length - 1];
if (lastElement != null && lastElement.type === "RestElement") {
rest = lastElement;
length--;
}
}
writeWithMap(state, "[", CAT_OTHER, node);
for (let i = 0; i < length; i++) {
if (i !== 0) write(state, ", ", CAT_OTHER);
const element = elements[i];
if (element != null) {
typeAssertIs<ESTree.AssignmentTargetMaybeDefault>(element);
printAssignmentTargetMaybeDefault(element, state);
}
if (i === length - 1 && (element == null || rest !== null)) {
write(state, ",", CAT_OTHER);
}
}
if (rest !== null) {
if (length > 0) write(state, " ", CAT_OTHER);
write(state, "...", CAT_OTHER);
printAssignmentTarget(rest.argument, state);
}
write(state, "]", CAT_OTHER);
}
+186
View File
@@ -0,0 +1,186 @@
// Binary/logical expressions (port of `binary_expr_visitor.rs`).
import { typeAssertIs } from "../asserts.ts";
import { CAT_OTHER, write } from "./write.ts";
import { printPrivateInExpression, printExpression } from "./expression.ts";
import { BIN_PRECEDENCE, CTX_FORBID_IN, PADDED_BIN_OPERATORS } from "./operators.ts";
import { withoutParens } from "./parens.ts";
import { PREC_CALL, PREC_EXPONENTIATION, PREC_LOWEST, PREC_PREFIX } from "./precedence.ts";
import type { State } from "../state.ts";
import type { LiteralExtras } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* One level of the binary/logical expression chain.
*
* `parent` is the level above, which is waiting for this one to finish before printing
* its own operator and right operand.
*/
interface BinaryVisitor {
e: ESTree.BinaryExpression | ESTree.LogicalExpression;
precedence: number;
ctx: number;
leftPrecedence: number;
operator: ESTree.BinaryOperator | ESTree.LogicalOperator;
wrap: boolean;
rightPrecedence: number;
parent: BinaryVisitor | null;
}
/**
* Print a binary or logical expression, and its whole left-leaning chain, without recursing.
*
* `a + b + c + d` nests to the left as deep as it is long, so recursion would put the stack
* at the mercy of the input. This walks down the left spine iteratively instead, and unwinds
* through each level's `parent` to print the operators and right operands on the way back up.
*
* @param precedence - Precedence of the position this expression sits in, deciding parenthesisation
* @param ctx - Context flags, carrying whether `in` is forbidden and calls are
*/
export function printBinaryish(
node: ESTree.BinaryExpression | ESTree.LogicalExpression,
state: State,
precedence: number,
ctx: number,
): void {
// The pending outer levels are threaded through `parent` rather than a separate stack array,
// so a non-nested operand allocates nothing extra
let v: BinaryVisitor | null = {
e: node,
precedence,
ctx,
leftPrecedence: PREC_LOWEST,
operator: node.operator,
wrap: false,
rightPrecedence: PREC_LOWEST,
parent: null,
};
for (;;) {
binCheckAndPrepare(v, state);
const left = withoutParens(v.e.left);
if (left.type === "BinaryExpression" || left.type === "LogicalExpression") {
if (left.type === "BinaryExpression" && left.left.type === "PrivateIdentifier") {
// Private-in expression as the left operand
typeAssertIs<ESTree.PrivateInExpression>(left);
printPrivateInExpression(left, state, PREC_LOWEST);
binVisitRightAndFinish(v, state);
break;
}
typeAssertIs<ESTree.BinaryExpression | ESTree.LogicalExpression>(left);
v = {
e: left,
precedence: v.leftPrecedence,
ctx: v.ctx,
leftPrecedence: PREC_LOWEST,
operator: v.operator,
wrap: false,
rightPrecedence: PREC_LOWEST,
parent: v,
};
} else {
// `v.e.left` prints, not the unwrapped `left` - a `ParenthesizedExpression` around a function
// expression is how Oxc's `pife` flag reaches this printer, and the arm for it in `printExpression`
// writes those parens back. Around anything else the wrapper is transparent, forwarding
// this precedence and `ctx` unchanged.
printExpression(v.e.left, state, v.leftPrecedence, v.ctx);
binVisitRightAndFinish(v, state);
break;
}
}
while ((v = v.parent) !== null) {
binVisitRightAndFinish(v, state);
}
}
/**
* Work out whether one level of the chain needs parentheses, write the opening one if so,
* and settle the precedences its two operands are printed at.
*
* `**` is right associative, so its left operand binds tighter, and the rest are the other way round.
* `??` may not sit unparenthesized beside `&&` or `||`, which is why either operand can be forced up to `PREC_PREFIX`.
*/
function binCheckAndPrepare(v: BinaryVisitor, state: State): void {
const { e } = v;
const eOperator = e.operator;
const ePrecedence = BIN_PRECEDENCE[eOperator];
// No parens if both sides use the same logical operator
const precedenceCheck =
v.precedence >= ePrecedence &&
(!isLogicalOperator(v.operator) || v.precedence !== BIN_PRECEDENCE[v.operator]);
v.operator = eOperator;
v.wrap = precedenceCheck || (eOperator === "in" && (v.ctx & CTX_FORBID_IN) !== 0);
if (v.wrap) {
write(state, "(", CAT_OTHER);
v.ctx &= ~CTX_FORBID_IN;
}
// One level below the operator's own precedence. The precedence scale has no gaps, so this is
// subtraction rather than a second table - `BinaryOperator::lower_precedence` in `oxc_syntax`
// names the adjacent variant for each operator, which comes to the same thing.
const lower = ePrecedence - 1;
v.leftPrecedence = lower;
v.rightPrecedence = lower;
if (ePrecedence === PREC_EXPONENTIATION) {
// Right-associative
v.leftPrecedence = ePrecedence;
} else {
// All other binary/logical operators are left-associative
v.rightPrecedence = ePrecedence;
}
if (eOperator === "??") {
// Nullish coalescing cannot mix with && / || unparenthesized
const left = withoutParens(e.left);
if (left.type === "LogicalExpression" && left.operator !== "??") {
v.leftPrecedence = PREC_PREFIX;
}
const right = withoutParens(e.right);
if (right.type === "LogicalExpression" && right.operator !== "??") {
v.rightPrecedence = PREC_PREFIX;
}
} else if (eOperator === "**") {
// The base of `**` must be an `UpdateExpression`.
// Unary/await bases and negative-printing literals must be parenthesized.
const left = withoutParens(e.left);
typeAssertIs<LiteralExtras>(left);
if (
left.type === "UnaryExpression" ||
left.type === "AwaitExpression" ||
(TS && left.type === "TSTypeAssertion") ||
(left.type === "Literal" && (typeof left.value === "number" || left.bigint != null))
) {
v.leftPrecedence = PREC_CALL;
}
}
}
/**
* Whether an operator is one of the three which `??` may not mix with unparenthesized.
*/
function isLogicalOperator(operator: string): boolean {
return operator === "&&" || operator === "||" || operator === "??";
}
/**
* Finish one level of the chain - its operator, its right operand, and its closing parenthesis.
*
* The operator is written space-padded as one token, which is why nothing here consults `last` -
* the spacing checks cannot observe an operator which already has a space either side of it.
*/
function binVisitRightAndFinish(v: BinaryVisitor, state: State): void {
// The operator is always surrounded by spaces here, which makes the token glue checks
// (`printSpaceBeforeIdentifier` / `printSpaceBeforeOperator`) unobservable, so the whole token is one write
write(state, PADDED_BIN_OPERATORS[v.operator], CAT_OTHER);
// Any `ParenthesizedExpression` wrapper is kept, for the same reason as on the left operand
printExpression(v.e.right, state, v.rightPrecedence, v.ctx);
if (v.wrap) write(state, ")", CAT_OTHER);
}
@@ -0,0 +1,197 @@
// Binding patterns.
import { typeAssertIs } from "../asserts.ts";
import {
CAT_IDENT,
CAT_OTHER,
CAT_QUESTION,
write,
writeWithMap,
writeWithMapNoLast,
} from "./write.ts";
import { printExpression } from "./expression.ts";
import { printSpaceBeforeIdentifier } from "./space.ts";
import { printLiteral } from "./literal.ts";
import { CTX_NONE } from "./operators.ts";
import { PREC_COMMA } from "./precedence.ts";
import { printString } from "./string.ts";
import { printTypeAnnotation } from "./typescript.ts";
import type { State } from "../state.ts";
import type { LiteralExtras, UnknownNode } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* A binding pattern, or one of the nodes which can stand in for one.
*
* `RestElement` appears in object and array patterns.
*/
type BindingPatternNode =
| ESTree.BindingPattern
| ESTree.BindingRestElement
| ESTree.FormalParameterRest;
/**
* Print anything which can be bound to - a name, a destructuring pattern, or one carrying a default.
*/
export function printBindingPattern(node: BindingPatternNode | UnknownNode, state: State): void {
switch (node.type) {
case "Identifier":
printSpaceBeforeIdentifier(state);
writeWithMap(state, node.name, CAT_IDENT, node);
if (TS && node.optional) write(state, "?", CAT_QUESTION);
if (TS && node.typeAnnotation != null) printTypeAnnotation(node.typeAnnotation, state);
break;
case "ObjectPattern":
printObjectBindingPattern(node, state);
if (TS && node.optional) write(state, "?", CAT_QUESTION);
if (TS && node.typeAnnotation != null) printTypeAnnotation(node.typeAnnotation, state);
break;
case "ArrayPattern":
printArrayBindingPattern(node, state);
if (TS && node.optional) write(state, "?", CAT_QUESTION);
if (TS && node.typeAnnotation != null) printTypeAnnotation(node.typeAnnotation, state);
break;
case "AssignmentPattern":
printBindingPattern(node.left, state);
write(state, " = ", CAT_OTHER);
printExpression(node.right, state, PREC_COMMA, CTX_NONE);
break;
case "RestElement":
writeWithMap(state, "...", CAT_OTHER, node);
printBindingPattern(node.argument, state);
if (TS && node.typeAnnotation != null) printTypeAnnotation(node.typeAnnotation, state);
break;
default:
throw new Error(`Unknown binding pattern type: ${node.type}`);
}
}
/**
* Print an object destructuring pattern, as in `const { a, b } = obj`.
*/
function printObjectBindingPattern(node: ESTree.ObjectPattern, state: State): void {
const { properties } = node;
const { length } = properties;
if (length === 0) {
writeWithMap(state, "{}", CAT_OTHER, node);
return;
}
writeWithMap(state, "{ ", CAT_OTHER, node);
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const property = properties[i];
if (property.type === "RestElement") {
write(state, "...", CAT_OTHER);
printBindingPattern(property.argument, state);
} else {
printBindingProperty(property, state);
}
}
write(state, " }", CAT_OTHER);
}
/**
* Print one property of an object pattern, in shorthand where the key and the binding agree.
*/
function printBindingProperty(node: ESTree.BindingProperty, state: State): void {
// Shorthand is re-derived from names (matching Oxc), not from the flag.
const { key, value } = node;
let shorthand = false;
if (!node.computed && key.type === "Identifier") {
if (value.type === "Identifier" && key.name === value.name) {
shorthand = true;
} else if (
value.type === "AssignmentPattern" &&
value.left.type === "Identifier" &&
key.name === value.left.name
) {
shorthand = true;
}
}
if (!shorthand) {
if (node.computed) {
write(state, "[", CAT_OTHER);
typeAssertIs<ESTree.Expression>(key);
printExpression(key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printPropertyKey(key, state);
}
write(state, ": ", CAT_OTHER);
}
printBindingPattern(value, state);
}
/**
* Print a property key which is not computed - a name, a private name, a string or a number.
*
* The caller decides between this and printing the key as an expression in brackets,
* because whether a key must be computed depends on what it is, not on how the source wrote it.
*/
export function printPropertyKey(key: ESTree.PropertyKey, state: State): void {
switch (key.type) {
case "Identifier":
printSpaceBeforeIdentifier(state);
writeWithMap(state, key.name, CAT_IDENT, key);
break;
case "PrivateIdentifier":
writeWithMapNoLast(state, "#", key);
write(state, key.name, CAT_IDENT);
break;
case "Literal":
typeAssertIs<LiteralExtras>(key);
if (typeof key.value === "string") {
printString(state, key.value, key);
} else {
printLiteral(key, state, PREC_COMMA, CTX_NONE);
}
break;
default:
printExpression(key, state, PREC_COMMA, CTX_NONE);
}
}
/**
* Print an array destructuring pattern, holes and rest element included.
*/
function printArrayBindingPattern(node: ESTree.ArrayPattern, state: State): void {
// Oxc stores the rest element separately from the other elements.
// A trailing comma is printed before it, and a space always precedes it.
const { elements } = node;
let { length } = elements;
let rest: ESTree.BindingRestElement | ESTree.AssignmentTargetRest | null = null;
if (length > 0) {
const lastElement = elements[length - 1];
if (lastElement != null && lastElement.type === "RestElement") {
rest = lastElement;
length--;
}
}
writeWithMap(state, "[", CAT_OTHER, node);
for (let i = 0; i < length; i++) {
if (i !== 0) write(state, ", ", CAT_OTHER);
const element = elements[i];
if (element != null) printBindingPattern(element, state);
if (i === length - 1 && (element == null || rest !== null)) write(state, ",", CAT_OTHER);
}
if (rest !== null) {
write(state, " ", CAT_OTHER);
printBindingPattern(rest, state);
}
write(state, "]", CAT_OTHER);
}
+420
View File
@@ -0,0 +1,420 @@
// Classes.
import { typeAssertIs } from "../asserts.ts";
import { printPropertyKey } from "./binding_pattern.ts";
import {
CAT_IDENT,
CAT_OP_UN_NOT,
CAT_OTHER,
CAT_QUESTION,
CAT_START_OF_STMT,
debugAssertLastFresh,
write,
writeNoLast,
writeWithMap,
} from "./write.ts";
import { printExpression } from "./expression.ts";
import { printFunctionBody, printParenParams } from "./function.ts";
import { printSpaceBeforeIdentifier } from "./space.ts";
import { printIndent } from "./indent.ts";
import { CTX_NONE } from "./operators.ts";
import { PREC_CALL, PREC_COMMA, PREC_LOWEST, PREC_POSTFIX } from "./precedence.ts";
import { printStatement } from "./statement.ts";
import {
printTSIndexSignature,
printTypeAnnotation,
printTypeArguments,
printTypeParameters,
} from "./typescript.ts";
import type { State } from "../state.ts";
import type {
AccessorPropertyNode,
ClassBodyNode,
MethodDefinitionNode,
PropertyDefinitionNode,
} from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print a class declaration or expression, decorators and heritage clauses included.
*
* A class expression is parenthesized where the statement or an `export default` starts with it,
* as `printFunction` does for functions.
*/
export function printClass(node: ESTree.Class, state: State): void {
let wrap = false;
if (node.type === "ClassExpression") {
debugAssertLastFresh(state);
// `CAT_START_OF_STMT` or `CAT_START_OF_DEFAULT_EXPORT`, which are adjacent - see `write.ts`
wrap = (state.last | 1) === CAT_START_OF_STMT;
}
if (wrap) write(state, "(", CAT_OTHER);
const { decorators } = node;
if (decorators != null && decorators.length > 0) printDecorators(decorators, state);
printSpaceBeforeIdentifier(state);
const declare = TS && node.declare;
const abstract = TS && node.abstract;
// The node's mapping goes on whichever of these is written first
if (declare) writeWithMap(state, "declare ", CAT_OTHER, node);
if (abstract) {
if (declare) {
write(state, "abstract ", CAT_OTHER);
} else {
writeWithMap(state, "abstract ", CAT_OTHER, node);
}
}
if (declare || abstract) {
write(state, "class", CAT_IDENT);
} else {
writeWithMap(state, "class", CAT_IDENT, node);
}
if (node.id != null) {
write(state, " ", CAT_OTHER);
writeWithMap(state, node.id.name, CAT_IDENT, node.id);
}
if (TS) printTypeParameters(node.typeParameters, state);
if (node.superClass != null) {
write(state, " extends ", CAT_OTHER);
printExpression(node.superClass, state, PREC_POSTFIX, CTX_NONE);
if (TS) printTypeArguments(node.superTypeArguments, state);
}
const { implements: implementsClauses } = node;
if (TS && implementsClauses != null && implementsClauses.length > 0) {
write(state, " implements ", CAT_OTHER);
const { length } = implementsClauses;
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const clause = implementsClauses[i];
printExpression(clause.expression, state, PREC_CALL, CTX_NONE);
printTypeArguments(clause.typeArguments, state);
}
}
write(state, " ", CAT_OTHER);
printClassBody(node.body, state);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Print a run of decorators, one per line, parenthesizing any whose expression needs it.
*/
export function printDecorators(decorators: ESTree.Decorator[], state: State): void {
const { length } = decorators;
for (let i = 0; i < length; i++) {
const decorator = decorators[i];
writeWithMap(state, "@", CAT_OTHER, decorator);
const { expression } = decorator;
const wrap = decoratorNeedsWrap(expression);
if (wrap) write(state, "(", CAT_OTHER);
printExpression(expression, state, PREC_LOWEST, CTX_NONE);
if (wrap) write(state, ")", CAT_OTHER);
write(state, " ", CAT_OTHER);
}
}
/**
* Whether a decorator's expression has to be parenthesized.
*
* The grammar allows a name, a chain of plain member accesses, and a call on either, and nothing
* else - so a computed access needs parentheses even though a static one does not.
*/
function decoratorNeedsWrap(expr: ESTree.Expression): boolean {
for (;;) {
switch (expr.type) {
case "Identifier":
return false;
case "MemberExpression":
// `@a.b` / `@this.b` / `@x!.y` need no parens; computed access does
return expr.computed;
case "CallExpression":
expr = expr.callee;
break;
default:
return true;
}
}
}
/**
* Print the members of a class in braces, empty ones tight.
*/
function printClassBody(node: ClassBodyNode, state: State): void {
const { body } = node;
const { length } = body;
if (length === 0) {
writeWithMap(state, "{}", CAT_OTHER, node);
return;
}
writeWithMap(state, "{\n", CAT_OTHER, node);
state.indentLevel++;
for (let i = 0; i < length; i++) {
printIndent(state);
const element = body[i];
switch (element.type) {
case "MethodDefinition":
/* IF TS */
case "TSAbstractMethodDefinition":
/* END_IF */
printMethodDefinition(element, state);
write(state, "\n", CAT_OTHER);
break;
case "PropertyDefinition":
/* IF TS */
case "TSAbstractPropertyDefinition":
/* END_IF */
printPropertyDefinition(element, state);
write(state, ";\n", CAT_OTHER);
break;
case "StaticBlock":
printStaticBlock(element, state);
write(state, "\n", CAT_OTHER);
break;
case "AccessorProperty":
/* IF TS */
case "TSAbstractAccessorProperty":
/* END_IF */
printAccessorProperty(element, state);
write(state, ";\n", CAT_OTHER);
break;
/* IF TS */
case "TSIndexSignature":
printTSIndexSignature(element, state);
write(state, ";\n", CAT_OTHER);
break;
/* END_IF */
default:
throw new Error(`Unknown class element type: ${element.type}`);
}
}
state.indentLevel--;
printIndent(state);
write(state, "}", CAT_OTHER);
}
/**
* Print a method, including getters, setters, constructors and their modifiers.
*/
function printMethodDefinition(node: MethodDefinitionNode, state: State): void {
const { decorators } = node;
if (decorators != null && decorators.length > 0) printDecorators(decorators, state);
if (TS && node.accessibility != null) {
printSpaceBeforeIdentifier(state);
writeNoLast(state, node.accessibility);
write(state, " ", CAT_OTHER);
}
if (TS && (node.type === "TSAbstractMethodDefinition" || node.abstract)) {
printSpaceBeforeIdentifier(state);
write(state, "abstract ", CAT_OTHER);
}
if (node.static) {
printSpaceBeforeIdentifier(state);
write(state, "static ", CAT_OTHER);
}
if (TS && node.override) {
printSpaceBeforeIdentifier(state);
write(state, "override ", CAT_OTHER);
}
const { kind } = node;
if (kind === "get") {
printSpaceBeforeIdentifier(state);
write(state, "get ", CAT_OTHER);
} else if (kind === "set") {
printSpaceBeforeIdentifier(state);
write(state, "set ", CAT_OTHER);
}
const func = node.value;
if (func.async) {
printSpaceBeforeIdentifier(state);
write(state, "async ", CAT_OTHER);
}
if (func.generator) write(state, "*", CAT_OTHER);
if (node.computed) {
write(state, "[", CAT_OTHER);
typeAssertIs<ESTree.Expression>(node.key);
printExpression(node.key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printPropertyKey(node.key, state);
}
if (TS && node.optional) write(state, "?", CAT_QUESTION);
if (TS) printTypeParameters(func.typeParameters, state);
printParenParams(func.params, state);
if (TS && func.returnType != null) {
printTypeAnnotation(func.returnType, state);
}
if (func.body != null) {
write(state, " ", CAT_OTHER);
printFunctionBody(func.body, state);
} else {
write(state, ";", CAT_OTHER);
}
}
/**
* Print a class field, with its modifiers and initializer.
*/
function printPropertyDefinition(node: PropertyDefinitionNode, state: State): void {
const { decorators } = node;
if (decorators != null && decorators.length > 0) printDecorators(decorators, state);
if (TS && node.declare) {
printSpaceBeforeIdentifier(state);
write(state, "declare ", CAT_OTHER);
}
if (TS && node.accessibility != null) {
printSpaceBeforeIdentifier(state);
writeNoLast(state, node.accessibility);
write(state, " ", CAT_OTHER);
}
if (TS && (node.type === "TSAbstractPropertyDefinition" || node.abstract)) {
printSpaceBeforeIdentifier(state);
write(state, "abstract ", CAT_OTHER);
}
if (node.static) {
printSpaceBeforeIdentifier(state);
write(state, "static ", CAT_OTHER);
}
if (TS && node.override) {
printSpaceBeforeIdentifier(state);
write(state, "override ", CAT_OTHER);
}
if (TS && node.readonly) {
printSpaceBeforeIdentifier(state);
write(state, "readonly ", CAT_OTHER);
}
if (node.computed) {
write(state, "[", CAT_OTHER);
typeAssertIs<ESTree.Expression>(node.key);
printExpression(node.key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printPropertyKey(node.key, state);
}
if (TS && node.optional) write(state, "?", CAT_QUESTION);
if (TS && node.definite) write(state, "!", CAT_OP_UN_NOT);
if (TS && node.typeAnnotation != null) printTypeAnnotation(node.typeAnnotation, state);
if (node.value != null) {
write(state, " = ", CAT_OTHER);
printExpression(node.value, state, PREC_COMMA, CTX_NONE);
}
}
/**
* Print a `static { … }` block.
*
* Its body has no directive prologue, unlike a function body, so a string statement at the top of
* one needs no protection from being read back as a directive.
*/
function printStaticBlock(node: ESTree.StaticBlock, state: State): void {
printSpaceBeforeIdentifier(state);
writeWithMap(state, "static ", CAT_OTHER, node);
const { body } = node;
const { length } = body;
if (length === 0) {
write(state, "{}", CAT_OTHER);
return;
}
write(state, "{\n", CAT_OTHER);
state.indentLevel++;
for (let i = 0; i < length; i++) {
printStatement(body[i], state);
}
state.indentLevel--;
printIndent(state);
write(state, "}", CAT_OTHER);
}
/**
* Print an `accessor` field.
*/
function printAccessorProperty(node: AccessorPropertyNode, state: State): void {
const { decorators } = node;
if (decorators != null && decorators.length > 0) printDecorators(decorators, state);
if (TS && (node.type === "TSAbstractAccessorProperty" || node.abstract)) {
printSpaceBeforeIdentifier(state);
write(state, "abstract ", CAT_OTHER);
}
if (TS && node.accessibility != null) {
printSpaceBeforeIdentifier(state);
writeNoLast(state, node.accessibility);
write(state, " ", CAT_OTHER);
}
if (node.static) {
printSpaceBeforeIdentifier(state);
write(state, "static ", CAT_OTHER);
}
if (TS && node.override) {
printSpaceBeforeIdentifier(state);
write(state, "override ", CAT_OTHER);
}
printSpaceBeforeIdentifier(state);
writeWithMap(state, "accessor", CAT_IDENT, node);
if (node.computed) {
write(state, " [", CAT_OTHER);
typeAssertIs<ESTree.Expression>(node.key);
printExpression(node.key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
write(state, " ", CAT_OTHER);
printPropertyKey(node.key, state);
}
if (TS && node.definite) write(state, "!", CAT_OP_UN_NOT);
if (TS && node.typeAnnotation != null) printTypeAnnotation(node.typeAnnotation, state);
if (node.value != null) {
write(state, " = ", CAT_OTHER);
printExpression(node.value, state, PREC_COMMA, CTX_NONE);
}
}
+949
View File
@@ -0,0 +1,949 @@
// Expressions.
import { typeAssertIs } from "../asserts.ts";
import { printAssignmentTarget } from "./assignment_target.ts";
import { printBinaryish } from "./binary.ts";
import { printPropertyKey } from "./binding_pattern.ts";
import {
CAT_IDENT,
CAT_INT_DIGIT,
CAT_LT,
CAT_OP_UN_NOT,
CAT_OP_UN_NOT_AFTER_LT,
CAT_OTHER,
CAT_QUESTION,
CAT_START_OF_ARROW_EXPR,
CAT_START_OF_STMT,
} from "./write.ts";
import { printClass } from "./class.ts";
import {
printFunction,
printFunctionBody,
printParenParams,
printParenParamsArrow,
} from "./function.ts";
import { printSpaceBeforeIdentifier, printSpaceBeforeOperator } from "./space.ts";
import { printIndent } from "./indent.ts";
import { printJSXElement, printJSXFragment } from "./jsx.ts";
import { printLiteral } from "./literal.ts";
import {
CTX_FORBID_CALL,
CTX_FORBID_IN,
CTX_NONE,
PADDED_ASSIGN_OPERATORS,
unaryOperatorCode,
updateOperatorCode,
} from "./operators.ts";
import { withoutParens } from "./parens.ts";
import {
PREC_ASSIGN,
PREC_CALL,
PREC_COMMA,
PREC_COMPARE,
PREC_CONDITIONAL,
PREC_EQUALS,
PREC_EXPONENTIATION,
PREC_LOWEST,
PREC_NEW,
PREC_POSTFIX,
PREC_PREFIX,
PREC_YIELD,
} from "./precedence.ts";
import {
debugAssertLastFresh,
write,
writeNoLast,
writeWithMap,
writeWithMapNoLast,
} from "./write.ts";
import { escapeScriptCloseTag } from "./string.ts";
import {
printTSAsOrSatisfiesExpression,
printTSTypeAssertion,
printTypeAnnotation,
printTypeArguments,
printTypeParameters,
} from "./typescript.ts";
import type { State } from "../state.ts";
import type { UnknownNode } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Dispatch point for expressions. The switch writes the one-token constructs inline
* and hands everything else to the printer for that node type, throwing on a type it does not know.
*
* Parens are not preserved from the input - every printer re-derives its own from `precedence`,
* so a `ParenthesizedExpression` mostly prints straight through.
*
* @param precedence - Precedence of the position this expression sits in.
* A printer compares its own precedence against it and parenthesizes itself when it would otherwise bind too loosely.
* @param ctx - `CTX_FORBID_IN` / `CTX_FORBID_CALL` flags describing the enclosing position, so that an `in` operator
* inside a `for` head, or a call under a `new`, knows to parenthesize itself.
*/
export function printExpression(
node: ESTree.Expression | UnknownNode,
state: State,
precedence: number,
ctx: number,
): void {
// Arms are ordered roughly in order of most common nodes.
// V8 turns this into (essentially) as chain of `if ... else if ... else if...`,
// so making common nodes short-circuit early is a large perf boost.
switch (node.type) {
case "Identifier":
printSpaceBeforeIdentifier(state);
writeWithMap(state, node.name, CAT_IDENT, node);
break;
case "MemberExpression":
printMemberExpression(node, state, ctx);
break;
case "CallExpression":
printCallExpression(node, state, precedence, ctx);
break;
case "Literal":
printLiteral(node, state, precedence, ctx);
break;
case "BinaryExpression":
if (node.left.type === "PrivateIdentifier") {
typeAssertIs<ESTree.PrivateInExpression>(node);
printPrivateInExpression(node, state, precedence);
} else {
typeAssertIs<ESTree.BinaryExpression>(node);
printBinaryish(node, state, precedence, ctx);
}
break;
case "LogicalExpression":
printBinaryish(node, state, precedence, ctx);
break;
case "ObjectExpression":
printObjectExpression(node, state);
break;
case "ArrayExpression":
printArrayExpression(node, state);
break;
case "AssignmentExpression":
printAssignmentExpression(node, state, precedence, ctx);
break;
case "UpdateExpression":
printUpdateExpression(node, state, precedence, ctx);
break;
case "UnaryExpression":
printUnaryExpression(node, state, precedence, ctx);
break;
case "ConditionalExpression":
printConditionalExpression(node, state, precedence, ctx);
break;
case "SequenceExpression":
printSequenceExpression(node, state, precedence, ctx);
break;
case "ArrowFunctionExpression":
printArrowFunctionExpression(node, state, precedence, ctx);
break;
case "FunctionExpression":
printFunction(node, state);
break;
case "ThisExpression":
printSpaceBeforeIdentifier(state);
writeWithMap(state, "this", CAT_IDENT, node);
break;
case "Super":
printSpaceBeforeIdentifier(state);
writeWithMap(state, "super", CAT_IDENT, node);
break;
case "NewExpression":
printNewExpression(node, state, precedence);
break;
case "TemplateLiteral":
printTemplateLiteral(node, state);
break;
case "TaggedTemplateExpression":
printExpression(node.tag, state, PREC_POSTFIX, ctx & CTX_FORBID_CALL);
if (TS) printTypeArguments(node.typeArguments, state);
printTemplateLiteral(node.quasi, state);
break;
case "ClassExpression":
printClass(node, state);
break;
case "AwaitExpression":
printAwaitExpression(node, state, precedence, ctx);
break;
case "YieldExpression":
printYieldExpression(node, state, precedence);
break;
case "ImportExpression":
printImportExpression(node, state, precedence, ctx);
break;
case "MetaProperty":
printSpaceBeforeIdentifier(state);
writeWithMapNoLast(state, node.meta.name, node);
writeNoLast(state, ".");
write(state, node.property.name, CAT_IDENT);
break;
case "ChainExpression":
printChainExpression(node, state, precedence, ctx);
break;
case "ParenthesizedExpression": {
// Parens around function/arrow expressions are preserved (Oxc `pife`)
const { expression } = node;
const inner = withoutParens(expression);
if (inner.type === "FunctionExpression" || inner.type === "ArrowFunctionExpression") {
write(state, "(", CAT_OTHER);
printExpression(inner, state, PREC_LOWEST, CTX_NONE);
write(state, ")", CAT_OTHER);
} else {
printExpression(expression, state, precedence, ctx);
}
break;
}
case "JSXElement":
printJSXElement(node, state);
break;
case "JSXFragment":
printJSXFragment(node, state);
break;
/* IF TS */
case "TSAsExpression":
case "TSSatisfiesExpression":
printTSAsOrSatisfiesExpression(node, state, precedence, ctx);
break;
case "TSNonNullExpression":
printExpression(node.expression, state, PREC_POSTFIX, ctx);
write(state, "!", CAT_OP_UN_NOT);
break;
case "TSInstantiationExpression":
printExpression(node.expression, state, PREC_PREFIX, ctx);
printTypeArguments(node.typeArguments, state);
break;
case "TSTypeAssertion":
printTSTypeAssertion(node, state, precedence, ctx);
break;
/* END_IF */
default:
throw new Error(`Unknown expression type: ${node.type}`);
}
}
/**
* The object is printed at `PREC_POSTFIX` and only `CTX_FORBID_CALL` is carried into it, so a member chain
* beneath a `new` keeps that restriction while a computed property, printed from `PREC_LOWEST`, starts clean.
*
* Two shapes need more than precedence to stay valid:
* 1. A bare `let` as the object.
* 2. Member access straight onto an integer literal.
*/
export function printMemberExpression(
node: ESTree.MemberExpression,
state: State,
ctx: number,
): void {
const { object } = node;
if (node.computed) {
// `(let)[0]` - a bare `let` object must be wrapped
const inner = withoutParens(object);
const wrap = inner.type === "Identifier" && inner.name === "let";
if (wrap) write(state, "(", CAT_OTHER);
printExpression(object, state, PREC_POSTFIX, ctx & CTX_FORBID_CALL);
if (wrap) write(state, ")", CAT_OTHER);
if (node.optional) write(state, "?.", CAT_OTHER);
write(state, "[", CAT_OTHER);
printExpression(node.property, state, PREC_LOWEST, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printExpression(object, state, PREC_POSTFIX, ctx & CTX_FORBID_CALL);
if (node.optional) {
write(state, "?", CAT_QUESTION);
} else {
debugAssertLastFresh(state);
// `0.toExponential()` is invalid; `0 .toExponential()` is valid
if (state.last === CAT_INT_DIGIT) write(state, " ", CAT_OTHER);
}
write(state, ".", CAT_OTHER);
const { property } = node;
if (property.type === "PrivateIdentifier") {
writeWithMapNoLast(state, "#", property);
write(state, property.name, CAT_IDENT);
} else {
writeWithMap(state, property.name, CAT_IDENT, property);
}
}
}
/**
* A call parenthesizes itself from `PREC_NEW` upwards, or whenever `CTX_FORBID_CALL` is set,
* both of which mean an unparenthesized argument list would be read as belonging to an enclosing `new`.
*
* Writing that paren also re-marks the start of a statement or of an `export default`,
* so the callee inside the parens is still read as the leftmost token of the construct.
*/
function printCallExpression(
node: ESTree.CallExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_NEW || (ctx & CTX_FORBID_CALL) !== 0;
if (wrap) {
// The mark read below is the one from before the paren, so it is checked here -
// `writeNoLast` marks `last` stale in between, and the last line clears that again
debugAssertLastFresh(state);
// A statement or an `export default` mark is left intact.
// A concise arrow body's mark is deliberately left to die at the paren, as `oxc_codegen` does.
writeNoLast(state, "(");
// `CAT_START_OF_STMT` or `CAT_START_OF_DEFAULT_EXPORT`, which are adjacent - see `write.ts`
if ((state.last | 1) !== CAT_START_OF_STMT) state.last = CAT_OTHER;
if (DEBUG) state.lastIsStale = false;
}
printExpression(node.callee, state, PREC_POSTFIX, CTX_NONE);
if (node.optional) write(state, "?.", CAT_OTHER);
if (TS) printTypeArguments(node.typeArguments, state);
printArguments(node.arguments, state);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* The argument list shared by `printCallExpression` and `printNewExpression`.
*
* Arguments print at `PREC_COMMA`, so a sequence expression parenthesizes itself instead of reading as more arguments.
*/
function printArguments(args: ESTree.Argument[], state: State): void {
const { length } = args;
if (length === 0) {
write(state, "()", CAT_OTHER);
return;
}
write(state, "(", CAT_OTHER);
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const arg = args[i];
if (arg.type === "SpreadElement") {
write(state, "...", CAT_OTHER);
printExpression(arg.argument, state, PREC_COMMA, CTX_NONE);
} else {
printExpression(arg, state, PREC_COMMA, CTX_NONE);
}
}
write(state, ")", CAT_OTHER);
}
/**
* `#field in obj`, which arrives as a `BinaryExpression` with a `PrivateIdentifier` on the left
* rather than as a node type of its own - hence the extra test in `printExpression`.
*
* It sits at the `in` operator's own level, so it wraps from `PREC_COMPARE` upwards, and the right
* operand prints one level tighter with `CTX_FORBID_IN` set.
*/
export function printPrivateInExpression(
node: ESTree.PrivateInExpression,
state: State,
precedence: number,
): void {
const wrap = precedence >= PREC_COMPARE;
if (wrap) write(state, "(", CAT_OTHER);
writeWithMapNoLast(state, "#", node.left);
write(state, node.left.name, CAT_IDENT);
write(state, " in ", CAT_OTHER);
printExpression(node.right, state, PREC_EQUALS, CTX_FORBID_IN);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* An object literal parenthesizes itself when it starts a statement or a concise arrow body,
* where a leading `{` would otherwise be read as a block.
*
* Layout follows the property count alone - two or more break across lines, exactly one prints
* inside spaces on a single line, and none gives `{}`.
*/
function printObjectExpression(node: ESTree.ObjectExpression, state: State): void {
debugAssertLastFresh(state);
// `CAT_START_OF_STMT` or `CAT_START_OF_ARROW_EXPR`, which are adjacent - see `write.ts`
const wrap = ((state.last - 1) | 1) === CAT_START_OF_STMT;
if (wrap) write(state, "(", CAT_OTHER);
const { properties } = node;
const { length } = properties;
const isMultiLine = length > 1;
writeWithMap(state, "{", CAT_OTHER, node);
if (isMultiLine) {
state.indentLevel++;
for (let i = 0; i < length; i++) {
write(state, i > 0 ? ",\n" : "\n", CAT_OTHER);
printIndent(state);
printObjectProperty(properties[i], state);
}
write(state, "\n", CAT_OTHER);
state.indentLevel--;
printIndent(state);
} else if (length === 1) {
write(state, " ", CAT_OTHER);
printObjectProperty(properties[0], state);
write(state, " ", CAT_OTHER);
}
write(state, "}", CAT_OTHER);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* One entry of an object literal - a spread, a method or accessor, or a plain `key: value` pair.
*
* Shorthand and computed-ness are both worked out here rather than taken from the AST,
* so the output stays valid however a transform left those flags.
*/
function printObjectProperty(node: ESTree.ObjectPropertyKind, state: State): void {
if (node.type === "SpreadElement") {
writeWithMap(state, "...", CAT_OTHER, node);
printExpression(node.argument, state, PREC_COMMA, CTX_NONE);
return;
}
const { key, value } = node;
if (
value.type === "FunctionExpression" ||
(TS && value.type === "TSEmptyBodyFunctionExpression")
) {
const { kind } = node;
const isGetter = kind === "get";
const isAccessor = isGetter || kind === "set";
if (isAccessor) write(state, isGetter ? "get " : "set ", CAT_OTHER);
if (node.method || isAccessor) {
if (value.async) {
printSpaceBeforeIdentifier(state);
write(state, "async ", CAT_OTHER);
}
if (value.generator) write(state, "*", CAT_OTHER);
if (node.computed) {
write(state, "[", CAT_OTHER);
typeAssertIs<ESTree.Expression>(key);
printExpression(key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printPropertyKey(key, state);
}
if (TS) printTypeParameters(value.typeParameters, state);
printParenParams(value.params, state);
if (TS && value.returnType != null) printTypeAnnotation(value.returnType, state);
if (value.body != null) {
write(state, " ", CAT_OTHER);
printFunctionBody(value.body, state);
}
return;
}
}
// Shorthand is re-derived from names (matching Oxc), except `__proto__`
// which keeps its flag (`{ __proto__ }` and `{ __proto__: x }` differ).
let shorthand = false;
// The identifier a name-derived shorthand prints, which is known here without asking
// the expression printer what it is.
// A `__proto__` shorthand leaves this null, since its flag says nothing about the value it carries.
let shorthandIdentifier: ESTree.IdentifierReference | null = null;
if (!node.computed && key.type === "Identifier") {
if (key.name === "__proto__") {
shorthand = node.shorthand;
} else {
const inner = withoutParens(value);
if (inner.type === "Identifier" && key.name === inner.name) {
shorthand = true;
shorthandIdentifier = inner;
}
}
}
let { computed } = node;
// `{ -1: 0 }` and `{ 1/0: 0 }` must print as `{ [-1]: 0 }` / `{ [1 / 0]: 0 }`
if (
!computed &&
key.type === "Literal" &&
typeof key.value === "number" &&
(key.value < 0 || Object.is(key.value, -0) || !Number.isFinite(key.value))
) {
computed = true;
}
if (shorthand) {
if (shorthandIdentifier !== null) {
printSpaceBeforeIdentifier(state);
writeWithMap(state, shorthandIdentifier.name, CAT_IDENT, shorthandIdentifier);
} else {
// `__proto__` shorthand, whose value can be anything. Print through any parens around it.
printExpression(withoutParens(value), state, PREC_COMMA, CTX_NONE);
}
} else {
if (computed) {
write(state, "[", CAT_OTHER);
typeAssertIs<ESTree.Expression>(key);
printExpression(key, state, PREC_COMMA, CTX_NONE);
write(state, "]", CAT_OTHER);
} else {
printPropertyKey(key, state);
}
write(state, ": ", CAT_OTHER);
printExpression(value, state, PREC_COMMA, CTX_NONE);
}
}
/**
* Arrays break across lines from three elements upwards, where objects break from two.
*
* Holes print as nothing between the commas, and a hole in final position needs one further comma
* written after it, since `[a,]` holds one element and `[a,,]` holds two.
*/
function printArrayExpression(node: ESTree.ArrayExpression, state: State): void {
const { elements } = node;
const { length } = elements;
const isMultiLine = length > 2;
writeWithMap(state, "[", CAT_OTHER, node);
if (isMultiLine) state.indentLevel++;
for (let i = 0; i < length; i++) {
if (isMultiLine) {
write(state, i !== 0 ? ",\n" : "\n", CAT_OTHER);
printIndent(state);
} else if (i !== 0) {
write(state, ", ", CAT_OTHER);
}
const element = elements[i];
if (element != null) {
if (element.type === "SpreadElement") {
write(state, "...", CAT_OTHER);
printExpression(element.argument, state, PREC_COMMA, CTX_NONE);
} else {
printExpression(element, state, PREC_COMMA, CTX_NONE);
}
}
if (i === length - 1 && element == null) {
write(state, ",", CAT_OTHER);
}
}
if (isMultiLine) {
write(state, "\n", CAT_OTHER);
state.indentLevel--;
printIndent(state);
}
write(state, "]", CAT_OTHER);
}
/**
* Wraps from `PREC_ASSIGN` upwards. An object destructuring target forces parens as well at the start of
* a statement or a concise arrow body, where the leading `{` would be read as a block.
*
* @param ctx - Handed to the right operand unchanged, so a `for` head's `CTX_FORBID_IN`
* still reaches an `in` operator there.
*/
function printAssignmentExpression(
node: ESTree.AssignmentExpression,
state: State,
precedence: number,
ctx: number,
): void {
const { left } = node;
let wrap = precedence >= PREC_ASSIGN;
if (!wrap && left.type === "ObjectPattern") {
debugAssertLastFresh(state);
// `CAT_START_OF_STMT` or `CAT_START_OF_ARROW_EXPR`, which are adjacent - see `write.ts`
wrap = ((state.last - 1) | 1) === CAT_START_OF_STMT;
}
if (wrap) write(state, "(", CAT_OTHER);
printAssignmentTarget(left, state);
write(state, PADDED_ASSIGN_OPERATORS[node.operator], CAT_OTHER);
printExpression(node.right, state, PREC_COMMA, ctx);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Which precedence decides the parens depends on `node.prefix` - a prefix `++`/`--` binds at `PREC_PREFIX`,
* a postfix one at `PREC_POSTFIX`.
*
* The operator's own category is recorded as it is written, which is how an adjacent `+` or `-`
* knows to leave a space rather than glue into `---x`.
*/
function printUpdateExpression(
node: ESTree.UpdateExpression,
state: State,
precedence: number,
ctx: number,
): void {
const selfPrecedence = node.prefix ? PREC_PREFIX : PREC_POSTFIX;
const wrap = precedence >= selfPrecedence;
if (wrap) write(state, "(", CAT_OTHER);
const operatorCode = updateOperatorCode(node.operator);
if (node.prefix) {
printSpaceBeforeOperator(state, operatorCode);
writeWithMap(state, node.operator, operatorCode, node);
printExpression(node.argument, state, PREC_PREFIX, ctx);
} else {
printExpression(node.argument, state, PREC_POSTFIX, ctx);
printSpaceBeforeOperator(state, operatorCode);
write(state, node.operator, operatorCode);
}
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Wraps from `PREC_PREFIX` upwards and prints its argument at `PREC_EXPONENTIATION`,
* so a `**` operand takes parens of its own - `-a ** b` does not parse.
*
* A `!` written straight after a `<` records a category of its own, so that a `--`
* printed next is spaced off it and cannot complete `<!--`.
*/
function printUnaryExpression(
node: ESTree.UnaryExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_PREFIX;
if (wrap) write(state, "(", CAT_OTHER);
const { operator } = node;
let isDeleteInfinity = false;
if (operator.length > 1) {
// typeof, void, delete
printSpaceBeforeIdentifier(state);
writeWithMap(state, operator, CAT_IDENT, node);
write(state, " ", CAT_OTHER);
// `delete Infinity` is a syntax error in strict mode
isDeleteInfinity =
operator === "delete" && node.argument.type === "Literal" && node.argument.value === Infinity;
} else {
let operatorCode = unaryOperatorCode(operator);
printSpaceBeforeOperator(state, operatorCode);
debugAssertLastFresh(state);
if (operatorCode === CAT_OP_UN_NOT && state.last === CAT_LT) {
operatorCode = CAT_OP_UN_NOT_AFTER_LT;
}
writeWithMap(state, operator, operatorCode, node);
}
if (isDeleteInfinity) write(state, "(0, ", CAT_OTHER);
printExpression(node.argument, state, PREC_EXPONENTIATION, ctx);
if (isDeleteInfinity) write(state, ")", CAT_OTHER);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Wraps from `PREC_CONDITIONAL` upwards. Both branches print at `PREC_YIELD`, which admits
* a bare assignment or `yield` but still parenthesizes a comma sequence.
*
* The consequent is fenced by the `?` and `:` either side of it, so it alone never needs
* the `in` restriction passed down.
*/
function printConditionalExpression(
node: ESTree.ConditionalExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_CONDITIONAL;
// The parens the wrap adds are themselves enough to escape a `for` head,
// so `in` is only forbidden further down when the expression is printed bare
let innerCtx = 0;
if (wrap) {
write(state, "(", CAT_OTHER);
} else {
innerCtx = ctx & CTX_FORBID_IN;
}
// Keep an `as` or `satisfies` test grouped, by printing it at the precedence which makes it
// parenthesize itself. Without those parentheses a regexp consequent does not survive a
// round trip: `value as Type ? /x/ : y` re-lexes the `/` as a division.
let testPrecedence = PREC_CONDITIONAL;
if (TS) {
const testType = withoutParens(node.test).type;
if (testType === "TSAsExpression" || testType === "TSSatisfiesExpression") {
testPrecedence = PREC_COMPARE;
}
}
printExpression(node.test, state, testPrecedence, innerCtx);
write(state, " ? ", CAT_OTHER);
printExpression(node.consequent, state, PREC_YIELD, CTX_NONE);
write(state, " : ", CAT_OTHER);
printExpression(node.alternate, state, PREC_YIELD, innerCtx);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Wraps from `PREC_COMMA` upwards.
* `CTX_FORBID_CALL` is cleared for the elements, since every position which sets that flag
* also passes a precedence at or above `PREC_COMMA`, so the parens are already written by then.
*/
function printSequenceExpression(
node: ESTree.SequenceExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_COMMA;
if (wrap) write(state, "(", CAT_OTHER);
const innerCtx = ctx & ~CTX_FORBID_CALL;
const { expressions } = node;
const { length } = expressions;
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
printExpression(expressions[i], state, PREC_LOWEST, innerCtx);
}
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Wraps from `PREC_ASSIGN` upwards, and when it does the parens fence the body, so `CTX_FORBID_IN`
* is dropped on the way in.
*
* A concise body marks `last`, which is how an object literal body knows to parenthesize itself.
* A TS return type has to sit between the parameters and the arrow, so those are printed separately,
* instead of through `printParenParamsArrow`.
*/
function printArrowFunctionExpression(
node: ESTree.ArrowFunctionExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_ASSIGN;
const bodyCtx = wrap ? ctx & ~CTX_FORBID_IN : ctx;
if (wrap) write(state, "(", CAT_OTHER);
if (node.async) {
printSpaceBeforeIdentifier(state);
writeWithMap(state, "async ", CAT_OTHER, node);
}
if (TS) printTypeParameters(node.typeParameters, state);
const { returnType } = node;
if (TS && returnType != null) {
printParenParams(node.params, state);
printTypeAnnotation(returnType, state);
write(state, " => ", CAT_OTHER);
} else {
printParenParamsArrow(node.params, state);
}
const { body } = node;
if (body.type === "BlockStatement") {
printFunctionBody(body, state);
} else {
state.last = CAT_START_OF_ARROW_EXPR;
printExpression(body, state, PREC_COMMA, bodyCtx);
}
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Only a call position binds tighter than `new`, so this wraps from `PREC_CALL` upwards.
*
* The callee prints at `PREC_NEW` with `CTX_FORBID_CALL`, so a call inside it parenthesizes itself,
* rather than being taken for this `new`'s own argument list.
*/
function printNewExpression(node: ESTree.NewExpression, state: State, precedence: number): void {
const wrap = precedence >= PREC_CALL;
if (wrap) write(state, "(", CAT_OTHER);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "new ", CAT_OTHER, node);
printExpression(node.callee, state, PREC_NEW, CTX_FORBID_CALL);
if (TS) printTypeArguments(node.typeArguments, state);
printArguments(node.arguments, state);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Quasis and expressions interleave, with one more quasi than there are expressions, so the first chunk
* is written before the loop and each iteration ends with the chunk after its substitution.
*
* Substitutions print from `PREC_LOWEST` with no context flags, since `${` and `}` fence them from everything around.
*/
function printTemplateLiteral(node: ESTree.TemplateLiteral, state: State): void {
writeWithMapNoLast(state, "`", node);
const { quasis, expressions } = node;
const { length } = expressions;
const firstQuasi = quasis[0];
writeWithMapNoLast(state, templateQuasiRaw(firstQuasi), firstQuasi);
for (let i = 0; i < length; i++) {
write(state, "${", CAT_OTHER);
printExpression(expressions[i], state, PREC_LOWEST, CTX_NONE);
writeNoLast(state, "}");
const quasi = quasis[i + 1];
writeWithMapNoLast(state, templateQuasiRaw(quasi), quasi);
}
write(state, "`", CAT_OTHER);
}
/**
* The raw text of one template chunk, ready to be written between the backticks.
*
* It also runs the `</script` escape, since a template can hold that sequence verbatim
* and would otherwise close a surrounding script element.
*/
function templateQuasiRaw(quasi: ESTree.TemplateElement): string {
// Line terminators in template raws are normalized to LF at parse time.
// TS-ESLint's `raw` keeps the source bytes, so normalize here.
let { raw } = quasi.value;
if (raw.includes("\r")) raw = raw.replace(/\r\n?/g, "\n");
return escapeScriptCloseTag(raw);
}
/**
* Wraps from `PREC_PREFIX` upwards like the other unary operators, and prints its argument
* at `PREC_EXPONENTIATION` - `await a ** b` does not parse, so the `**` takes parens.
*/
function printAwaitExpression(
node: ESTree.AwaitExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_PREFIX;
if (wrap) write(state, "(", CAT_OTHER);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "await ", CAT_OTHER, node);
printExpression(node.argument, state, PREC_EXPONENTIATION, ctx);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Wraps from `PREC_ASSIGN` upwards. The argument prints at `PREC_YIELD`, one level below assignment,
* so a nested `yield` or an assignment stays bare while a comma sequence parenthesizes.
*/
function printYieldExpression(
node: ESTree.YieldExpression,
state: State,
precedence: number,
): void {
const wrap = precedence >= PREC_ASSIGN;
if (wrap) write(state, "(", CAT_OTHER);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "yield", CAT_IDENT, node);
if (node.delegate) write(state, "*", CAT_OTHER);
if (node.argument != null) {
write(state, " ", CAT_OTHER);
printExpression(node.argument, state, PREC_YIELD, CTX_NONE);
}
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* `import(...)` takes the same wrap rule as a call, since `new import("x")` would otherwise
* attach the argument list to the `new`.
*
* A `phase` prints as `import.<phase>(...)`, and import options follow the source as a second argument.
*/
function printImportExpression(
node: ESTree.ImportExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_NEW || (ctx & CTX_FORBID_CALL) !== 0;
if (wrap) write(state, "(", CAT_OTHER);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "import", CAT_IDENT, node);
if (node.phase != null) {
writeNoLast(state, ".");
write(state, node.phase, CAT_IDENT);
}
write(state, "(", CAT_OTHER);
printExpression(node.source, state, PREC_COMMA, CTX_NONE);
if (node.options != null) {
write(state, ", ", CAT_OTHER);
printExpression(node.options, state, PREC_COMMA, CTX_NONE);
}
write(state, ")", CAT_OTHER);
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* A `ChainExpression` only records how far an optional chain reaches, so with nothing pressing on it
* the node prints straight through with the outer precedence and context.
*
* From `PREC_POSTFIX` upwards, or under `CTX_FORBID_CALL`, the whole chain is parenthesized
* and printed from `PREC_LOWEST` - the chain has to stop at the paren, instead of running on
* into the enclosing member access or `new`.
*/
function printChainExpression(
node: ESTree.ChainExpression,
state: State,
precedence: number,
ctx: number,
): void {
const wrap = precedence >= PREC_POSTFIX || (ctx & CTX_FORBID_CALL) !== 0;
if (wrap) {
write(state, "(", CAT_OTHER);
printExpression(node.expression, state, PREC_LOWEST, CTX_NONE);
write(state, ")", CAT_OTHER);
} else {
printExpression(node.expression, state, precedence, ctx);
}
}
+167
View File
@@ -0,0 +1,167 @@
// Functions.
import { typeAssertIs } from "../asserts.ts";
import { printBindingPattern } from "./binding_pattern.ts";
import {
CAT_IDENT,
CAT_OTHER,
CAT_START_OF_STMT,
debugAssertLastFresh,
write,
writeNoLast,
writeWithMap,
} from "./write.ts";
import { printDecorators } from "./class.ts";
import { printSpaceBeforeIdentifier } from "./space.ts";
import { printIndent } from "./indent.ts";
import { printDirectivesAndStatements } from "./statement.ts";
import { printTypeAnnotation, printTypeParameters } from "./typescript.ts";
import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print a function declaration or expression, from `async` through to the closing brace of its body.
*
* A function expression is parenthesized where the statement or an `export default` starts with it,
* since it would otherwise be read as a declaration.
*/
export function printFunction(node: ESTree.Function, state: State): void {
let wrap = false;
if (node.type === "FunctionExpression") {
debugAssertLastFresh(state);
// `CAT_START_OF_STMT` or `CAT_START_OF_DEFAULT_EXPORT`, which are adjacent - see `write.ts`
wrap = (state.last | 1) === CAT_START_OF_STMT;
}
if (wrap) write(state, "(", CAT_OTHER);
printSpaceBeforeIdentifier(state);
// The node's mapping goes on whichever of these is written first
const declare = TS && node.declare;
if (declare) {
writeWithMap(state, "declare ", CAT_OTHER, node);
write(state, node.async ? "async function" : "function", CAT_IDENT);
} else {
writeWithMap(state, node.async ? "async function" : "function", CAT_IDENT, node);
}
if (node.generator) write(state, "* ", CAT_OTHER);
if (node.id != null) {
printSpaceBeforeIdentifier(state);
writeWithMap(state, node.id.name, CAT_IDENT, node.id);
}
if (TS) printTypeParameters(node.typeParameters, state);
printParenParams(node.params, state);
if (TS && node.returnType != null) printTypeAnnotation(node.returnType, state);
if (node.body != null) {
write(state, " ", CAT_OTHER);
printFunctionBody(node.body, state);
} else {
write(state, ";", CAT_OTHER);
}
if (wrap) write(state, ")", CAT_OTHER);
}
/**
* Print a parameter list in parentheses.
*/
export function printParenParams(params: ESTree.ParamPattern[], state: State): void {
// `(params)`, as a single write when there are none
if (params.length === 0) {
write(state, "()", CAT_OTHER);
return;
}
write(state, "(", CAT_OTHER);
printParams(params, state);
write(state, ")", CAT_OTHER);
}
/**
* Print the parameters themselves, without the parentheses around them.
*
* TypeScript parameter properties carry the modifiers which make them a property as well as a
* parameter, and decorators can appear on either kind.
*/
function printParams(params: ESTree.ParamPattern[], state: State): void {
const { length } = params;
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const param = params[i];
const { decorators } = param;
if (decorators != null && decorators.length > 0) printDecorators(decorators, state);
if (TS && param.type === "TSParameterProperty") {
if (param.accessibility != null) {
printSpaceBeforeIdentifier(state);
writeNoLast(state, param.accessibility);
write(state, " ", CAT_OTHER);
}
if (param.override) {
printSpaceBeforeIdentifier(state);
write(state, "override ", CAT_OTHER);
}
if (param.readonly) {
printSpaceBeforeIdentifier(state);
write(state, "readonly ", CAT_OTHER);
}
printBindingPattern(param.parameter, state);
} else {
// The `TS &&` above defeats narrowing; `TSParameterProperty` was excluded by the check
typeAssertIs<Exclude<typeof param, ESTree.TSParameterProperty>>(param);
printBindingPattern(param, state);
}
}
}
/**
* Print a function body in braces, empty ones tight.
*
* A function body has a directive prologue, so it goes through the same printer as a program does,
* rather than through the block statement printer.
*/
export function printFunctionBody(body: ESTree.FunctionBody, state: State): void {
// `body` is a BlockStatement holding directives + statements.
const statements = body.body;
if (statements.length === 0) {
writeWithMap(state, "{}", CAT_OTHER, body);
return;
}
writeWithMap(state, "{\n", CAT_OTHER, body);
state.indentLevel++;
printDirectivesAndStatements(statements, state);
state.indentLevel--;
printIndent(state);
write(state, "}", CAT_OTHER);
}
/**
* Print an arrow function's parameter list along with its `=>`.
*
* Oxc keeps the parentheses even around a lone parameter, so there is no single-parameter form.
*/
export function printParenParamsArrow(params: ESTree.ParamPattern[], state: State): void {
// `(params) => `, as a single write when there are none
if (params.length === 0) {
write(state, "() => ", CAT_OTHER);
return;
}
write(state, "(", CAT_OTHER);
printParams(params, state);
write(state, ") => ", CAT_OTHER);
}
+54
View File
@@ -0,0 +1,54 @@
// Indentation.
//
// One level's worth of indentation is a string, and the strings for each level are cached,
// since the same handful of levels are printed over and over.
//
// The cache is not this build's. It is created once per process alongside `State`, which carries it here,
// so the levels one build has grown are there for the next one - see `state.ts`.
import { CAT_OTHER, write } from "./write.ts";
import type { State } from "../state.ts";
/**
* Write the indentation for the current level, or the pending single space in place of it.
*
* Comment printing and inline statement bodies ask for a space where an indent would go,
* which keeps `if (x) foo()` on one line.
*/
export function printIndent(state: State): void {
if (state.pendingIndentAsSpace) {
write(state, " ", CAT_OTHER);
state.pendingIndentAsSpace = false;
return;
}
const level = state.indentLevel;
if (level > 0) {
// Indentation is validated to contain only spaces and tabs, neither of which any reader
// of `last` distinguishes, so the category is a constant
const { indents } = state;
write(state, level < indents.length ? indents[level] : growIndents(state, level), CAT_OTHER);
}
}
/**
* Extend the indent cache up to `level` and return the indent for it.
*
* Separate from `printIndent` so the cached lookup there stays small enough to inline.
*/
function growIndents(state: State, level: number): string {
const { indents, indentString } = state;
let { length } = indents;
let indent = indents[length - 1];
for (; length <= level; length++) {
indent += indentString;
// Force the cons string flat.
// That costs here, but it is appended to the output many times afterwards.
indent.charCodeAt(0);
indents.push(indent);
}
return indent;
}
+51
View File
@@ -0,0 +1,51 @@
// A fast JavaScript code generator from an ESTree-compliant AST.
//
// A faithful port of Oxc's Rust `oxc_codegen` crate (pretty-printing mode).
// Output is byte-identical to `oxc_codegen` with default options (tab indentation, double quotes, comments off).
//
// The printer is one function per construct, split across this directory by area.
// `printStatement` in `statement.ts` and `printExpression` in `expression.ts` are the two dispatch
// points the rest hangs off.
//
// Reference: `oxc/crates/oxc_codegen/src/{gen.rs,lib.rs,str.rs,binary_expr_visitor.rs}`
import { debugAssert, typeAssertIs } from "../asserts.ts";
import { emitMappings } from "./source_map.ts";
import { printProgram, printStatement } from "./statement.ts";
import type { Options } from "./options.ts";
import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print `node`, returning the generated code.
*
* One of these exists per build. The package entry point picks the build and calls it -
* callers of the package never reach this directly.
*
* @param state - Created by the entry point, so that all 4 builds share one class and so one object shape
* @param options - The same options `state` was created from, for the parts only this build acts on
* @returns Generated code
*/
export function printSync(node: ESTree.Node, state: State, options: Options): string {
if (node.type === "Program") {
printProgram(node, state);
} else {
typeAssertIs<ESTree.Statement>(node);
printStatement(node, state);
}
// This is removed by minifier in non-sourcemap builds
if (SOURCEMAPS) {
debugAssert(
options.sourceMap != null,
"`options.sourceMap` should be defined when `SOURCEMAPS` is `true`",
);
emitMappings(state, options.sourceMap);
}
return state.output;
}
export type { Mapping, Options, Position, SourceMapGenerator } from "./options.ts";
+213
View File
@@ -0,0 +1,213 @@
// JSX.
// Port of `gen.rs` JSX section.
import { CAT_OTHER, write, writeNoLast, writeWithMap, writeWithMapNoLast } from "./write.ts";
import { printExpression } from "./expression.ts";
import { CTX_NONE } from "./operators.ts";
import { PREC_COMMA, PREC_LOWEST } from "./precedence.ts";
import { printTypeArguments } from "./typescript.ts";
import type { State } from "../state.ts";
import type { UnknownNode } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print a JSX element, its attributes and its children.
*
* An element with no closing element is self-closing and gets ` />`, which is also the only place
* a space is required before the slash.
*/
export function printJSXElement(node: ESTree.JSXElement, state: State): void {
const { openingElement } = node;
writeWithMapNoLast(state, "<", openingElement);
printJSXElementName(openingElement.name, state);
if (TS) printTypeArguments(openingElement.typeArguments, state);
const { attributes } = openingElement;
const { length } = attributes;
for (let i = 0; i < length; i++) {
writeNoLast(state, " ");
const attribute = attributes[i];
if (attribute.type === "JSXSpreadAttribute") {
write(state, "{...", CAT_OTHER);
printExpression(attribute.argument, state, PREC_COMMA, CTX_NONE);
writeNoLast(state, "}");
} else {
printJSXAttribute(attribute, state);
}
}
const { closingElement } = node;
if (closingElement == null) {
write(state, " />", CAT_OTHER);
return;
}
writeNoLast(state, ">");
const { children } = node;
const childCount = children.length;
for (let i = 0; i < childCount; i++) {
printJSXChild(children[i], state);
}
writeWithMapNoLast(state, "</", closingElement);
printJSXElementName(closingElement.name, state);
write(state, ">", CAT_OTHER);
}
/**
* Print the name in an opening or closing tag - a name, a namespaced name, a member chain, or `this`.
*/
function printJSXElementName(
node: ESTree.JSXElementName | ESTree.ThisExpression | UnknownNode,
state: State,
): void {
switch (node.type) {
case "JSXIdentifier":
writeWithMapNoLast(state, node.name, node);
break;
case "JSXMemberExpression":
printJSXElementName(node.object, state);
writeNoLast(state, ".");
printJSXElementName(node.property, state);
break;
case "JSXNamespacedName":
writeWithMapNoLast(state, node.namespace.name, node);
writeNoLast(state, ":");
writeNoLast(state, node.name.name);
break;
case "ThisExpression":
writeWithMapNoLast(state, "this", node);
break;
default:
throw new Error(`Unknown JSX name type: ${node.type}`);
}
}
/**
* Print one attribute, with its value where it has one - a bare attribute is `true`.
*/
function printJSXAttribute(node: ESTree.JSXAttribute, state: State): void {
// Attribute names never update `last`.
// Everything which can follow one - `=`, the next attribute's leading space, `>`, ` />` -
// is punctuation needing no separation from what precedes it, and nothing between here
// and the next real write reads `last`.
const { name } = node;
if (name.type === "JSXNamespacedName") {
writeWithMapNoLast(state, name.namespace.name, name);
writeNoLast(state, ":");
writeNoLast(state, name.name.name);
} else {
writeWithMapNoLast(state, name.name, name);
}
const { value } = node;
if (value != null) {
writeNoLast(state, "=");
printJSXAttributeValue(value, state);
}
}
/**
* Print an attribute's value.
*
* A string value is printed from its raw text, not through the string escaper, because JSX attribute strings
* have no escape sequences - the quote is picked to suit the contents instead.
*/
function printJSXAttributeValue(node: ESTree.JSXAttributeValue | UnknownNode, state: State): void {
switch (node.type) {
case "Literal": {
// JSX strings have no escape sequences and HTML entities are not decoded by parser at present.
// Print the raw source text (matching Oxc), choosing only the quote.
//
// Once HTML entities are decoded (which they should be), this check stops being enough -
// `<Foo bar="'&quot;" />` decodes to a value holding both quote characters, which no choice
// of quote mark can contain, so the text will have to be escaped.
// The cases in `test/print.test.ts` pin the current output, so they will fail the moment
// the parser starts decoding, rather than the change slipping through here unnoticed.
const { raw } = node;
const text = raw != null ? raw.slice(1, -1) : String(node.value);
const quote = text.includes('"') ? "'" : '"';
writeWithMapNoLast(state, quote, node);
writeNoLast(state, text);
writeNoLast(state, quote);
break;
}
case "JSXExpressionContainer":
printJSXExpressionContainer(node, state);
break;
case "JSXElement":
printJSXElement(node, state);
break;
case "JSXFragment":
printJSXFragment(node, state);
break;
default:
throw new Error(`Unknown JSX attribute value type: ${node.type}`);
}
}
/**
* Print a `{ … }` container, which may hold nothing but a comment.
*/
function printJSXExpressionContainer(node: ESTree.JSXExpressionContainer, state: State): void {
write(state, "{", CAT_OTHER);
if (node.expression.type !== "JSXEmptyExpression") {
printExpression(node.expression, state, PREC_LOWEST, CTX_NONE);
}
// `}` needs no category. What follows it in either caller context (attribute value, child)
// is punctuation or JSX text, none of which needs separating from it, and nothing reads
// `last` before the next real write.
writeNoLast(state, "}");
}
/**
* Print a `<>...</>` fragment.
*/
export function printJSXFragment(node: ESTree.JSXFragment, state: State): void {
writeWithMapNoLast(state, "<>", node.openingFragment);
const { children } = node;
const { length } = children;
for (let i = 0; i < length; i++) {
printJSXChild(children[i], state);
}
writeWithMap(state, "</>", CAT_OTHER, node.closingFragment);
}
/**
* Print one child of an element or fragment.
*
* Text is written from its raw source, since whitespace between elements is significant
* and any entities in it must survive untouched.
*/
function printJSXChild(node: ESTree.JSXChild | UnknownNode, state: State): void {
switch (node.type) {
case "JSXText":
writeWithMapNoLast(state, node.raw != null ? node.raw : node.value, node);
break;
case "JSXExpressionContainer":
printJSXExpressionContainer(node, state);
break;
case "JSXElement":
printJSXElement(node, state);
break;
case "JSXFragment":
printJSXFragment(node, state);
break;
case "JSXSpreadChild":
write(state, "{...", CAT_OTHER);
printExpression(node.expression, state, PREC_LOWEST, CTX_NONE);
writeNoLast(state, "}");
break;
default:
throw new Error(`Unknown JSX child type: ${node.type}`);
}
}
+201
View File
@@ -0,0 +1,201 @@
// Literals.
import { typeAssertIs } from "../asserts.ts";
import {
CAT_IDENT,
CAT_INT_DIGIT,
CAT_OP_UN_NEG,
CAT_OTHER,
CAT_REGEX_SLASH,
write,
writeNoLast,
writeWithMap,
writeWithMapNoLast,
} from "./write.ts";
import { printSpaceBeforeIdentifier, printSpaceBeforeOperator } from "./space.ts";
import { printNonNegativeFloat } from "./number.ts";
import { CTX_TYPESCRIPT } from "./operators.ts";
import { PREC_PREFIX } from "./precedence.ts";
import { printString } from "./string.ts";
import type { State } from "../state.ts";
import type { LiteralExtras } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* The literal types, which all share `type: "Literal"` and are told apart by `typeof value`.
*/
type LiteralNode =
| ESTree.BooleanLiteral
| ESTree.NullLiteral
| ESTree.NumericLiteral
| ESTree.BigIntLiteral
| ESTree.RegExpLiteral
| ESTree.StringLiteral;
/**
* Print any ESTree `Literal`, which is one node type covering strings, numbers, booleans, `null`,
* regexes and bigints.
*
* The kind is told apart by the type of `value` and by which extra property is present,
* since the node itself does not say.
*/
export function printLiteral(
node: LiteralNode,
state: State,
precedence: number,
ctx: number,
): void {
// Ordered for the hot path - `value` is a `RegExp` / `null` for regexes and a `BigInt` / `null` for bigints,
// so a string or number `value` identifies a plain string/numeric literal without touching `regex` / `bigint` first.
const { value } = node;
switch (typeof value) {
case "string":
printString(state, value, node);
break;
case "number":
typeAssertIs<ESTree.NumericLiteral>(node);
printNumericLiteral(node, state, precedence, ctx);
break;
case "boolean":
printSpaceBeforeIdentifier(state);
writeWithMap(state, value ? "true" : "false", CAT_IDENT, node);
break;
default:
typeAssertIs<LiteralExtras>(node);
if (node.regex != null) {
typeAssertIs<ESTree.RegExpLiteral>(node);
printRegExpLiteral(node, state);
} else if (node.bigint != null) {
typeAssertIs<ESTree.BigIntLiteral>(node);
printBigIntLiteral(node, state, precedence);
} else {
// `null`
printSpaceBeforeIdentifier(state);
writeWithMap(state, "null", CAT_IDENT, node);
}
break;
}
}
/**
* Print a numeric literal, parenthesizing a negative one where the position needs it.
*
* A negative number is a unary minus applied to a positive literal, so it needs a space
* or parentheses in the places any other unary minus would, and `-0` has to keep its sign.
*
* In TypeScript positions the source text is printed back rather than the shortest form,
* so a type keeps the digits it was written with.
*/
function printNumericLiteral(
node: ESTree.NumericLiteral,
state: State,
precedence: number,
ctx: number,
): void {
const { value } = node;
if ((ctx & CTX_TYPESCRIPT) !== 0 && node.raw != null) {
// A number's raw text can only end 3 ways: a digit, a hex letter, or a trailing `.` as in `1.`.
// The first two are identifier characters and the third is not, so testing for the `.` decides it -
// no character classification needed.
// `CAT_IDENT` rather than `CAT_INT_DIGIT` - raw text only prints inside a TS type,
// where no member `.` can follow, so there is no `0 .toExponential()` hazard to separate.
const { raw } = node;
writeWithMap(state, raw, raw[raw.length - 1] === "." ? CAT_OTHER : CAT_IDENT, node);
return;
}
if (value > 0 && value < Infinity) {
// Finite and positive: the common case, and the same branch the chain
// below would reach, without the two builtin calls.
printSpaceBeforeIdentifier(state);
printNonNegativeFloat(state, value, node);
} else if (Number.isNaN(value)) {
printSpaceBeforeIdentifier(state);
writeWithMap(state, "NaN", CAT_IDENT, node);
} else if (!Number.isFinite(value)) {
const negative = value < 0;
const wrap = negative && precedence >= PREC_PREFIX;
if (wrap) write(state, "(", CAT_OTHER);
if (negative) {
printSpaceBeforeOperator(state, CAT_OP_UN_NEG);
writeNoLast(state, "-");
} else {
printSpaceBeforeIdentifier(state);
}
writeWithMap(state, "Infinity", CAT_IDENT, node);
if (wrap) write(state, ")", CAT_OTHER);
} else if (Object.is(value, 0)) {
// +0 exactly - `Object.is` rejects `-0`, which the negative paths below print as `-0`
printSpaceBeforeIdentifier(state);
writeWithMap(state, "0", CAT_INT_DIGIT, node);
} else if (precedence >= PREC_PREFIX) {
writeNoLast(state, "(-");
printNonNegativeFloat(state, -value, node);
write(state, ")", CAT_OTHER);
} else {
printSpaceBeforeOperator(state, CAT_OP_UN_NEG);
writeNoLast(state, "-");
printNonNegativeFloat(state, -value, node);
}
}
/**
* Print a regex literal from its pattern and flags.
*
* A regex with no flags ends in `/`, which is recorded as its own category, because a `/`
* immediately after it would open a comment.
*/
function printRegExpLiteral(node: ESTree.RegExpLiteral, state: State): void {
// Neither of the separating spaces below can be needed in pretty mode, so the check is not made.
// Both guard against a regex being written immediately after something, and nothing can be
// immediately before a regex here: every operator which could put one after a `/` or a `<` is
// written space padded, so `last` is always the space.
//
// A minified mode would stop padding operators and make both reachable again, so this code would
// need to be restored. `CAT_REGEX_SLASH` is left in place for that - the code is still written
// after a flagless regex, and it still sits in the range `printSpaceBeforeIdentifier` tests,
// which costs nothing and is what keeps this a 4 line restoration.
//
// debugAssertLastFresh(state);
// const { last } = state;
// if (last === CAT_REGEX_SLASH || (last === CAT_LT && /^script/i.test(pattern.slice(0, 6)))) {
// write(state, " ", CAT_OTHER);
// }
//
// * `last === CAT_REGEX_SLASH` keeps `/a//b/` from lexing as a line comment
// * `CAT_LT` arm keeps `<` followed by `/script...` from closing a host `<script>` element.
writeWithMapNoLast(state, "/", node);
writeNoLast(state, node.regex.pattern);
// `CAT_REGEX_SLASH` rather than `CAT_OTHER`. It means "a regex just closed", which is what the
// commented-out check above would read. With flags, the flags are what `last` describes instead.
const { flags } = node.regex;
if (flags === "") {
write(state, "/", CAT_REGEX_SLASH);
} else {
writeNoLast(state, "/");
write(state, flags, CAT_IDENT);
}
}
/**
* Print a bigint literal, parenthesizing a negative one where the position needs it,
* same as `printNumericLiteral` does.
*/
function printBigIntLiteral(node: ESTree.BigIntLiteral, state: State, precedence: number): void {
printSpaceBeforeIdentifier(state);
const value = node.bigint;
if (value.startsWith("-") && precedence >= PREC_PREFIX) {
writeWithMapNoLast(state, "(", node);
writeNoLast(state, value);
write(state, "n)", CAT_OTHER);
} else {
writeWithMapNoLast(state, value, node);
write(state, "n", CAT_IDENT);
}
}
+338
View File
@@ -0,0 +1,338 @@
// Modules.
import {
CAT_IDENT,
CAT_OTHER,
CAT_START_OF_DEFAULT_EXPORT,
write,
writeNoLast,
writeWithMap,
} from "./write.ts";
import { printClass } from "./class.ts";
import { printExpression } from "./expression.ts";
import { printFunction } from "./function.ts";
import { printSpaceBeforeIdentifier } from "./space.ts";
import { printIndent } from "./indent.ts";
import { CTX_NONE } from "./operators.ts";
import { PREC_COMMA } from "./precedence.ts";
import { printVariableDeclaration } from "./statement.ts";
import { printString } from "./string.ts";
import {
printTSEnumDeclaration,
printTSImportEqualsDeclaration,
printTSInterfaceDeclaration,
printTSModuleDeclaration,
printTSTypeAliasDeclaration,
} from "./typescript.ts";
import type { State } from "../state.ts";
import type { ExportNamedDeclarationNode } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print an `import` statement, in any of its forms.
*
* Default, namespace and named specifiers can be combined, and the named ones are the only kind in
* braces, so the braces are opened and closed around a run rather than around the whole list.
*/
export function printImportDeclaration(node: ESTree.ImportDeclaration, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "import", CAT_IDENT, node);
if (TS && node.importKind === "type") write(state, " type", CAT_IDENT);
if (node.phase != null) {
writeNoLast(state, " ");
write(state, node.phase, CAT_IDENT);
}
const { specifiers } = node;
const { length } = specifiers;
if (length === 0) {
// `import "source";`
write(state, " ", CAT_OTHER);
printString(state, node.source.value, node.source);
printImportAttributes(node.attributes, state);
write(state, ";\n", CAT_OTHER);
return;
}
let inBlock = false;
for (let i = 0; i < length; i++) {
const specifier = specifiers[i];
switch (specifier.type) {
case "ImportDefaultSpecifier":
if (inBlock) {
write(state, " },", CAT_OTHER);
inBlock = false;
} else if (i === 0) {
write(state, " ", CAT_OTHER);
} else {
write(state, ", ", CAT_OTHER);
}
printSpaceBeforeIdentifier(state);
writeWithMap(state, specifier.local.name, CAT_IDENT, specifier);
if (i === length - 1) write(state, " ", CAT_OTHER);
break;
case "ImportNamespaceSpecifier":
if (inBlock) {
write(state, " },", CAT_OTHER);
inBlock = false;
} else if (i === 0) {
write(state, " ", CAT_OTHER);
} else {
write(state, ", ", CAT_OTHER);
}
writeWithMap(state, "* as ", CAT_OTHER, specifier);
writeWithMap(state, specifier.local.name, CAT_IDENT, specifier.local);
write(state, " ", CAT_OTHER);
break;
default: {
// ImportSpecifier
if (inBlock) {
write(state, ", ", CAT_OTHER);
} else {
if (i !== 0) {
write(state, ",", CAT_OTHER);
}
inBlock = true;
write(state, " { ", CAT_OTHER);
}
if (TS && specifier.importKind === "type") write(state, "type ", CAT_OTHER);
const importedName = moduleExportName(specifier.imported, state);
const { local } = specifier;
if (importedName !== local.name) {
write(state, " as ", CAT_OTHER);
writeWithMap(state, local.name, CAT_IDENT, local);
}
break;
}
}
}
write(state, inBlock ? " } from " : "from ", CAT_OTHER);
printString(state, node.source.value, node.source);
printImportAttributes(node.attributes, state);
write(state, ";\n", CAT_OTHER);
}
/**
* Print a `with { … }` clause, and nothing at all when there are no attributes.
*
* An empty clause is written where the AST says the source had one, since `with {}`
* and no clause are different programs.
*/
function printImportAttributes(
attributes: ESTree.ImportAttribute[] | null | undefined,
state: State,
): void {
if (attributes == null) return;
const { length } = attributes;
if (length === 0) return;
write(state, " with { ", CAT_OTHER);
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const attribute = attributes[i];
const { key } = attribute;
if (key.type === "Identifier") {
writeWithMap(state, key.name, CAT_IDENT, key);
} else {
printString(state, key.value, key);
}
write(state, ": ", CAT_OTHER);
printString(state, attribute.value.value, attribute.value);
}
write(state, " }", CAT_OTHER);
}
/**
* Print the name on one side of an import or export specifier, and return it.
*
* The name comes back because the caller compares the two sides to decide whether an `as` clause
* is needed at all, and it is cheaper to return it than to work it out twice.
*
* @returns The name printed
*/
function moduleExportName(node: ESTree.ModuleExportName, state: State): string {
if (node.type === "Identifier") {
printSpaceBeforeIdentifier(state);
writeWithMap(state, node.name, CAT_IDENT, node);
return node.name;
}
printString(state, node.value, node);
return node.value;
}
/**
* Print an `export` statement which names what it exports, whether that is a declaration,
* a list of specifiers, or a list re-exported from another module.
*/
export function printExportNamedDeclaration(node: ExportNamedDeclarationNode, state: State): void {
printIndent(state);
writeWithMap(state, "export ", CAT_OTHER, node);
const { declaration } = node;
if (declaration != null) {
switch (declaration.type) {
case "VariableDeclaration":
printVariableDeclaration(declaration, state, CTX_NONE);
write(state, ";\n", CAT_OTHER);
break;
case "FunctionDeclaration":
/* IF TS */
case "TSDeclareFunction":
/* END_IF */
printFunction(declaration, state);
write(state, "\n", CAT_OTHER);
break;
case "ClassDeclaration":
printClass(declaration, state);
write(state, "\n", CAT_OTHER);
break;
/* IF TS */
case "TSModuleDeclaration":
printTSModuleDeclaration(declaration, state);
write(state, "\n", CAT_OTHER);
break;
case "TSInterfaceDeclaration":
printTSInterfaceDeclaration(declaration, state);
write(state, "\n", CAT_OTHER);
break;
case "TSEnumDeclaration":
printTSEnumDeclaration(declaration, state);
write(state, "\n", CAT_OTHER);
break;
case "TSTypeAliasDeclaration":
printTSTypeAliasDeclaration(declaration, state);
write(state, ";\n", CAT_OTHER);
break;
case "TSImportEqualsDeclaration":
printTSImportEqualsDeclaration(declaration, state);
write(state, ";\n", CAT_OTHER);
break;
/* END_IF */
default:
throw new Error(`Unknown export declaration type: ${declaration.type}`);
}
return;
}
if (TS && node.exportKind === "type") write(state, "type ", CAT_OTHER);
write(state, "{", CAT_OTHER);
const { specifiers } = node;
const { length } = specifiers;
if (length > 0) {
write(state, " ", CAT_OTHER);
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const specifier = specifiers[i];
if (TS && specifier.exportKind === "type") {
write(state, "type ", CAT_OTHER);
}
const localName = moduleExportName(specifier.local, state);
const exportedName =
specifier.exported.type === "Identifier"
? specifier.exported.name
: specifier.exported.value;
if (localName !== exportedName) {
write(state, " as ", CAT_OTHER);
moduleExportName(specifier.exported, state);
}
}
write(state, " ", CAT_OTHER);
}
write(state, "}", CAT_OTHER);
if (node.source != null) {
write(state, " from ", CAT_OTHER);
printString(state, node.source.value, node.source);
printImportAttributes(node.attributes, state);
}
write(state, ";\n", CAT_OTHER);
}
/**
* Print `export * from "…"`, with the `as name` form where the AST has one.
*/
export function printExportAllDeclaration(node: ESTree.ExportAllDeclaration, state: State): void {
printIndent(state);
writeWithMap(
state,
TS && node.exportKind === "type" ? "export type *" : "export *",
CAT_OTHER,
node,
);
if (node.exported != null) {
write(state, " as ", CAT_OTHER);
moduleExportName(node.exported, state);
}
write(state, " from ", CAT_OTHER);
printString(state, node.source.value, node.source);
printImportAttributes(node.attributes, state);
write(state, ";\n", CAT_OTHER);
}
/**
* Print `export default ...`.
*
* The output position is recorded before the value is printed, so that a function or class expression
* starting there knows to parenthesize itself.
*/
export function printExportDefaultDeclaration(
node: ESTree.ExportDefaultDeclaration,
state: State,
): void {
printIndent(state);
writeWithMap(state, "export default ", CAT_OTHER, node);
const { declaration } = node;
switch (declaration.type) {
case "FunctionDeclaration":
/* IF TS */
case "TSDeclareFunction":
/* END_IF */
printFunction(declaration, state);
write(state, "\n", CAT_OTHER);
break;
case "ClassDeclaration":
printClass(declaration, state);
write(state, "\n", CAT_OTHER);
break;
/* IF TS */
case "TSInterfaceDeclaration":
printTSInterfaceDeclaration(declaration, state);
write(state, "\n", CAT_OTHER);
break;
/* END_IF */
default:
state.last = CAT_START_OF_DEFAULT_EXPORT;
printExpression(declaration, state, PREC_COMMA, CTX_NONE);
write(state, ";\n", CAT_OTHER);
break;
}
}
+211
View File
@@ -0,0 +1,211 @@
// Number printing (port of `oxc_ecmascript::number_literal`, Terser-derived).
//
// The shortest text for a number is the shortest of a handful of forms, and which of them are even possible
// follows from what `String` gave - an integer can go hexadecimal or trade trailing zeros for an exponent,
// a fraction can drop its leading zero or trade leading zeros for one, and only a number `String` already wrote
// in exponent notation can fold its point into the exponent. So the forms are tried by separate functions,
// rather than by one which tries all of them on every number.
//
// Each form is written to the output in pieces, so no candidate string is ever built.
import { debugAssert } from "../asserts.ts";
import {
CAT_IDENT,
CAT_INT_DIGIT,
write,
writeNoLast,
writeWithMap,
writeWithMapNoLast,
} from "./write.ts";
import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Print a non-negative finite number in its shortest form.
*
* Integers below 1000 are the overwhelming majority, and 3 digits are too few for any other form
* to be shorter, so they print straight from `String` with none of the shortening work in the way.
*
* `CAT_INT_DIGIT` goes only on plain digits. It is what forces the space in `0 .toExponential()`,
* and every other form ends in, or contains, a `.`, an `e` or an `x` which separates it
* from a following `.` already.
*/
export function printNonNegativeFloat(state: State, value: number, node: ESTree.Node): void {
if (Number.isInteger(value)) {
if (value < 1000) {
writeWithMap(state, String(value), CAT_INT_DIGIT, node);
} else {
printShortestInteger(state, value, node);
}
} else {
printShortestFraction(state, value, node);
}
}
/**
* Print an integer of 1000 or more in its shortest form.
*
* `String` gives plain digits below 1e21 and exponent notation from there up, which have different forms
* available to them, so they are separate cases. Both can go hexadecimal.
*/
function printShortestInteger(state: State, value: number, node: ESTree.Node): void {
const formatted = String(value);
if (value >= 1e21) {
debugAssert(formatted.includes("e+"), "`String` gives `e+` notation from 1e21 up");
// The `+` always goes, so the text is one shorter than `formatted`
if (printHexIfShorter(state, value, formatted.length - 1, node)) return;
printExponent(state, formatted, formatted.indexOf("e"), node);
return;
}
const { length } = formatted;
if (printHexIfShorter(state, value, length, node)) return;
// A run of trailing zeros as an exponent: `1000` -> `1e3`
if (formatted.charCodeAt(length - 1) === 48 /* 0 */) {
// The first digit is never a zero, so the run always stops
let zeros = 1;
while (formatted.charCodeAt(length - 1 - zeros) === 48 /* 0 */) zeros++;
// Worth it when the `e` and the exponent cost less than the zeros they replace
const exponent = String(zeros);
if (exponent.length + 1 < zeros) {
writeWithMapNoLast(state, formatted.slice(0, length - zeros), node);
writeNoLast(state, "e");
write(state, exponent, CAT_IDENT);
return;
}
}
writeWithMap(state, formatted, CAT_INT_DIGIT, node);
}
/**
* Print a non-integer in its shortest form.
*
* `String` gives `0.<digits>` from 1e-7 up to 1, `<digits>.<digits>` from 1 up, and exponent notation
* below 1e-7. The first always loses its leading zero, and may trade a run of zeros after the point
* for a negative exponent. The second is already as short as it gets.
*/
function printShortestFraction(state: State, value: number, node: ESTree.Node): void {
const formatted = String(value);
// The trailing-zero form `printShortestInteger` uses can never win here, so it is not tried.
// The shortest text for a fraction never ends in a zero digit, and an exponent of at most 3 digits
// never ends in the 3 zeros it would take to pay for the `e`.
if (DEBUG) {
let zeros = 0;
while (formatted.charCodeAt(formatted.length - 1 - zeros) === 48 /* 0 */) zeros++;
debugAssert(zeros < 3, () => `Fraction text ends in ${zeros} zeros: ${formatted}`);
}
if (formatted.charCodeAt(0) === "0".charCodeAt(0)) {
// `0.<digits>`, whose leading zero always goes
if (formatted.charCodeAt(2) === "0".charCodeAt(0)) {
// A run of zeros straight after the point as a negative exponent: `0.0001` -> `1e-4`.
// There is always a non-zero digit for the run to stop at, since the text is the shortest
// which reads back as `value`.
let start = 3;
while (formatted.charCodeAt(start) === "0".charCodeAt(0)) start++;
// The exponent is the count of digits after the point, which is what shifting the point
// past all of them costs, zeros included
const exponent = String(formatted.length - 2);
// Both forms keep the digits, so it comes down to the `e-` and the exponent against the `.`
// and the zeros they replace
if (exponent.length + 2 < start - 1) {
writeWithMapNoLast(state, formatted.slice(start), node);
writeNoLast(state, "e-");
write(state, exponent, CAT_IDENT);
return;
}
}
writeWithMap(state, formatted.slice(1), CAT_IDENT, node);
return;
}
const exponentIndex = formatted.indexOf("e");
if (exponentIndex === -1) {
writeWithMap(state, formatted, CAT_IDENT, node);
return;
}
printExponent(state, formatted, exponentIndex, node);
}
/**
* Print a number `String` gave in exponent notation, in its shortest form.
*
* `String` writes exactly one digit before the point, so the digits after it can be folded into the exponent -
* `1.5e+21` -> `15e20`. That drops the `.`, so it wins whenever the exponent it leaves is no longer to write
* than the one it replaces. The `+` of a positive exponent always goes.
*
* @param formatted - `String(value)`, which is in exponent notation
* @param exponentIndex - Index of the `e` in `formatted`
*/
function printExponent(
state: State,
formatted: string,
exponentIndex: number,
node: ESTree.Node,
): void {
const exponent =
formatted.charCodeAt(exponentIndex + 1) === "+".charCodeAt(0)
? formatted.slice(exponentIndex + 2)
: formatted.slice(exponentIndex + 1);
if (exponentIndex > 1) {
debugAssert(
formatted.charCodeAt(1) === ".".charCodeAt(0),
"Exponent notation has 1 digit up front",
);
const folded = String(Number(exponent) - (exponentIndex - 2));
if (folded.length <= exponent.length) {
writeWithMapNoLast(state, formatted[0], node);
writeNoLast(state, formatted.slice(2, exponentIndex));
writeNoLast(state, "e");
write(state, folded, CAT_IDENT);
return;
}
}
writeWithMapNoLast(state, formatted.slice(0, exponentIndex + 1), node);
write(state, exponent, CAT_IDENT);
}
/**
* Print `value` in hexadecimal if that is shorter than `length` characters, and report whether it was.
*
* Hexadecimal has to win back the `0x` in front of it, which takes 13 digits at the very least,
* so below that the conversion is not attempted at all.
*
* That bound is in decimal digits, and the caller with a number in exponent notation passes the length
* of that instead, which is shorter - but such a number is at least 1e21, whose 18 hex digits make 20 characters,
* so anything the early return skips there could not have won either.
*
* `BigInt` rather than `value.toString(16)`, whose result the specification leaves implementation-approximated
* for every radix but 10, where the output here has to be exact.
*
* @param length - Length of the shortest text found for `value` so far
*/
function printHexIfShorter(
state: State,
value: number,
length: number,
node: ESTree.Node,
): boolean {
if (length < 13) return false;
const hex = BigInt(value).toString(16);
if (hex.length + 2 >= length) return false;
writeWithMapNoLast(state, "0x", node);
write(state, hex, CAT_IDENT);
return true;
}
+152
View File
@@ -0,0 +1,152 @@
// Operator tables.
//
// The context flags a printer is given about the position it is printing into, the precedence of
// each binary and assignment operator, those operators pre-padded with the spaces pretty mode
// always puts around them, and the category a unary or update operator leaves in `state.last`.
import {
CAT_OP_UN_NEG,
CAT_OP_UN_NOT,
CAT_OP_UN_PLUS,
CAT_OP_UPD_DEC,
CAT_OP_UPD_INC,
CAT_OTHER,
} from "./write.ts";
import { debugAssert } from "../asserts.ts";
import type { Category } from "./write.ts";
// Context flags (`oxc_codegen::Context`).
/** No context. */
export const CTX_NONE = 0;
/** Set in a `for` statement's head, where an unparenthesized `in` would end the head. */
export const CTX_FORBID_IN = 1;
/** Set under a `new`, where an unparenthesized call would be taken as `new`'s own argument list. */
export const CTX_FORBID_CALL = 2;
/** Set inside a TS type, where a numeric literal prints its raw text rather than the shortest form. */
export const CTX_TYPESCRIPT = 4;
/**
* Binary/logical operator precedences on the Oxc scale.
*/
export const BIN_PRECEDENCE = {
__proto__: null,
"**": 17,
"*": 16,
"/": 16,
"%": 16,
"+": 15,
"-": 15,
"<<": 14,
">>": 14,
">>>": 14,
"<": 13,
">": 13,
"<=": 13,
">=": 13,
instanceof: 13,
in: 13,
"==": 12,
"!=": 12,
"===": 12,
"!==": 12,
"&": 11,
"^": 10,
"|": 9,
"&&": 8,
"||": 7,
"??": 6,
};
/**
* Space-padded forms of every binary/logical operator.
*
* In pretty-print mode an operator is always surrounded by single spaces,
* so the whole token is one constant string and needs a single `write` call.
*/
export const PADDED_BIN_OPERATORS = {
__proto__: null,
"**": " ** ",
"*": " * ",
"/": " / ",
"%": " % ",
"+": " + ",
"-": " - ",
"<<": " << ",
">>": " >> ",
">>>": " >>> ",
"<": " < ",
">": " > ",
"<=": " <= ",
">=": " >= ",
instanceof: " instanceof ",
in: " in ",
"==": " == ",
"!=": " != ",
"===": " === ",
"!==": " !== ",
"&": " & ",
"^": " ^ ",
"|": " | ",
"&&": " && ",
"||": " || ",
"??": " ?? ",
};
/**
* Space-padded forms of every assignment operator.
*
* In pretty-print mode an operator is always surrounded by single spaces,
* so the whole token is one constant string and needs a single `write` call.
*/
export const PADDED_ASSIGN_OPERATORS = {
__proto__: null,
"=": " = ",
"+=": " += ",
"-=": " -= ",
"*=": " *= ",
"/=": " /= ",
"%=": " %= ",
"**=": " **= ",
"<<=": " <<= ",
">>=": " >>= ",
">>>=": " >>>= ",
"&=": " &= ",
"^=": " ^= ",
"|=": " |= ",
"&&=": " &&= ",
"||=": " ||= ",
"??=": " ??= ",
};
/**
* The category a unary operator leaves in `last`, which is what `printSpaceBeforeOperator` reads
* to decide whether the next operator needs separating.
*
* `typeof`, `void` and `delete` never reach here - they end in an identifier character and are handled as words.
*/
export function unaryOperatorCode(operator: string): Category {
debugAssert(!["typeof", "void", "delete"].includes(operator));
switch (operator) {
case "+":
return CAT_OP_UN_PLUS;
case "-":
return CAT_OP_UN_NEG;
case "!":
return CAT_OP_UN_NOT;
default:
return CAT_OTHER;
}
}
/**
* The category `++` or `--` leaves in `last`, as `unaryOperatorCode` does for unary operators.
*/
export function updateOperatorCode(operator: string): Category {
return operator === "++" ? CAT_OP_UPD_INC : CAT_OP_UPD_DEC;
}
+72
View File
@@ -0,0 +1,72 @@
// Printing options, and the source map types the `sourceMap` option brings with it.
/**
* Source map generator, compatible with `SourceMapGenerator` from the `source-map` package.
*/
export interface SourceMapGenerator {
addMapping(mapping: Mapping): void;
file?: string;
_file?: string;
}
/**
* Source map mapping emitted through `Options["sourceMap"]`.
*
* Note: the `Mapping` objects passed to `addMapping` are reused across calls;
* implementations must copy any values they need to retain.
*/
export interface Mapping {
original: Position;
generated: Position;
name: string | undefined;
source: string;
}
/**
* A `Mapping` before the per-mapping fields are filled in.
*/
export type MutableMapping = Omit<Mapping, "original" | "source"> & {
original: Position | null;
source: string | undefined;
};
/**
* Position in the original source, as found on a node's `loc.start`.
*/
export interface Position {
line: number;
column: number;
}
/**
* Code generator options.
*/
export interface Options {
/**
* String to use for indentation, defaults to `"\t"`.
* Must consist only of spaces and/or tabs - anything else falls back to a tab.
*/
indent?: string;
/**
* Indent level to start from, defaults to `0`.
*/
startingIndentLevel?: number;
/**
* `.tsx` mode if `true`, in which a lone type parameter prints as `<T,>`.
* Defaults to `false`.
*/
jsx?: boolean;
/**
* `true` if the AST may contain TypeScript syntax (TS-ESLint dialect).
* Defaults to `false`.
*/
ts?: boolean;
/**
* If present, source mappings are emitted through it.
*/
sourceMap?: SourceMapGenerator;
}
+16
View File
@@ -0,0 +1,16 @@
// Looking through the parentheses an AST may carry, to the expression they wrap.
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Look through any `ParenthesizedExpression` wrappers to the expression inside.
*
* Parsing with `preserveParens: false` produces none of these, but the printer accepts an AST
* which has them, and decides parenthesisation from precedence rather than from what the source had.
*/
export function withoutParens(node: ESTree.Expression): ESTree.Expression {
while (node.type === "ParenthesizedExpression") {
node = node.expression;
}
return node;
}
@@ -0,0 +1,29 @@
// Operator precedence levels, on the scale `oxc_syntax::precedence::Precedence` uses.
//
// A printer is told the precedence of the position it is printing into, and parenthesizes itself
// when its own precedence is lower. The numbering is Oxc's, so the two agree on what needs parens.
/** No precedence at all - nothing parenthesizes itself against this. */
export const PREC_LOWEST = 0;
/** The comma operator, as in a sequence expression. */
export const PREC_COMMA = 1;
/** `yield` and `yield*`. */
export const PREC_YIELD = 3;
/** Assignment, including the compound and logical forms, and arrow functions. */
export const PREC_ASSIGN = 4;
/** The `?:` conditional operator. */
export const PREC_CONDITIONAL = 5;
/** The equality operators, `==` through `!==`. */
export const PREC_EQUALS = 12;
/** The relational operators, including `in` and `instanceof`. */
export const PREC_COMPARE = 13;
/** `**`, the one right-associative binary operator. */
export const PREC_EXPONENTIATION = 17;
/** The prefix unary and update operators, `await`, and `!`. */
export const PREC_PREFIX = 18;
/** The postfix update operators, and the position a member expression's object prints at. */
export const PREC_POSTFIX = 19;
/** `new` without an argument list, which binds tighter than a call. */
export const PREC_NEW = 20;
/** Calls, member access and tagged templates - the tightest binding there is. */
export const PREC_CALL = 21;
@@ -0,0 +1,55 @@
// Source map generation.
//
// `write` records the output offset and original position of every mapped node as it goes,
// and this converts those offsets to generated line/column in one pass at the end.
import { debugAssert, typeAssertIs } from "../asserts.ts";
import type { Mapping, MutableMapping, SourceMapGenerator } from "./options.ts";
import type { State } from "../state.ts";
/**
* Convert the offsets recorded during printing into mappings, and hand them to the generator.
*
* Printing records only an output offset per mapped node, which is why this walks the output once
* at the end counting newlines, rather than tracking a line and column throughout.
*
* @param sourceMap - Generator the mappings are added to, which the caller was given in its options
*/
export function emitMappings(state: State, sourceMap: SourceMapGenerator): void {
debugAssert(
state.mapOffsets !== null && state.mapPositions !== null && state.mapNames !== null,
"Source map arrays should exist when a `sourceMap` was given",
);
const { output, mapOffsets, mapPositions, mapNames } = state;
const source = sourceMap.file || sourceMap._file;
// The `generated` and `mapping` objects are reused across `addMapping` calls to avoid generating ephemeral objects
const generated = { line: 1, column: 0 };
const mapping: MutableMapping = { original: null, generated, name: undefined, source };
let line = 1;
let lineStart = 0;
let nextNewline = output.indexOf("\n");
if (nextNewline === -1) nextNewline = Infinity;
const { length } = mapOffsets;
for (let i = 0; i < length; i++) {
const offset = mapOffsets[i];
while (offset > nextNewline) {
line++;
lineStart = nextNewline + 1;
const next = output.indexOf("\n", lineStart);
nextNewline = next === -1 ? Infinity : next;
}
generated.line = line;
generated.column = offset - lineStart;
mapping.original = mapPositions[i];
mapping.name = mapNames[i];
typeAssertIs<Mapping>(mapping);
sourceMap.addMapping(mapping);
}
}
+121
View File
@@ -0,0 +1,121 @@
// Spacing between tokens.
//
// Two adjacent tokens can merge into a different token when written straight up against each other -
// `in` after an identifier, `+` after `+`. Each of these is called before writing the second one,
// and writes a space only where it would otherwise happen.
import { debugAssert } from "../asserts.ts";
import {
ALL_CATEGORIES,
CAT_IDENT,
CAT_INT_DIGIT,
CAT_OP_UN_NEG,
CAT_OP_UN_NOT_AFTER_LT,
CAT_OP_UN_PLUS,
CAT_OP_UPD_DEC,
CAT_OP_UPD_INC,
CAT_OTHER,
CAT_REGEX_SLASH,
debugAssertLastFresh,
write,
} from "./write.ts";
import type { Category } from "./write.ts";
import type { State } from "../state.ts";
// `printSpaceBeforeIdentifier` selects the categories needing a space by their position in `Category` numbering.
// Check `category <= CAT_REGEX_SLASH` matches the intended categories, and no others.
if (DEBUG) {
for (const category of ALL_CATEGORIES) {
const expected = [CAT_IDENT, CAT_INT_DIGIT, CAT_REGEX_SLASH].includes(category);
const actual = category <= CAT_REGEX_SLASH;
debugAssert(
actual === expected,
`Category ${category} disagrees with \`last <= CAT_REGEX_SLASH\``,
);
}
}
/**
* Write a space, if what was written last would otherwise run into an identifier.
*
* Call it before writing anything starting with an identifier character - a name, a keyword, or a
* number. The categories needing the space are the lowest codes in the numbering, so the test is
* one compare.
*/
export function printSpaceBeforeIdentifier(state: State): void {
debugAssertLastFresh(state);
// `last` starts as `CAT_OTHER` (start of output behaves like after whitespace),
// so no empty-output check is needed.
// Everything needing a space before an identifier is one of the lowest codes, so this is one compare -
// identifier characters, a plain-digit number, and a regex closed with no flags.
if (state.last <= CAT_REGEX_SLASH) write(state, " ", CAT_OTHER);
}
// `printSpaceBeforeOperator` selects the categories needing a space by their position in `Category` numbering.
// Check `category >= CAT_OP_UN_NOT_AFTER_LT` matches the intended categories, and no others.
// `CAT_OP_UN_NOT` is deliberately absent - `printSpaceBeforeOperatorSlow` has no clause for a
// plain `!`, so it sits below the range and storing it costs the slow path nothing.
if (DEBUG) {
for (const category of ALL_CATEGORIES) {
const expected = [
CAT_OP_UN_NOT_AFTER_LT,
CAT_OP_UN_PLUS,
CAT_OP_UPD_INC,
CAT_OP_UN_NEG,
CAT_OP_UPD_DEC,
].includes(category);
const actual = category >= CAT_OP_UN_NOT_AFTER_LT;
debugAssert(
actual === expected,
`Category ${category} disagrees with \`last >= CAT_OP_UN_NOT_AFTER_LT\``,
);
}
}
/**
* Write a space, if the operator about to be written would otherwise merge with the one before it.
*
* `+ +y` and `- -y` are the cases - `++y` and `--y` would be a different operator.
* An operator category in `last` proves that operator was the immediately preceding token,
* because every write replaces `last`, so no position has to be recorded alongside it.
*
* @param next - Category of the operator about to be written
*/
export function printSpaceBeforeOperator(state: State, next: Category): void {
debugAssertLastFresh(state);
// The slow path only runs when an operator it distinguishes was the immediately preceding token,
// which is rare in pretty output. Keep the hot check inlinable.
const prev = state.last;
if (prev >= CAT_OP_UN_NOT_AFTER_LT) printSpaceBeforeOperatorSlow(state, prev, next);
}
/**
* The pairs of adjacent operators which need separating, kept out of `printSpaceBeforeOperator`
* so the common path stays small enough to inline.
*
* In pretty mode binary operators are written space-padded, so they never leave an operator code in `last`
* and are never passed as `next` - only unary and update operators reach here.
* Oxc's `print_space_before_operator` also has clauses for the binary cases, but those were already
* unreachable here, and there is now no code which could produce a binary operator's category.
*
* Only prefix operators appear as `next` - `+ +y`, `- --y` - which is why `prev` being `++` or
* `--` matches no clause. That is not a gap: in pretty output a postfix `++`/`--` is always
* followed by punctuation or a padded binary operator, never directly by another operator. (The
* asymmetry is the Rust original's, where it is live in minified mode - unpadded `(x++)+y` must
* print `x+++y` with no space, while unary `+ +y` must keep one.)
*
* @param prev - Category of the operator written last
* @param next - Category of the operator about to be written
*/
function printSpaceBeforeOperatorSlow(state: State, prev: Category, next: Category): void {
if (
(prev === CAT_OP_UN_PLUS && (next === CAT_OP_UN_PLUS || next === CAT_OP_UPD_INC)) ||
(prev === CAT_OP_UN_NEG && (next === CAT_OP_UN_NEG || next === CAT_OP_UPD_DEC)) ||
(prev === CAT_OP_UN_NOT_AFTER_LT && next === CAT_OP_UPD_DEC)
) {
write(state, " ", CAT_OTHER);
}
}
+796
View File
@@ -0,0 +1,796 @@
// Statements.
import { typeAssertIs } from "../asserts.ts";
import { printAssignmentTarget } from "./assignment_target.ts";
import { printBindingPattern } from "./binding_pattern.ts";
import {
CAT_IDENT,
CAT_OP_UN_NOT,
CAT_OTHER,
CAT_START_OF_STMT,
write,
writeNoLast,
writeWithMap,
writeWithMapNoLast,
} from "./write.ts";
import { printClass } from "./class.ts";
import { printExpression } from "./expression.ts";
import { printFunction } from "./function.ts";
import { printSpaceBeforeIdentifier } from "./space.ts";
import { printIndent } from "./indent.ts";
import {
printExportAllDeclaration,
printExportDefaultDeclaration,
printExportNamedDeclaration,
printImportDeclaration,
} from "./module.ts";
import { CTX_FORBID_IN, CTX_NONE } from "./operators.ts";
import { withoutParens } from "./parens.ts";
import { PREC_COMMA, PREC_LOWEST } from "./precedence.ts";
import { printString } from "./string.ts";
import {
printTSEnumDeclaration,
printTSImportEqualsDeclaration,
printTSInterfaceDeclaration,
printTSModuleDeclaration,
printTSTypeAliasDeclaration,
printTypeAnnotation,
} from "./typescript.ts";
import type { State } from "../state.ts";
import type { LiteralExtras, UnknownNode } from "./types.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Entry point for a whole file.
* A hashbang is written ahead of everything else, since it is only legal on the first line.
*/
export function printProgram(node: ESTree.Program, state: State): void {
if (node.hashbang != null) {
writeNoLast(state, "#!");
writeNoLast(state, node.hashbang.value);
write(state, "\n", CAT_OTHER);
}
printDirectivesAndStatements(node.body, state);
}
/**
* A directive prologue followed by ordinary statements. Directives are taken from the front for as long
* as they keep coming, and everything from the first non-directive onwards prints normally.
*
* The handover is the delicate part - a string literal statement arriving right after the prologue
* has to be kept from being read back as one more directive.
*/
export function printDirectivesAndStatements(
body: Array<ESTree.Directive | ESTree.Statement>,
state: State,
): void {
const { length } = body;
if (length === 0) return;
let i = 0;
let stmt = body[0];
while (stmt.type === "ExpressionStatement") {
if (stmt.directive != null) {
typeAssertIs<ESTree.Directive>(stmt);
printDirective(stmt, state);
if (++i >= length) return;
stmt = body[i];
} else {
// Ensure a string literal (only possible via parentheses, since a bare one would be a directive)
// as 1st statement or after other real directives, is not re-parsed as a directive
const inner = withoutParens(stmt.expression);
typeAssertIs<LiteralExtras>(inner);
if (inner.type === "Literal" && typeof inner.value === "string") {
printIndent(state);
writeNoLast(state, "(");
printString(state, inner.value, inner);
write(state, ");\n", CAT_OTHER);
i++;
}
break;
}
}
for (; i < length; i++) {
printStatement(body[i], state);
}
}
/**
* Directives print from their raw source text, so the quote has to be chosen to suit the text
* rather than the other way round - the scan takes the first unescaped `"` or `'` and quotes with
* the other one.
*/
function printDirective(stmt: ESTree.Directive, state: State): void {
printIndent(state);
const { directive } = stmt;
// A directive may not contain an escape sequence or line continuation,
// so print the raw `directive` value with a quote character it doesn't contain.
let quote = '"';
const { length } = directive;
for (let i = 0; i < length; i++) {
const char = directive[i];
if (char === '"') {
quote = "'";
break;
} else if (char === "'") {
break;
} else if (char === "\\") {
i++;
}
}
writeWithMapNoLast(state, quote, stmt);
writeNoLast(state, directive);
writeNoLast(state, quote);
write(state, ";\n", CAT_OTHER);
}
/**
* Dispatch point for statements, and the counterpart to `printExpression`. Unknown types throw.
*
* Each case owns its own leading indent and its trailing newline, so a statement always leaves
* the output at the start of a fresh line for the next one.
*/
export function printStatement(node: ESTree.Statement | UnknownNode, state: State): void {
// Arms are ordered roughly in order of most common nodes.
// V8 turns this into (essentially) as chain of `if ... else if ... else if...`,
// so making common nodes short-circuit early is a large perf boost.
switch (node.type) {
case "ExpressionStatement":
printExpressionStatement(node, state);
break;
case "VariableDeclaration":
printIndent(state);
printVariableDeclaration(node, state, CTX_NONE);
write(state, ";\n", CAT_OTHER);
break;
case "BlockStatement":
printIndent(state);
printBlockStatement(node, state);
write(state, "\n", CAT_OTHER);
break;
case "IfStatement":
printIndent(state);
printIf(node, state);
break;
case "ReturnStatement":
printReturnStatement(node, state);
break;
case "FunctionDeclaration":
printIndent(state);
printFunction(node, state);
write(state, "\n", CAT_OTHER);
break;
case "ForStatement":
printForStatement(node, state);
break;
case "WhileStatement":
printWhileStatement(node, state);
break;
case "DoWhileStatement":
printDoWhileStatement(node, state);
break;
case "SwitchStatement":
printSwitchStatement(node, state);
break;
case "BreakStatement":
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "break", CAT_IDENT, node);
if (node.label != null) {
write(state, " ", CAT_OTHER);
writeWithMap(state, node.label.name, CAT_IDENT, node.label);
}
write(state, ";\n", CAT_OTHER);
break;
case "ContinueStatement":
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "continue", CAT_IDENT, node);
if (node.label != null) {
write(state, " ", CAT_OTHER);
writeWithMap(state, node.label.name, CAT_IDENT, node.label);
}
write(state, ";\n", CAT_OTHER);
break;
case "TryStatement":
printTryStatement(node, state);
break;
case "ThrowStatement":
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "throw ", CAT_OTHER, node);
printExpression(node.argument, state, PREC_LOWEST, CTX_NONE);
write(state, ";\n", CAT_OTHER);
break;
case "ForInStatement":
printForInStatement(node, state);
break;
case "ForOfStatement":
printForOfStatement(node, state);
break;
case "ClassDeclaration":
printIndent(state);
printClass(node, state);
write(state, "\n", CAT_OTHER);
break;
case "LabeledStatement":
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, node.label.name, CAT_IDENT, node.label);
write(state, ":", CAT_OTHER);
printBody(node.body, state);
break;
case "EmptyStatement":
printIndent(state);
write(state, ";\n", CAT_OTHER);
break;
case "ImportDeclaration":
printImportDeclaration(node, state);
break;
case "ExportNamedDeclaration":
printExportNamedDeclaration(node, state);
break;
case "ExportDefaultDeclaration":
printExportDefaultDeclaration(node, state);
break;
case "ExportAllDeclaration":
printExportAllDeclaration(node, state);
break;
case "WithStatement":
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "with(", CAT_OTHER, node);
printExpression(node.object, state, PREC_LOWEST, CTX_NONE);
write(state, ")", CAT_OTHER);
printBody(node.body, state);
break;
case "DebuggerStatement":
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "debugger;\n", CAT_OTHER, node);
break;
/* IF TS */
case "TSModuleDeclaration":
printIndent(state);
printTSModuleDeclaration(node, state);
write(state, "\n", CAT_OTHER);
break;
case "TSInterfaceDeclaration":
printIndent(state);
printTSInterfaceDeclaration(node, state);
write(state, "\n", CAT_OTHER);
break;
case "TSTypeAliasDeclaration":
printIndent(state);
printTSTypeAliasDeclaration(node, state);
write(state, ";\n", CAT_OTHER);
break;
case "TSEnumDeclaration":
printIndent(state);
printTSEnumDeclaration(node, state);
write(state, "\n", CAT_OTHER);
break;
case "TSImportEqualsDeclaration":
printIndent(state);
printTSImportEqualsDeclaration(node, state);
write(state, ";\n", CAT_OTHER);
break;
case "TSDeclareFunction":
printIndent(state);
printFunction(node, state);
write(state, "\n", CAT_OTHER);
break;
case "TSExportAssignment":
printIndent(state);
writeWithMap(state, "export = ", CAT_OTHER, node);
printExpression(node.expression, state, PREC_LOWEST, CTX_NONE);
write(state, ";\n", CAT_OTHER);
break;
case "TSNamespaceExportDeclaration":
printIndent(state);
writeWithMap(state, "export as namespace ", CAT_OTHER, node);
writeWithMap(state, node.id.name, CAT_IDENT, node.id);
write(state, ";\n", CAT_OTHER);
break;
/* END_IF */
default:
throw new Error(`Unknown statement type: ${node.type}`);
}
}
/**
* Marks `last` as the start of a statement, which is how an object literal or an object destructuring assignment
* printed there knows to parenthesize itself.
*
* The indent is written first, so the mark is the last thing to touch `last` before the expression.
*/
function printExpressionStatement(node: ESTree.ExpressionStatement, state: State): void {
printIndent(state);
state.last = CAT_START_OF_STMT;
printExpression(node.expression, state, PREC_LOWEST, CTX_NONE);
write(state, ";\n", CAT_OTHER);
}
/**
* Shared by the standalone declaration and the `for` and `for-in` heads, which is why it writes no
* indent and no terminator - the caller supplies both.
*
* @param ctx - Passed to every initializer, so a `for` head can hand down `CTX_FORBID_IN` and stop an
* `in` operator there being read as the head's own.
*/
export function printVariableDeclaration(
node: ESTree.VariableDeclaration,
state: State,
ctx: number,
): void {
printSpaceBeforeIdentifier(state);
// The node's mapping goes on whichever of these is written first
const declare = TS && node.declare;
if (declare) {
writeWithMap(state, "declare ", CAT_OTHER, node);
write(state, node.kind, CAT_IDENT);
} else {
writeWithMap(state, node.kind, CAT_IDENT, node);
}
const { declarations } = node;
const { length } = declarations;
if (length > 0) write(state, " ", CAT_OTHER);
for (let i = 0; i < length; i++) {
if (i > 0) write(state, ", ", CAT_OTHER);
const declarator = declarations[i];
const { id } = declarator;
if (TS && declarator.definite) {
// `let x!: T` - the `!` sits between the name and its annotation
typeAssertIs<ESTree.BindingIdentifier>(id);
printSpaceBeforeIdentifier(state);
writeWithMap(state, id.name, CAT_IDENT, id);
write(state, "!", CAT_OP_UN_NOT);
if (id.typeAnnotation != null) {
printTypeAnnotation(id.typeAnnotation, state);
}
} else {
printBindingPattern(id, state);
}
if (declarator.init != null) {
write(state, " = ", CAT_OTHER);
printExpression(declarator.init, state, PREC_COMMA, ctx);
}
}
}
/**
* An empty block prints as `{}` with no line break inside. Otherwise the body prints one statement
* per line, a level deeper, with the closing brace back at the caller's level.
*
* Nothing is written before the opening brace or after the closing one, so callers can attach
* ` else`, ` catch` or a newline themselves.
*/
function printBlockStatement(block: ESTree.BlockStatement, state: State): void {
const { body } = block;
const { length } = body;
if (length === 0) {
writeWithMap(state, "{}", CAT_OTHER, block);
return;
}
writeWithMap(state, "{\n", CAT_OTHER, block);
state.indentLevel++;
for (let i = 0; i < length; i++) {
printStatement(body[i], state);
}
state.indentLevel--;
printIndent(state);
write(state, "}", CAT_OTHER);
}
/**
* An `else if` chain prints flat, by recursing on an `IfStatement` alternate rather than treating
* it as a nested body.
*
* A non-block consequent which trails off into a dangling `if` is given braces, so a following
* `else` cannot attach to the wrong one - see `wrapToAvoidAmbiguousElse`.
*/
function printIf(node: ESTree.IfStatement, state: State): void {
printSpaceBeforeIdentifier(state);
writeWithMap(state, "if (", CAT_OTHER, node);
printExpression(node.test, state, PREC_LOWEST, CTX_NONE);
const { consequent, alternate } = node;
if (consequent.type === "BlockStatement") {
write(state, ") ", CAT_OTHER);
printBlockStatement(consequent, state);
write(state, alternate != null ? " " : "\n", CAT_OTHER);
} else if (wrapToAvoidAmbiguousElse(consequent)) {
write(state, ") {\n", CAT_OTHER);
state.indentLevel++;
printStatement(consequent, state);
state.indentLevel--;
printIndent(state);
write(state, "}", CAT_OTHER);
write(state, alternate != null ? " " : "\n", CAT_OTHER);
} else {
write(state, ")", CAT_OTHER);
printBody(consequent, state);
if (alternate != null) printIndent(state);
}
if (alternate != null) {
printSpaceBeforeIdentifier(state);
write(state, "else", CAT_IDENT);
if (alternate.type === "BlockStatement") {
write(state, " ", CAT_OTHER);
printBlockStatement(alternate, state);
write(state, "\n", CAT_OTHER);
} else if (alternate.type === "IfStatement") {
write(state, " ", CAT_OTHER);
printIf(alternate, state);
} else {
printBody(alternate, state);
}
}
}
/**
* Whether a statement trails off into an `if` with no `else`, which would swallow an `else` written after it.
*
* Only the trailing position is followed - a loop, `with` or label body, and an `if` alternate -
* since that is where a following `else` would land.
*/
function wrapToAvoidAmbiguousElse(stmt: ESTree.Statement): boolean {
for (;;) {
switch (stmt.type) {
case "IfStatement":
if (stmt.alternate == null) return true;
stmt = stmt.alternate;
break;
case "ForStatement":
case "ForOfStatement":
case "ForInStatement":
case "WhileStatement":
case "WithStatement":
case "LabeledStatement":
stmt = stmt.body;
break;
default:
return false;
}
}
}
/**
* With an argument the space after `return` is part of the same write.
* With none, the keyword is recorded as an identifier, so anything printed after it keeps a separating space.
*/
function printReturnStatement(node: ESTree.ReturnStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
const { argument } = node;
if (argument != null) {
writeWithMap(state, "return ", CAT_OTHER, node);
printExpression(argument, state, PREC_LOWEST, CTX_NONE);
} else {
writeWithMap(state, "return", CAT_IDENT, node);
}
write(state, ";\n", CAT_OTHER);
}
/**
* The catch binding is optional, so an absent `param` prints a bare `catch` block rather than an empty pair of parens.
* The handler and the finalizer are each written only if present.
*/
function printTryStatement(node: ESTree.TryStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "try ", CAT_OTHER, node);
printBlockStatement(node.block, state);
const { handler } = node;
if (handler != null) {
write(state, " catch", CAT_IDENT);
if (handler.param != null) {
write(state, " (", CAT_OTHER);
printBindingPattern(handler.param, state);
write(state, ")", CAT_OTHER);
}
write(state, " ", CAT_OTHER);
printBlockStatement(handler.body, state);
}
if (node.finalizer != null) {
write(state, " finally ", CAT_OTHER);
printBlockStatement(node.finalizer, state);
}
write(state, "\n", CAT_OTHER);
}
/**
* A switch with no cases prints as `{}` on the same line. Otherwise the cases sit one level in,
* and each indents its own statements again - see `printSwitchCase`.
*/
function printSwitchStatement(node: ESTree.SwitchStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "switch (", CAT_OTHER, node);
printExpression(node.discriminant, state, PREC_LOWEST, CTX_NONE);
write(state, ") ", CAT_OTHER);
const { cases } = node;
const { length } = cases;
if (length === 0) {
write(state, "{}\n", CAT_OTHER);
return;
}
write(state, "{\n", CAT_OTHER);
state.indentLevel++;
for (let i = 0; i < length; i++) {
printSwitchCase(cases[i], state);
}
state.indentLevel--;
printIndent(state);
write(state, "}\n", CAT_OTHER);
}
/**
* A case with exactly one statement hands it to `printBody`, which keeps it on the line of the `case`.
* Any other count goes onto separate lines a level deeper, so an empty case prints as just its label.
*/
function printSwitchCase(node: ESTree.SwitchCase, state: State): void {
printIndent(state);
if (node.test != null) {
writeWithMap(state, "case ", CAT_OTHER, node);
printExpression(node.test, state, PREC_LOWEST, CTX_NONE);
} else {
writeWithMap(state, "default", CAT_IDENT, node);
}
write(state, ":", CAT_OTHER);
const { consequent } = node;
const { length } = consequent;
if (length === 1) {
printBody(consequent[0], state);
return;
}
write(state, "\n", CAT_OTHER);
state.indentLevel++;
for (let i = 0; i < length; i++) {
printStatement(consequent[i], state);
}
state.indentLevel--;
}
/**
* The test is fenced by its own parens, so it prints from `PREC_LOWEST` with no context flags,
* and the body goes through `printBody` like every other loop here.
*/
function printWhileStatement(node: ESTree.WhileStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "while (", CAT_OTHER, node);
printExpression(node.test, state, PREC_LOWEST, CTX_NONE);
write(state, ")", CAT_OTHER);
printBody(node.body, state);
}
/**
* Three body shapes with their own spacing - a block sits inline between `do` and `while`,
* an empty body prints as a bare `;`, and anything else takes its own indented line
* with the `while` returned to the outer level.
*
* The closing `);` is always written out rather than left to ASI.
*/
function printDoWhileStatement(node: ESTree.DoWhileStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "do", CAT_IDENT, node);
const { body } = node;
if (body.type === "BlockStatement") {
write(state, " ", CAT_OTHER);
printBlockStatement(body, state);
write(state, " ", CAT_OTHER);
} else if (body.type === "EmptyStatement") {
printIndent(state);
write(state, ";\n", CAT_OTHER);
} else {
write(state, "\n", CAT_OTHER);
state.indentLevel++;
printStatement(body, state);
state.indentLevel--;
printIndent(state);
}
write(state, "while (", CAT_OTHER);
printExpression(node.test, state, PREC_LOWEST, CTX_NONE);
write(state, ");\n", CAT_OTHER);
}
/**
* The init prints with `CTX_FORBID_IN`, so an `in` operator there parenthesizes itself instead of
* turning the head into a `for-in`.
*
* An absent test or update still writes its `;`, which is why those cases fold the separator,
* and for the update the closing paren too, into a single write.
*/
function printForStatement(node: ESTree.ForStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "for (", CAT_OTHER, node);
const { init } = node;
if (init != null) {
if (init.type === "VariableDeclaration") {
printVariableDeclaration(init, state, CTX_FORBID_IN);
} else {
printExpression(init, state, PREC_LOWEST, CTX_FORBID_IN);
}
}
const { test, update } = node;
if (test != null) {
write(state, "; ", CAT_OTHER);
printExpression(test, state, PREC_LOWEST, CTX_NONE);
} else {
write(state, ";", CAT_OTHER);
}
if (update != null) {
write(state, "; ", CAT_OTHER);
printExpression(update, state, PREC_LOWEST, CTX_NONE);
write(state, ")", CAT_OTHER);
} else {
write(state, ";)", CAT_OTHER);
}
printBody(node.body, state);
}
/**
* The left side is either a declaration or an assignment target. A declaration prints with `CTX_FORBID_IN`,
* so an `in` inside its initializer parenthesizes itself instead of ending the head early.
*/
function printForInStatement(node: ESTree.ForInStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "for (", CAT_OTHER, node);
const { left } = node;
if (left.type === "VariableDeclaration") {
printVariableDeclaration(left, state, CTX_FORBID_IN);
} else {
printAssignmentTarget(left, state);
}
write(state, " in ", CAT_OTHER);
printExpression(node.right, state, PREC_LOWEST, CTX_NONE);
write(state, ")", CAT_OTHER);
printBody(node.body, state);
}
/**
* Two targets have to be parenthesized in a `for-of` head - one beginning with `let`,
* which would read as a declaration, and a bare `async` in a loop without `await`,
* which would start `async of => {}`.
*
* Nothing here needs `CTX_FORBID_IN`, unlike the `for` and `for-in` heads, and the right side
* prints at `PREC_COMMA` so a sequence takes parens.
*/
function printForOfStatement(node: ESTree.ForOfStatement, state: State): void {
printIndent(state);
printSpaceBeforeIdentifier(state);
writeWithMap(state, "for", CAT_IDENT, node);
if (node.await) write(state, " await", CAT_IDENT);
write(state, " (", CAT_OTHER);
const { left } = node;
if (left.type === "VariableDeclaration") {
printVariableDeclaration(left, state, CTX_NONE);
} else {
typeAssertIs<ESTree.Expression>(left);
const bare = withoutParens(left);
const wrap =
forOfHeadStartsWithLet(left) ||
(!node.await && bare.type === "Identifier" && bare.name === "async");
if (wrap) write(state, "(", CAT_OTHER);
printAssignmentTarget(left, state);
if (wrap) write(state, ")", CAT_OTHER);
}
write(state, " of ", CAT_OTHER);
printExpression(node.right, state, PREC_COMMA, CTX_NONE);
write(state, ")", CAT_OTHER);
printBody(node.body, state);
}
/**
* The restriction is on the first token of the head rather than on the target as a whole,
* so this walks down the leftmost spine - through member objects and parens -
* to whatever prints first.
*/
function forOfHeadStartsWithLet(left: ESTree.Expression): boolean {
// Whether the leading token would be `let`.
for (;;) {
switch (left.type) {
case "Identifier":
return left.name === "let";
case "MemberExpression":
if (left.computed) {
const inner = withoutParens(left.object);
if (inner.type === "Identifier" && inner.name === "let") {
// `let[...]` is emitted as `(let)[...]`, so no longer starts with `let`
return false;
}
}
left = left.object;
break;
case "ParenthesizedExpression":
left = left.expression;
break;
default:
return false;
}
}
}
/**
* A statement in body position - an `if` branch, a loop or `with` body, a label's statement,
* or a lone `case` statement.
*
* A block is attached with a space and closed with a newline, an empty statement collapses to `;`,
* and anything else sets `pendingIndentAsSpace`, so its indent prints as a single space and it stays
* on the current line.
*/
function printBody(stmt: ESTree.Statement, state: State): void {
if (stmt.type === "BlockStatement") {
write(state, " ", CAT_OTHER);
printBlockStatement(stmt, state);
write(state, "\n", CAT_OTHER);
} else if (stmt.type === "EmptyStatement") {
write(state, ";\n", CAT_OTHER);
} else {
state.pendingIndentAsSpace = true;
printStatement(stmt, state);
}
}
+146
View File
@@ -0,0 +1,146 @@
// String literal printing (port of `str.rs`, pretty mode: fixed quote).
import { CAT_OTHER, write, writeNoLast, writeWithMapNoLast } from "./write.ts";
import { debugAssert } from "../asserts.ts";
import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Characters which may need escaping or special handling.
*
* The quote is fixed to `"` in pretty mode, so `'` and `` ` `` are deliberately absent -
* they are never escaped, and must not drag a string onto the slow path.
*/
const STRING_ESCAPE_REGEX =
// eslint-disable-next-line no-control-regex
/[\0\x07\b\v\f\n\r\x1B\\"<\u2028\u2029\xA0\uD800-\uDFFF]/;
/**
* Print a string literal, quotes and all.
*
* Pretty mode always uses double quotes, matching Oxc's default options, so there is no quote to choose.
* Almost no string needs escaping, so the scan for characters which do is all that happens on the common path.
*/
export function printString(state: State, value: string, node: ESTree.Node): void {
// Quote is fixed - double, matching `oxc_codegen`'s default option
writeWithMapNoLast(state, '"', node);
// Almost no string needs escaping, so the loop which performs escaping sits in its own function
if (!STRING_ESCAPE_REGEX.test(value)) {
writeNoLast(state, value);
} else {
printEscapedStringContents(state, value);
}
write(state, '"', CAT_OTHER);
}
/**
* Print the contents of a string which contains at least one character needing an escape.
*
* Runs of ordinary characters are copied in chunks rather than one at a time, so the cost is in
* the escapes rather than the length. Kept out of `printString` because it almost never runs.
*/
function printEscapedStringContents(state: State, value: string): void {
let chunkStart = 0;
const { length } = value;
for (let i = 0; i < length; i++) {
let escape: string | null = null;
const code = value.charCodeAt(i);
switch (code) {
case 0:
if (i + 1 < length) {
const next = value.charCodeAt(i + 1);
if (next >= "0".charCodeAt(0) && next <= "9".charCodeAt(0)) {
escape = "\\x00";
break;
}
}
escape = "\\0";
break;
case 7:
escape = "\\x07";
break;
case 8:
escape = "\\b";
break;
case 11:
escape = "\\v";
break;
case 12:
escape = "\\f";
break;
case 10:
escape = "\\n";
break;
case 13:
escape = "\\r";
break;
case 27:
escape = "\\x1B";
break;
case 92:
escape = "\\\\";
break;
case 34: // "
escape = '\\"';
break;
case 60: // <
// `</script` -> `<\/script`
if (/^<\/script/i.test(value.slice(i, i + 8))) {
escape = "<\\";
break;
}
continue;
case 0x2028:
escape = "\\u2028";
break;
case 0x2029:
escape = "\\u2029";
break;
case 0xa0:
escape = "\\xA0";
break;
default:
if (code >= 0xd800 && code <= 0xdfff) {
// Escape lone surrogates. Paired surrogates pass through.
if (code <= 0xdbff && i + 1 < length) {
const next = value.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
// Valid pair, both halves stay in the chunk
i++;
continue;
}
}
escape = "\\u" + code.toString(16);
break;
}
continue;
}
debugAssert(escape != null);
writeNoLast(state, value.slice(chunkStart, i));
writeNoLast(state, escape);
chunkStart = i + 1;
}
writeNoLast(state, value.slice(chunkStart));
}
// `</script` -> `<\/script` in template literal quasis.
/**
* Break up any `</script` in text which is printed raw, so the output cannot end an inline script
* element early.
*
* For template literal quasis, which are printed from their raw text and so never pass through the
* string escaper.
*/
export function escapeScriptCloseTag(text: string): string {
if (!text.includes("</")) return text;
return text.replace(/<\/(script)/gi, "<\\/$1");
}
+121
View File
@@ -0,0 +1,121 @@
// Node types.
//
// AST nodes are typed with Oxc's own ESTree type definitions.
// The printer is more permissive than they are in 2 respects, which the types below cover:
//
// 1. Its dispatch functions throw on node types they don't know.
// `UnknownNode` keeps those branches reachable - without it the type-checker narrows the node to `never`
// and rejects the `node.type` read in the error message.
//
// 2. It also accepts ASTs from other ESTree producers (Acorn, TS-ESLint), which differ from Oxc's AST
// in a handful of places. The `*Node` aliases widen the properties where they do, and the branches
// which read a property Oxc's AST doesn't have at all assert its type at the point of use.
import type { Position } from "./options.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/** A node whose type the printer doesn't handle. */
export interface UnknownNode {
type: "<unknown node>";
}
/**
* Properties which only some literals carry. `printLiteral` reads them on any literal to tell
* regexes and bigints apart from the rest.
*/
export interface LiteralExtras {
regex?: ESTree.RegExpLiteral["regex"];
bigint?: string;
}
/**
* Oxc marks abstract class members with a `TSAbstract*` node type, where others use an `abstract` flag.
*/
export type MethodDefinitionNode = ESTree.MethodDefinition & { abstract?: boolean };
/**
* A class field, which Oxc may mark abstract with a flag rather than a `TSAbstract*` type.
*/
export type PropertyDefinitionNode = ESTree.PropertyDefinition & { abstract?: boolean };
/**
* An `accessor` field, marked abstract the same way as `PropertyDefinitionNode`.
*/
export type AccessorPropertyNode = ESTree.AccessorProperty & { abstract?: boolean };
/**
* A class body whose members may include a node type the printer does not know.
*/
export type ClassBodyNode = Omit<ESTree.ClassBody, "body"> & {
body: Array<ESTree.ClassElement | UnknownNode>;
};
/**
* An `export` whose declaration may be a node type the printer does not know.
*/
export type ExportNamedDeclarationNode = Omit<ESTree.ExportNamedDeclaration, "declaration"> & {
declaration: ESTree.Declaration | UnknownNode | null;
};
/**
* Oxc keeps a module's kind in `kind`, using `global: true` for `declare global`.
* Older producers have no `kind`, and only the `global` flag distinguishes the two.
*/
export type TSModuleDeclarationNode = Omit<
ESTree.TSModuleDeclaration | ESTree.TSGlobalDeclaration,
"kind"
> & {
kind?: ESTree.TSModuleDeclarationKind | "global" | null;
};
/**
* Oxc puts a mapped type's parameter in `key` and `constraint`, where TS-ESLint < 6 used `typeParameter`.
*/
export type TSMappedTypeNode = Omit<ESTree.TSMappedType, "key"> & {
key?: ESTree.BindingIdentifier | null;
};
/**
* The pre-TS-ESLint-6 shape `TSMappedTypeNode` falls back to reading.
*/
export interface TSMappedTypeLegacyParameter {
typeParameter: Omit<ESTree.TSTypeParameter, "constraint"> & { constraint: ESTree.TSType };
}
/**
* Oxc's `TSImportType` names the module in `source`, where TS-ESLint < 8 used `argument`.
*/
export type TSImportTypeNode = Omit<ESTree.TSImportType, "source"> & {
source?: ESTree.StringLiteral | null;
};
/**
* The pre-TS-ESLint-8 shape `TSImportTypeNode` falls back to reading.
*/
export interface TSImportTypeLegacyArgument {
argument: ESTree.StringLiteral | ESTree.TSLiteralType | ESTree.TSType;
}
/**
* Oxc wraps enum members in a `TSEnumBody`, where TS-ESLint < 8 put them on the declaration itself.
*/
export type TSEnumDeclarationNode = Omit<ESTree.TSEnumDeclaration, "body"> & {
body?: ESTree.TSEnumBody | null;
};
/**
* The pre-TS-ESLint-8 shape `TSEnumDeclarationNode` falls back to reading.
*/
export interface TSEnumDeclarationLegacyMembers {
members: ESTree.TSEnumMember[];
}
/**
* A node, as passed to `write`, which only records where the node started for source maps.
*
* `loc` is not part of Oxc's AST - that records byte spans - but producers such as Acorn add it.
*/
export interface MappableNode {
type: string;
loc?: { start: Position } | null;
}
File diff suppressed because it is too large. Load diff
+405
View File
@@ -0,0 +1,405 @@
// Writing to the output, and the categories which describe what was written last.
//
// These live together because the categories exist only to be stored by `write` -
// `state.last` records what was written last by category, not the last character itself.
import { debugAssert, typeAssertIs } from "../asserts.ts";
import type { MappableNode } from "./types.ts";
import type { State } from "../state.ts";
// `state.last` records what was written last, by category, not the last character itself.
//
// Reading last character of `state.output` is disastrous for performance. `state.output` is a "cons string",
// a concatenation of many smaller strings. Reading any character of it causes the string to be flattened,
// which is costly. Given how often it's required to know what was printed last, this produces a 4x slow-down.
//
// No reader needs the character - only its category - so the category is defined statically in `write` calls
// and stored as a small integer, which costs no write barrier to store and one compare to test.
//
// Three properties of the layout are load bearing, so keep them if you add a code:
//
// 1. `CAT_IDENT` through `CAT_REGEX_SLASH` are the classes needing a space before a following identifier,
// and they are the lowest codes, so `printSpaceBeforeIdentifier` is one compare against `CAT_REGEX_SLASH`.
// The unary and update operators are likewise contiguous, and the highest codes, so `printSpaceBeforeOperator`
// is one compare too.
//
// 2. The `CAT_START_OF_*` codes say where the output has reached, not what was written last, and they sit between
// those two ranges. Both space checks therefore read them as "nothing to separate", which is what the `CAT_OTHER`
// they displace meant - every position one of them marks follows whitespace or the start of the output.
// See `state.ts` for what they are for.
//
// 3. `CAT_START_OF_STMT` is odd, with the other two marks either side of it.
// Each of the two pairs a reader asks about is then one `|`-and-compare instead of two compares.
//
// The whole numbering:
//
// Group 0 to 2: A following identifier needs a space (`printSpaceBeforeIdentifier` checks `last <= CAT_REGEX_SLASH`)
// 0 CAT_IDENT Identifier part - letters, digits, `_`, `$`, ID_Continue
// 1 CAT_INT_DIGIT Numeric literal of plain digits (`0 .toExponential()`)
// 2 CAT_REGEX_SLASH Regex closed with no flags
//
// Group 3 to 9: Checked individually
// 3 CAT_OTHER Anything else not covered by another category - punctuation, whitespace
// 4 CAT_LT `<`
// 5 CAT_QUESTION `?`
// 6 CAT_START_OF_DEFAULT_EXPORT `export default` expression is about to be printed
// 7 CAT_START_OF_STMT Expression statement's expression is about to be printed
// 8 CAT_START_OF_ARROW_EXPR Concise arrow body is about to be printed
// 9 CAT_OP_UN_NOT `!`
//
// Group 10 to 14: Operators `printSpaceBeforeOperatorSlow` needs to tell apart (`last >= CAT_OP_UN_NOT_AFTER_LT`)
// 10 CAT_OP_UN_NOT_AFTER_LT `!` written straight after a `<`
// 11 CAT_OP_UN_PLUS `+`
// 12 CAT_OP_UPD_INC `++`
// 13 CAT_OP_UN_NEG `-`
// 14 CAT_OP_UPD_DEC `--`
/**
* A category code. One of the `CAT_*` constants below, and nothing else -
* the range checks which read `state.last` are only sound over exactly this set.
*/
export type Category =
| typeof CAT_IDENT
| typeof CAT_INT_DIGIT
| typeof CAT_REGEX_SLASH
| typeof CAT_OTHER
| typeof CAT_LT
| typeof CAT_QUESTION
| typeof CAT_START_OF_STMT
| typeof CAT_START_OF_ARROW_EXPR
| typeof CAT_START_OF_DEFAULT_EXPORT
| typeof CAT_OP_UN_NOT
| typeof CAT_OP_UN_NOT_AFTER_LT
| typeof CAT_OP_UN_PLUS
| typeof CAT_OP_UPD_INC
| typeof CAT_OP_UN_NEG
| typeof CAT_OP_UPD_DEC;
/** Identifier part - letters, digits, `_`, `$`, Unicode `ID_Continue`. */
export const CAT_IDENT = 0;
/**
* A numeric literal whose text is plain digits, so a following `.` would be read as part of the number -
* `0 .toExponential()` needs the space, `1e3.x` and `.5.x` do not.
*/
export const CAT_INT_DIGIT = 1;
/**
* A regex's closing `/`, written only where the regex has no flags.
*
* In the identifier range because `/a/ in x` needs the space just as much as `x in y` does -
* without it the `in` would be read as regex flags. With flags, `CAT_IDENT` says the same thing.
*/
export const CAT_REGEX_SLASH = 2;
/** Anything not covered by another category - punctuation, whitespace, a quote. */
export const CAT_OTHER = 3;
/** `<`, which a following `!` must not merge with into `<!--`. */
export const CAT_LT = 4;
/** `?`, which must not merge with a following `?` into `??` - see `TSJSDocNullableType`. */
export const CAT_QUESTION = 5;
/**
* An `export default` expression is about to be printed, and nothing has been written since.
*
* Read by the function and class printers, which parenthesize themselves here so the declaration forms
* (`export default function f() {}`) stay distinguishable from the expression ones.
*
* Immediately below `CAT_START_OF_STMT`, which is what makes their shared test one `|`.
*/
export const CAT_START_OF_DEFAULT_EXPORT = 6;
/**
* An expression statement's expression is about to be printed, and nothing has been written since.
*
* A node which finds this in `last` is the leftmost token of the statement, however deeply nested it is,
* so an object literal or an object destructuring assignment there parenthesizes itself.
*
* The only mark both readers ask about, so it is the odd one, with the other two either side.
*/
export const CAT_START_OF_STMT = 7;
/**
* A concise arrow body is about to be printed, and nothing has been written since.
*
* Read by the same two printers as `CAT_START_OF_STMT` - `x => ({ a: 1 })` needs the parens for
* the same reason a statement does, because a leading `{` would be read as a block.
*
* Immediately above `CAT_START_OF_STMT`, which is what makes their shared test one `|`.
*/
export const CAT_START_OF_ARROW_EXPR = 8;
/**
* `!`, written anywhere other than straight after a `<` - both the unary operator and TS's
* postfix `!` (non-null assertion, definite assignment), which postfix position keeps off a `<`.
*
* An operator code, but deliberately below the range `printSpaceBeforeOperator` gates on -
* no following operator merges with a plain `!`, so storing this never costs the slow path a call.
*/
export const CAT_OP_UN_NOT = 9;
/**
* `!` written immediately after a `<`, which is the `<!--` hazard.
* Folding the check on the preceding character into the code saves tracking the second-last character.
*
* The first of the operators `printSpaceBeforeOperator` gates on - writing one of these
* is what records it, so no separate field tracks which operator came last.
*/
export const CAT_OP_UN_NOT_AFTER_LT = 10;
/** `+`, which must not merge with a following `+` or `++`. */
export const CAT_OP_UN_PLUS = 11;
/** `++`, which must not follow a `+` without a space. */
export const CAT_OP_UPD_INC = 12;
/** `-`, which must not merge with a following `-` or `--`. */
export const CAT_OP_UN_NEG = 13;
/** `--`, which must not follow a `-`, nor the `!` of a `<!`. */
export const CAT_OP_UPD_DEC = 14;
/**
* Every category, in numbering order.
*
* Only the debug checks which prove the range compares still match the sets they mean read this,
* so release builds drop it entirely.
*/
export const ALL_CATEGORIES: Category[] = [
CAT_IDENT,
CAT_INT_DIGIT,
CAT_REGEX_SLASH,
CAT_OTHER,
CAT_LT,
CAT_QUESTION,
CAT_START_OF_DEFAULT_EXPORT,
CAT_START_OF_STMT,
CAT_START_OF_ARROW_EXPR,
CAT_OP_UN_NOT,
CAT_OP_UN_NOT_AFTER_LT,
CAT_OP_UN_PLUS,
CAT_OP_UPD_INC,
CAT_OP_UN_NEG,
CAT_OP_UPD_DEC,
];
// The five marker readers select a pair of marks by position rather than comparing each in turn.
// `printFunction`, `printClass` and `printCallExpression` ask "statement or `export default`",
// `printObjectExpression` and `printAssignmentExpression` ask "statement or concise arrow body":
//
// (last | 1) === CAT_START_OF_STMT // Statement or `export default`
// ((last - 1) | 1) === CAT_START_OF_STMT // Statement or concise arrow body
//
// Both hold only while `CAT_START_OF_STMT` is odd and its two neighbours sit either side of it.
// Check each against the set it is meant to select, over every category and no others.
if (DEBUG) {
for (const category of ALL_CATEGORIES) {
debugAssert(
((category | 1) === CAT_START_OF_STMT) ===
(category === CAT_START_OF_STMT || category === CAT_START_OF_DEFAULT_EXPORT),
`Category ${category} disagrees with \`(last | 1) === CAT_START_OF_STMT\``,
);
debugAssert(
(((category - 1) | 1) === CAT_START_OF_STMT) ===
(category === CAT_START_OF_STMT || category === CAT_START_OF_ARROW_EXPR),
`Category ${category} disagrees with \`((last - 1) | 1) === CAT_START_OF_STMT\``,
);
}
}
/**
* Append `code` to the output, and record what it ends with.
*
* @param code - Text to append, never empty
* @param last - Category of the last character of `code`
*/
export function write(state: State, code: string, last: Category): void {
if (DEBUG) {
debugAssertCategoryMatches(state, code, last);
state.lastIsStale = false;
state.lastCharWritten = code[code.length - 1];
}
state.last = last;
state.output += code;
}
/**
* Append `code` to the output, leaving `state.last` describing whatever came before it.
*
* Only sound where the value of `last` is provably dead - another `write` must follow before anything reads it.
* The readers are `printSpaceBeforeIdentifier` and the `CAT_LT`, `CAT_REGEX_SLASH` and `CAT_QUESTION`
* adjacency checks, all of which run at the start of printing a construct.
*
* In practice that means using it for all but the final fragment when one token is written in pieces.
* Debug builds track the rule and throw if a reader sees a stale `last`.
*
* @param code - Text to append, which unlike `write` may be empty
*/
export function writeNoLast(state: State, code: string): void {
if (DEBUG) {
state.lastIsStale = true;
if (code.length > 0) state.lastCharWritten = code[code.length - 1];
}
state.output += code;
}
/**
* Append `code` to the output, record what it ends with, and record a source mapping for `node`.
*
* The mapping is only recorded where the caller asked for source maps and `node` carries a `loc` -
* Oxc's own AST records byte spans instead, so the check is on the node, not the build.
*
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
* into `write` and drops the `node` argument, leaving this unreferenced for the minifier to remove.
*
* @param code - Text to append, never empty
* @param last - Category of the last character of `code`
* @param node - Node this text came from
*/
export function writeWithMap(state: State, code: string, last: Category, node: MappableNode): void {
if (DEBUG) {
debugAssertCategoryMatches(state, code, last);
state.lastIsStale = false;
state.lastCharWritten = code[code.length - 1];
}
if (SOURCEMAPS && node.loc != null) {
debugAssert(
state.mapOffsets !== null && state.mapPositions !== null && state.mapNames !== null,
"Source map arrays should exist when source maps are enabled",
);
typeAssertIs<{ name?: string }>(node);
state.mapOffsets.push(state.output.length);
state.mapPositions.push(node.loc.start);
state.mapNames.push(node.name);
}
state.last = last;
state.output += code;
}
/**
* Append `code` and record a source mapping for `node`, leaving `state.last` alone.
*
* `writeNoLast`'s rule about `last` applies here too - another write must follow before anything reads it.
*
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
* into `write` and drops the `node` argument, leaving this unreferenced for the minifier to remove.
*
* @param code - Text to append, which unlike `writeWithMap` may be empty
* @param node - Node this text came from
*/
export function writeWithMapNoLast(state: State, code: string, node: MappableNode): void {
if (SOURCEMAPS && node.loc != null) {
debugAssert(
state.mapOffsets !== null && state.mapPositions !== null && state.mapNames !== null,
"Source map arrays should exist when source maps are enabled",
);
typeAssertIs<{ name?: string }>(node);
state.mapOffsets.push(state.output.length);
state.mapPositions.push(node.loc.start);
state.mapNames.push(node.name);
}
if (DEBUG) {
state.lastIsStale = true;
if (code.length > 0) state.lastCharWritten = code[code.length - 1];
}
state.output += code;
}
/**
* Assert that `state.last` still describes what was written last.
*
* Every reader of `last` calls this, so a `writeNoLast` whose caller broke the rule is caught by
* the conformance suites rather than silently incorrectly spacing one construct.
*
* Debug builds only - the call and its argument are removed from release builds entirely.
*/
export function debugAssertLastFresh(state: State): void {
debugAssert(!state.lastIsStale, "`last` was read after `writeNoLast` left it stale");
}
/** Matches a character which can continue an identifier. */
const IDENT_CONTINUE_REGEX = /[\p{ID_Continue}$\u200C\u200D]/u;
/**
* Assert that `last` truthfully describes the end of `code`.
*
* A category encodes the merge hazard of the trailing token, not the trailing character -
* a digit legitimately ends both a `CAT_IDENT` and a `CAT_INT_DIGIT` write - so each final character
* has a set of permitted categories rather than one right answer.
*
* Debug builds only. Removed by minifier in release builds.
*/
function debugAssertCategoryMatches(state: State, code: string, last: Category): void {
if (!DEBUG) return;
debugAssert(code.length > 0, "`code` should not be an empty string");
const ch = code.at(-1)!;
let ok;
if (ch >= "0" && ch <= "9") {
// A digit ends an identifier (`x1`), a number some other character already separates
// (`1e3`, `0x1f`, `.5`) or a written fragment of one (the digits of `15e-8`) - all `CAT_IDENT`.
// Only a numeric literal which is plain digits throughout is `CAT_INT_DIGIT`.
// The dangerous converse - a whole plain-digit literal claiming `CAT_IDENT`, which loses the space
// in `0 .toExponential()` - looks exactly like a fragment write, so it cannot be caught here.
// The AST fixtures pin the number printer's category decisions instead.
ok = last === CAT_IDENT || (last === CAT_INT_DIGIT && /^[0-9]+$/.test(code));
} else if (ch === "+") {
if (code.endsWith("++")) {
ok = last === CAT_OP_UPD_INC;
} else {
ok = last === CAT_OP_UN_PLUS;
}
} else if (ch === "-") {
if (code.endsWith("--")) {
ok = last === CAT_OP_UPD_DEC;
} else {
ok = last === CAT_OP_UN_NEG;
}
} else if (ch === "!") {
// The two operator categories are told apart by the character before the `!`, which also proves
// the `CAT_OP_UN_NOT_AFTER_LT` branch in `printUnaryExpression` fires exactly when it should
if (last === CAT_OP_UN_NOT_AFTER_LT) {
ok = state.lastCharWritten === "<";
} else {
ok = last === CAT_OP_UN_NOT && state.lastCharWritten !== "<";
}
} else if (ch === "<") {
ok = last === CAT_LT;
} else if (ch === "?") {
ok = last === CAT_QUESTION;
} else if (ch === "/") {
ok = last === CAT_REGEX_SLASH;
} else {
// The final character, whole - for an astral character `ch` is only its low surrogate,
// which no Unicode property matches
const isLowSurrogate = ch >= "\uDC00" && ch <= "\uDFFF";
const finalChar = isLowSurrogate && code.length >= 2 ? code.slice(-2) : ch;
if (IDENT_CONTINUE_REGEX.test(finalChar)) {
ok = last === CAT_IDENT;
} else if (ch <= "\x7F") {
// ASCII
ok = last === CAT_OTHER;
} else {
// Not an identifier character by this runtime's Unicode tables, but the parser's tables
// may be a newer Unicode version which does count it, so both claims are believed -
// test262 holds identifiers whose final characters are new enough to hit this
ok = last === CAT_IDENT || last === CAT_OTHER;
}
}
debugAssert(ok, () => `Category ${last} does not describe the end of ${JSON.stringify(code)}`);
}
+133
View File
@@ -0,0 +1,133 @@
// Printer state.
//
// One object, created here and handed to whichever printer build `index.ts` selects.
// It is deliberately outside `print/`, so that all 4 builds share this one class and therefore one
// object shape - a build-time flag must never add or remove a field, or the printers would each
// see a different hidden class for the object they thread through every function.
//
// The printers only ever receive it, so they import it as a type.
// Nothing in `print/` constructs one, and none of the 4 printer builds bundles this file.
import { CAT_OTHER } from "./print/write.ts";
import type { Category } from "./print/write.ts";
import type { Options, Position } from "./print/options.ts";
/**
* The string one level of indentation prints as. Set from the `indent` option, a tab by default.
*/
let indentString = "\t";
/**
* Indentation cache, holding the string for each level - `indents[2]` is two levels' worth.
*
* There is one per process, and every `State` carries it, so all 4 printer builds grow and read
* the same array - the cache a build fills is there for the next build to use.
*
* It survives across prints and is discarded only when `indentString` changes.
*/
const indents = [""];
/**
* The `indent` option must be made up of only spaces and tabs, and not empty string.
* Anything else falls back to a tab.
*/
const INDENT_REGEX = /^[ \t]+$/;
export class State {
// Current output.
// A string which is appended to as the printing process proceeds.
declare output: string;
// Current indentation level.
declare indentLevel: number;
// The process-wide indent cache and the string one level of it prints as.
// `printIndent` reads the cache through here, and `growIndents` extends it.
declare indents: string[];
declare indentString: string;
// `true` if printing a JSX / TSX file. Only affects printing of TSX.
declare isJsx: boolean;
// Operator/token glue tracking.
declare pendingIndentAsSpace: boolean;
// Category of the last thing written - one of the `CAT_*` constants, never a character.
//
// It also carries the three `CAT_START_OF_*` markers, which say that an expression statement,
// a concise arrow body or an `export default` expression is about to be printed, and nothing
// has been written since.
//
// A node seeing one of those is the leftmost token of that construct, however deeply nested it is,
// which is how an object literal, a function or a class knows to parenthesize itself.
//
// They work because the next write replaces `last`, so "nothing written since" needs nothing else recorded -
// and because every position they mark follows whitespace, which is what the `CAT_OTHER` they displace
// means for spacing.
declare last: Category;
// `true` between a `writeNoLast` and the write which follows it,
// i.e. while `last` describes something other than what was written last.
// Only used in debug builds. See `debugAssertLastFresh`.
declare lastIsStale: boolean;
// The character the output currently ends with.
// Only used in debug builds. See `debugAssertCategoryMatches`.
declare lastCharWritten: string;
// Deferred source mappings.
// Either all 3 are arrays, or all 3 are `null` (when the printer was given no `sourceMap`).
declare mapOffsets: number[] | null;
declare mapPositions: Position[] | null;
declare mapNames: (string | undefined)[] | null;
constructor(options: Options) {
this.output = "";
let indentLevel = options.startingIndentLevel;
if (typeof indentLevel !== "number") indentLevel = 0;
this.indentLevel = indentLevel;
// The `indent` option is validated here, not in the printer, and changing of it discards
// the cache grown for the old `indentString`.
// That should be rare - most users have an indent style they prefer, and use it consistently.
let { indent } = options;
if (typeof indent !== "string" || !INDENT_REGEX.test(indent)) indent = "\t";
if (indent !== indentString) {
indentString = indent;
indents.length = 1;
}
this.indents = indents;
this.indentString = indent;
// `.tsx` mode: lone type parameters print as `<T,>`
this.isJsx = options.jsx === true;
// Operator/token glue tracking
this.pendingIndentAsSpace = false;
// Start of output behaves like after whitespace. Reading the last character from `output`
// directly would flatten V8's rope representation on every append-then-read (quadratic),
// which is why the category is tracked rather than derived.
this.last = CAT_OTHER;
// Debug-only fields for checking `last` is correct on both writes and reads
if (DEBUG) {
this.lastIsStale = false;
this.lastCharWritten = "";
}
// `writeWithMap` records the output offset and original position of every mapped node,
// and `emitMappings` converts the offsets to generated line/column in one pass at the end
if (options.sourceMap == null) {
this.mapOffsets = null;
this.mapPositions = null;
this.mapNames = null;
} else {
this.mapOffsets = [];
this.mapPositions = [];
this.mapNames = [];
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// Acorn-JSX conformance.
//
// Tests this package prints every JSX fixture exactly as Rust `oxc_codegen` prints it.
import { readdir, readFile } from "node:fs/promises";
import { join as pathJoin } from "node:path";
import { describe, it } from "vitest";
import { checkFixture, JSX_DIR_PATH } from "./utils/common.ts";
// The directory holds the recorded ASTs and tokens alongside the sources.
// Only the sources matter here - both printers work from a fresh parse.
const fixturePaths = (await readdir(JSX_DIR_PATH, { recursive: true }))
.filter((path) => path.endsWith(".jsx"))
.sort();
describe.concurrent("JSX", () => {
for (const path of fixturePaths) {
// oxlint-disable-next-line vitest/valid-title, vitest/expect-expect
it(path, async (ctx) => {
const sourceText = await readFile(pathJoin(JSX_DIR_PATH, path), "utf8");
if (!checkFixture(path, sourceText, "jsx", "unambiguous")) ctx.skip();
});
}
});
+406
View File
@@ -0,0 +1,406 @@
// Leftmost-position parenthesization.
//
// Three constructs record the output offset at which their child expression begins - an expression
// statement, a concise arrow body, and `export default`. A node printed at exactly that offset is
// the leftmost token of the construct, and several node types parenthesize themselves on finding
// that they are: an object literal or an object-destructuring assignment would otherwise open what
// reads as a block, and a function or class expression would read as a declaration.
//
// This suite pins that observable behaviour, not the mechanism which produces it. Every case is a
// source snippet printed by Rust `oxc_codegen` and by this package, and the two must agree - so
// nothing here states where the parens belong, only that both printers put them in the same places.
// It is meant to survive a rewrite of the position-marker machinery, and to be the gate on one.
//
// The matrix is every expression which can be the leftmost token of a construct, crossed with the
// three constructs which record an offset. A combination which cannot be written at all - `yield`
// in an arrow body, or anything needing an enclosing function after `export default` - is skipped
// with its reason, rather than quietly dropped.
//
// A snippet which does not parse is a fault in the snippet, not a reason to skip it, so every case
// requires `checkFixture` to have compared something.
import { describe, expect, it } from "vitest";
import { checkFixture } from "./utils/common.ts";
import type { Lang } from "./utils/common.ts";
// --- Cases ------------------------------------------------------------------------------------
/**
* What an expression needs around it to be legal.
*
* Each context turns this into the enclosing code, or into the reason it cannot supply it.
*/
type Needs =
| "nothing"
/** `yield` */
| "generator"
/** `await` */
| "async"
/** `new.target` */
| "function"
/** A private name */
| "class"
/** `super` */
| "derived-class";
/**
* Where the bare source of an expression reads as something other than an expression.
*
* - `"block"` - a leading `{` opens a block at a statement start and a block body in an arrow, so
* both of those contexts parenthesize the source. `export default {}` is unambiguous, and takes it
* bare.
* - `"comma"` - a top-level `,` ends both an arrow body and an `export default`, so both of those
* contexts parenthesize the source. A statement takes it bare.
* - `"declaration"` - a leading `function` or `class` is a declaration at a statement start and after
* `export default`, so neither can host it. The `function-parenthesized` and `class-parenthesized`
* cases reach the marker in those two contexts instead.
*/
type Ambiguity = "block" | "comma" | "declaration";
interface Case {
/** Test name, unique within the matrix */
name: string;
/** Source of the expression whose leftmost token is under test. Must be a single line. */
expr: string;
/** What the expression needs around it. `"nothing"` when absent. */
needs?: Needs;
/** Where `expr` does not read as an expression */
ambiguity?: Ambiguity;
/** Language to parse as. `"js"` when absent. */
lang?: Lang;
/** `true` where the snippet is only legal in a module */
module?: boolean;
}
const CASES: Case[] = [
// Identifiers, `this`, and every literal kind
{ name: "identifier", expr: "x" },
{ name: "this", expr: "this" },
{ name: "string", expr: `"str"` },
{ name: "number", expr: "1" },
{ name: "number-decimal", expr: "1.5" },
{ name: "number-negative", expr: "-1" },
{ name: "bigint", expr: "1n" },
{ name: "bigint-negative", expr: "-1n" },
{ name: "regex", expr: "/a/" },
{ name: "regex-flags", expr: "/a/g" },
{ name: "boolean-true", expr: "true" },
{ name: "boolean-false", expr: "false" },
{ name: "null", expr: "null" },
// Member access, calls and `new`
{ name: "member-dot", expr: "x.y" },
{ name: "member-computed", expr: "x[0]" },
{ name: "member-deep", expr: "x.y.z.w" },
{ name: "member-private", expr: "this.#x", needs: "class" },
{ name: "call", expr: "f()" },
{ name: "call-arguments", expr: "f(1, 2)" },
{ name: "call-of-call", expr: "f()()" },
{ name: "call-of-member", expr: "x.y()" },
{ name: "call-parenthesized-callee", expr: "(f())()" },
{ name: "new", expr: "new C()" },
{ name: "new-no-arguments", expr: "new C" },
{ name: "new-member-callee", expr: "new x.C()" },
{ name: "new-call-callee", expr: "new (f())()" },
{ name: "template", expr: "`t`" },
{ name: "template-substitution", expr: "`a${b}c`" },
{ name: "tagged-template", expr: "tag`t`" },
{ name: "tagged-template-member-tag", expr: "x.tag`t`" },
// Optional chaining, which arrives wrapped in a `ChainExpression`
{ name: "chain-member", expr: "x?.y" },
{ name: "chain-computed", expr: "x?.[0]" },
{ name: "chain-call", expr: "x?.()" },
{ name: "chain-deep", expr: "x?.y.z" },
// Operators
{ name: "binary-add", expr: "x + y" },
{ name: "binary-exponent", expr: "x ** y" },
{ name: "binary-in", expr: "x in y" },
{ name: "binary-instanceof", expr: "x instanceof y" },
{ name: "logical-and", expr: "x && y" },
{ name: "logical-or", expr: "x || y" },
{ name: "logical-nullish", expr: "x ?? y" },
{ name: "private-in", expr: "#x in obj", needs: "class" },
{ name: "conditional", expr: "x ? y : z" },
{ name: "sequence", expr: "x, y", ambiguity: "comma" },
{ name: "unary-negate", expr: "-x" },
{ name: "unary-plus", expr: "+x" },
{ name: "unary-not", expr: "!x" },
{ name: "unary-bitwise-not", expr: "~x" },
{ name: "unary-typeof", expr: "typeof x" },
{ name: "unary-void", expr: "void 0" },
{ name: "unary-delete", expr: "delete x.y" },
{ name: "update-prefix-increment", expr: "++x" },
{ name: "update-prefix-decrement", expr: "--x" },
{ name: "update-postfix-increment", expr: "x++" },
{ name: "update-postfix-decrement", expr: "x--" },
// Assignment, including the two destructuring targets
{ name: "assign", expr: "x = 1" },
{ name: "assign-compound", expr: "x += 1" },
{ name: "assign-logical", expr: "x ||= 1" },
{ name: "assign-member-target", expr: "x.y = 1" },
{ name: "assign-object-pattern", expr: "{ a } = b", ambiguity: "block" },
{ name: "assign-object-pattern-nested", expr: "{ a: { b } } = c", ambiguity: "block" },
{ name: "assign-object-pattern-default", expr: "{ a = 1 } = b", ambiguity: "block" },
{ name: "assign-object-pattern-rest", expr: "{ ...a } = b", ambiguity: "block" },
{ name: "assign-array-pattern", expr: "[a] = b" },
{ name: "assign-array-pattern-hole", expr: "[, a] = b" },
{ name: "assign-array-pattern-rest", expr: "[...a] = b" },
// Object and array literals
{ name: "object-empty", expr: "{}", ambiguity: "block" },
{ name: "object-one-property", expr: "{ a: 1 }", ambiguity: "block" },
{ name: "object-two-properties", expr: "{ a: 1, b: 2 }", ambiguity: "block" },
{ name: "object-shorthand", expr: "{ a }", ambiguity: "block" },
{ name: "object-method", expr: "{ m() {} }", ambiguity: "block" },
{ name: "object-spread", expr: "{ ...x }", ambiguity: "block" },
{ name: "array-empty", expr: "[]" },
{ name: "array-elements", expr: "[1, 2]" },
{ name: "array-holding-object", expr: "[{}]" },
// Functions, classes and arrows
{ name: "function", expr: "function () {}", ambiguity: "declaration" },
{ name: "function-named", expr: "function f() {}", ambiguity: "declaration" },
{ name: "function-async", expr: "async function () {}", ambiguity: "declaration" },
{ name: "function-generator", expr: "function* () {}", ambiguity: "declaration" },
{ name: "function-parenthesized", expr: "(function () {})" },
{ name: "function-parenthesized-named", expr: "(function f() {})" },
{ name: "class", expr: "class {}", ambiguity: "declaration" },
{ name: "class-named", expr: "class C {}", ambiguity: "declaration" },
{ name: "class-extends", expr: "class extends D {}", ambiguity: "declaration" },
{ name: "class-parenthesized", expr: "(class {})" },
{ name: "class-parenthesized-named", expr: "(class C {})" },
{ name: "arrow", expr: "() => 1" },
{ name: "arrow-async", expr: "async () => 1" },
{ name: "arrow-single-parameter", expr: "x => x" },
{ name: "arrow-block-body", expr: "() => {}" },
{ name: "arrow-object-body", expr: "() => ({ a: 1 })" },
{ name: "arrow-of-arrow-object-body", expr: "() => () => ({ a: 1 })" },
// `await`, `yield`, `import()`, and the meta properties
{ name: "await", expr: "await x", needs: "async" },
{ name: "await-member", expr: "await x.y", needs: "async" },
{ name: "yield", expr: "yield x", needs: "generator" },
{ name: "yield-bare", expr: "yield", needs: "generator" },
{ name: "yield-delegate", expr: "yield* x", needs: "generator" },
{ name: "import-call", expr: `import("mod")` },
{ name: "import-call-member", expr: `import("mod").then` },
{ name: "import-meta", expr: "import.meta", module: true },
{ name: "import-meta-member", expr: "import.meta.url", module: true },
{ name: "new-target", expr: "new.target", needs: "function" },
// `super`
{ name: "super-call", expr: "super()", needs: "derived-class" },
{ name: "super-property", expr: "super.x", needs: "derived-class" },
{ name: "super-computed", expr: "super[0]", needs: "derived-class" },
// Sources whose parens survive into the AST in the preserve-parens mode
{ name: "parenthesized-identifier", expr: "(x)" },
{ name: "parenthesized-object", expr: "({})" },
{ name: "parenthesized-object-twice", expr: "(({}))" },
{ name: "parenthesized-sequence", expr: "(x, y)" },
{ name: "parenthesized-arrow", expr: "(() => 1)" },
// Nested leftmost chains - the node at the recorded offset is arbitrarily deep
{ name: "nested-object-member", expr: "({}).x" },
{ name: "nested-object-member-deep", expr: "({}).a.b.c" },
{ name: "nested-object-computed", expr: "({})[0]" },
{ name: "nested-object-call", expr: "({}).x()" },
{ name: "nested-object-optional", expr: "({})?.x" },
{ name: "nested-object-assign", expr: "({ a: 1 }).b = 2" },
{ name: "nested-object-update", expr: "({}).x++" },
{ name: "nested-object-binary", expr: "({}) + 1" },
{ name: "nested-object-instanceof", expr: "({}) instanceof Object" },
{ name: "nested-object-logical", expr: "({}) && x" },
{ name: "nested-object-conditional", expr: "({}) ? a : b" },
{ name: "nested-object-sequence", expr: "({}), 1", ambiguity: "comma" },
{ name: "nested-object-tagged-template", expr: "({}).tag`t`" },
{ name: "nested-object-new-callee", expr: "new (({}).C)()" },
{ name: "nested-object-pattern-assign-member", expr: "({ a } = b).c" },
{ name: "nested-function-call", expr: "(function () {})()" },
{ name: "nested-function-member", expr: "(function () {}).call()" },
{ name: "nested-function-assign", expr: "(function () {}).x = 1" },
{ name: "nested-function-binary", expr: "(function () {}) + 1" },
{ name: "nested-function-tagged-template", expr: "(function () {})`t`" },
{ name: "nested-function-async-call", expr: "(async function () {})()" },
{ name: "nested-function-generator-call", expr: "(function* () {})()" },
{ name: "nested-class-member", expr: "(class {}).x" },
{ name: "nested-class-call", expr: "(class {})()" },
{ name: "nested-class-binary", expr: "(class {}) + 1" },
{ name: "nested-arrow-call", expr: "(() => 1)()" },
// Shapes which must NOT be parenthesized - the node sits past the recorded offset
{ name: "object-as-argument", expr: "f({})" },
{ name: "object-as-assignment-value", expr: "x = {}" },
{ name: "object-as-property-value", expr: "({ a: {} })" },
{ name: "object-as-right-operand", expr: "1 + {}" },
{ name: "object-after-unary-not", expr: "!{}" },
{ name: "object-after-typeof", expr: "typeof {}" },
{ name: "function-as-argument", expr: "f(function () {})" },
{ name: "function-as-assignment-value", expr: "x = function () {}" },
{ name: "class-as-assignment-value", expr: "x = class {}" },
// JSX
{ name: "jsx-element", expr: "<div />", lang: "jsx" },
{ name: "jsx-element-children", expr: "<div>text</div>", lang: "jsx" },
{ name: "jsx-element-member-name", expr: "<a.b />", lang: "jsx" },
{ name: "jsx-fragment", expr: "<></>", lang: "jsx" },
{ name: "nested-jsx-member", expr: "(<div />).props", lang: "jsx" },
// TypeScript
{ name: "ts-as", expr: "x as any", lang: "ts" },
{ name: "ts-satisfies", expr: "x satisfies object", lang: "ts" },
{ name: "ts-non-null", expr: "x!", lang: "ts" },
{ name: "ts-type-assertion", expr: "<string> x", lang: "ts" },
{ name: "ts-instantiation", expr: "f<string>", lang: "ts" },
{ name: "ts-as-object", expr: "{} as any", lang: "ts", ambiguity: "block" },
{ name: "ts-satisfies-object", expr: "{} satisfies object", lang: "ts", ambiguity: "block" },
{ name: "ts-type-assertion-object", expr: "<any> {}", lang: "ts" },
{ name: "nested-ts-as-object", expr: "({} as any).x", lang: "ts" },
{ name: "nested-ts-non-null-object", expr: "({})!", lang: "ts" },
{ name: "nested-ts-non-null-object-member", expr: "({})!.x", lang: "ts" },
{ name: "nested-ts-as-function", expr: "(function () {}) as any", lang: "ts" },
// `TSParenthesizedType`, the type-level counterpart, which the preserve-parens mode keeps
{ name: "ts-as-parenthesized-type", expr: "x as (string)", lang: "ts" },
{ name: "ts-type-assertion-parenthesized-type", expr: "<(string)> x", lang: "ts" },
{ name: "nested-ts-as-parenthesized-type-object", expr: "({} as (any)).x", lang: "ts" },
];
// --- Contexts ---------------------------------------------------------------------------------
/** A built snippet, or the reason a context cannot host the expression. */
type Built = { source: string } | { skip: string };
interface Context {
/** Name of the construct which records the offset */
name: string;
/** `true` where the construct is only legal in a module */
module: boolean;
/** Build the snippet for a case, or give the reason this construct cannot host it */
build: (testCase: Case) => Built;
}
/**
* Put a statement inside whatever it needs around it.
*
* Every snippet opens with a throwaway `null;` immediately before the construct under test, so that
* the construct never starts at output offset 0 - where a marker still holding its initial value
* would match by accident - and so that a leading string literal is an expression statement rather
* than a directive.
*
* @param needs - What the statement needs around it
* @param statement - The statement, which must be a single line
* @returns Snippet source
*/
function enclose(needs: Needs, statement: string): string {
switch (needs) {
case "async":
return `async function f() {\n\tnull;\n\t${statement}\n}\n`;
case "generator":
return `function* g() {\n\tnull;\n\t${statement}\n}\n`;
case "function":
return `function f() {\n\tnull;\n\t${statement}\n}\n`;
case "class":
return `class C {\n\t#x;\n\tm(obj) {\n\t\tnull;\n\t\t${statement}\n\t}\n}\n`;
case "derived-class":
return `class C extends D {\n\tconstructor() {\n\t\tnull;\n\t\t${statement}\n\t}\n}\n`;
default:
return `null;\n${statement}\n`;
}
}
/** The reason `export default` cannot host an expression needing an enclosing construct. */
const EXPORT_DEFAULT_NEEDS_SKIPS: Partial<Record<Needs, string>> = {
generator:
"`yield` needs an enclosing generator, and `export default` is only legal at the top level of a module",
function:
"`new.target` needs an enclosing function, and `export default` is only legal at the top level of a module",
class:
"a private name needs an enclosing class body, and `export default` is only legal at the top level of a module",
"derived-class":
"`super` needs an enclosing class method, and `export default` is only legal at the top level of a module",
};
const CONTEXTS: Context[] = [
{
name: "expression statement",
module: false,
build({ expr, needs = "nothing", ambiguity }) {
if (ambiguity === "declaration") {
return {
skip:
"a statement starting `function` or `class` is a declaration, not an expression - " +
"the `function-parenthesized` and `class-parenthesized` cases cover this construct instead",
};
}
return { source: enclose(needs, `${ambiguity === "block" ? `(${expr})` : expr};`) };
},
},
{
name: "arrow body",
module: false,
build({ expr, needs = "nothing", ambiguity }) {
if (needs === "generator") {
return {
skip: "an arrow body is parsed without the enclosing generator's `yield`, so `yield` there is a syntax error",
};
}
const body = ambiguity === "block" || ambiguity === "comma" ? `(${expr})` : expr;
// An `await` body needs the arrow itself to be async, rather than an enclosing async function
if (needs === "async") return { source: enclose("nothing", `async () => ${body};`) };
return { source: enclose(needs, `() => ${body};`) };
},
},
{
name: "export default",
module: true,
build({ expr, needs = "nothing", ambiguity }) {
if (ambiguity === "declaration") {
return {
skip:
"`export default function () {}` is a `FunctionDeclaration` and `export default class {}` a " +
"`ClassDeclaration`, neither of which records an offset - the `function-parenthesized` and " +
"`class-parenthesized` cases cover this construct instead",
};
}
// `await` needs nothing: a module allows it at the top level
const needsSkip = EXPORT_DEFAULT_NEEDS_SKIPS[needs];
if (needsSkip !== undefined) return { skip: needsSkip };
return { source: `null;\nexport default ${ambiguity === "comma" ? `(${expr})` : expr};\n` };
},
},
];
// --- Tests ------------------------------------------------------------------------------------
describe.concurrent("leftmost position", () => {
for (const context of CONTEXTS) {
// oxlint-disable-next-line vitest/valid-title
describe(context.name, () => {
for (const testCase of CASES) {
const built = context.build(testCase);
// Reported as skipped, carrying the reason, rather than left out of the run
// oxlint-disable-next-line vitest/no-conditional-tests
if ("skip" in built) {
// oxlint-disable-next-line vitest/valid-title, vitest/expect-expect
it(testCase.name, (ctx) => ctx.skip(built.skip));
continue;
}
// oxlint-disable-next-line vitest/valid-title
it(testCase.name, () => {
const lang = testCase.lang ?? "js";
const sourceType = context.module || testCase.module ? "module" : "script";
const checked = checkFixture(`leftmost.${lang}`, built.source, lang, sourceType);
expect(checked, `snippet does not parse:\n${built.source}`).toBe(true);
});
}
});
}
});
+403
View File
@@ -0,0 +1,403 @@
// Hand-built AST tests, for the shapes the conformance suites cannot reach.
//
// The suites print ASTs produced by a parser, so anything a parser never produces is invisible to
// them. The clearest case is a negative numeric literal: `-1` parses as a `UnaryExpression` around
// `Literal(1)`, so `Literal(-1)` never appears, and the printer's whole negative-number path is
// unreachable from any fixture. A stale identifier on that path once survived all 58,609 suite
// fixtures and only turned up in `node --run lint`.
//
// The ASTs here are written by hand to reach exactly those places, and each case states the output
// the printer is meant to produce for it. Anything which changes an expected string is either a bug
// or a deliberate change which needs the string updated with it.
import { parseSync } from "oxc-parser";
import { describe, expect, test } from "vitest";
import { printSync } from "../dist/index.js";
import type * as ESTree from "../../../npm/oxc-types/types.d.ts";
// Parse options for the cases which give source rather than an AST.
//
// `preserveParens: false` because this printer deliberately does not support the redundant
// `ParenthesizedExpression` node. `experimentalRawTransfer` is how the printer is meant to be used,
// so the ASTs are the shape it really sees.
//
// `experimentalRawTransfer` is not in the published `ParserOptions` type - it is experimental and
// untyped. Declaring the object here rather than inline at the call site keeps TypeScript's excess
// property check (which only applies to object literals) from rejecting it, without a cast.
const PARSE_OPTIONS = {
preserveParens: false,
experimentalRawTransfer: true,
};
// --- AST builders ---------------------------------------------------------------------------
/**
* Build an AST node.
*
* The printer reads only the fields these builders set, so the nodes below carry none of the
* `start` / `end` positions a parser would attach. This puts the cast that says so in one place,
* rather than at every builder.
*
* @param fields - Fields the node needs
* @returns The node
*/
const node = <T extends ESTree.Node>(fields: Partial<T>): T => fields as T;
const program = (...body: ESTree.Statement[]) =>
node<ESTree.Program>({ type: "Program", sourceType: "module", body });
const stmt = (expression: ESTree.Expression) =>
node<ESTree.ExpressionStatement>({ type: "ExpressionStatement", expression });
const num = (value: number) => node<ESTree.NumericLiteral>({ type: "Literal", value });
const str = (value: string) => node<ESTree.StringLiteral>({ type: "Literal", value });
const id = (name: string) => node<ESTree.IdentifierReference>({ type: "Identifier", name });
const regex = (pattern: string, flags: string) =>
node<ESTree.RegExpLiteral>({ type: "Literal", value: null, regex: { pattern, flags } });
const big = (value: string) => node<ESTree.BigIntLiteral>({ type: "Literal", bigint: value });
const member = (object: ESTree.Expression, property: ESTree.IdentifierName) =>
node<ESTree.StaticMemberExpression>({
type: "MemberExpression",
object,
property,
computed: false,
optional: false,
});
const call = (callee: ESTree.Expression, args: ESTree.Argument[] = []) =>
node<ESTree.CallExpression>({
type: "CallExpression",
callee,
arguments: args,
optional: false,
});
const unary = (operator: ESTree.UnaryOperator, argument: ESTree.Expression) =>
node<ESTree.UnaryExpression>({ type: "UnaryExpression", operator, argument, prefix: true });
const update = (
operator: ESTree.UpdateOperator,
argument: ESTree.SimpleAssignmentTarget,
prefix = true,
) => node<ESTree.UpdateExpression>({ type: "UpdateExpression", operator, argument, prefix });
const bin = (operator: ESTree.BinaryOperator, left: ESTree.Expression, right: ESTree.Expression) =>
node<ESTree.BinaryExpression>({ type: "BinaryExpression", operator, left, right });
const seq = (...expressions: ESTree.Expression[]) =>
node<ESTree.SequenceExpression>({ type: "SequenceExpression", expressions });
const arr = (...elements: ESTree.ArrayExpressionElement[]) =>
node<ESTree.ArrayExpression>({ type: "ArrayExpression", elements });
const ret = (argument: ESTree.Expression) =>
node<ESTree.ReturnStatement>({ type: "ReturnStatement", argument });
// One expression statement, which is how nearly every case below is shaped
const e = (expression: ESTree.Expression) => program(stmt(expression));
// --- Runner ---------------------------------------------------------------------------------
/** A case: its name, the AST to print, and the output the printer must produce for it. */
type Case = [name: string, ast: ESTree.Node, output: string];
/**
* Define one test per case, checking the printer's output is exactly the expected string.
*
* @param cases - The cases
*/
function checkCases(cases: Case[]): void {
test.each(cases)("%s", (_name, ast, output) => {
expect(printSync(ast)).toBe(output);
});
}
// --- Tests ----------------------------------------------------------------------------------
// A numeric literal followed by `.` needs a space when the number is plain digits, because
// `0.toExponential()` would lex the `.` as part of the number. This is the `needSpaceBeforeDot`
// path, and every case below is unreachable from a parsed fixture in at least one respect.
describe("member access on numbers", () => {
checkCases([
["int-zero", e(call(member(num(0), id("toExponential")))), "0 .toExponential();\n"],
["int-one", e(member(num(1), id("x"))), "1 .x;\n"],
["int-large", e(member(num(255), id("x"))), "255 .x;\n"],
["int-999", e(member(num(999), id("x"))), "999 .x;\n"],
["int-1000", e(member(num(1000), id("x"))), "1e3.x;\n"],
["decimal", e(member(num(0.5), id("x"))), ".5.x;\n"],
["decimal-leading", e(member(num(1.5), id("x"))), "1.5.x;\n"],
["exponent-large", e(member(num(1e21), id("x"))), "1e21.x;\n"],
["exponent-small", e(member(num(1e-7), id("x"))), "1e-7.x;\n"],
["negative-int", e(member(num(-1), id("x"))), "(-1).x;\n"],
["negative-decimal", e(member(num(-0.5), id("x"))), "(-.5).x;\n"],
["negative-infinity", e(member(num(-Infinity), id("x"))), "(-Infinity).x;\n"],
["infinity", e(member(num(Infinity), id("x"))), "Infinity.x;\n"],
["nan", e(member(num(NaN), id("x"))), "NaN.x;\n"],
["chained-int", e(member(member(num(7), id("x")), id("y"))), "7 .x.y;\n"],
["call-arg-int", e(call(id("f"), [member(num(3), id("x"))])), "f(3 .x);\n"],
// The shortened forms, which all carry a `.`, an `e` or an `x` of their own and so must NOT
// gain the space, against the long integers which stay plain digits and must
["int-1500", e(member(num(1500), id("x"))), "1500 .x;\n"],
["int-max-safe", e(member(num(9007199254740991), id("x"))), "9007199254740991 .x;\n"],
["hex", e(member(num(281474976710655), id("x"))), "0xffffffffffff.x;\n"],
["exponent-fold", e(member(num(1.2e101), id("x"))), "12e100.x;\n"],
]);
});
describe("numbers", () => {
// Bare numeric literals, including the negative and non-finite paths
checkCases([
["int", e(num(42)), "42;\n"],
["zero", e(num(0)), "0;\n"],
["negative-zero", e(num(-0)), "-0;\n"],
["negative-int", e(num(-1)), "-1;\n"],
["negative-decimal", e(num(-0.5)), "-.5;\n"],
["negative-large", e(num(-1e21)), "-1e21;\n"],
["negative-small", e(num(-0.000001)), "-1e-6;\n"],
["infinity", e(num(Infinity)), "Infinity;\n"],
["negative-infinity", e(num(-Infinity)), "-Infinity;\n"],
["nan", e(num(NaN)), "NaN;\n"],
["exponent", e(num(1e21)), "1e21;\n"],
["return-negative", program(ret(num(-1))), "return -1;\n"],
["return-int", program(ret(num(1))), "return 1;\n"],
["binary-negative", e(bin("+", id("a"), num(-1))), "a + -1;\n"],
[
"array-negatives",
e(arr(num(-1), num(-0), num(-Infinity))),
"[\n\t-1,\n\t-0,\n\t-Infinity\n];\n",
],
]);
// The shortest-form rules. A number prints as whichever of plain digits, hexadecimal and
// exponent notation is shortest, and the suites reach almost none of it: real code writes small
// integers. Every form and every "not worth it" boundary is here.
checkCases([
["int-1500", e(num(1500)), "1500;\n"], // trailing zeros too few to pay for the `e`
// 16 digits, and hexadecimal still does not pay
["int-max-safe", e(num(9007199254740991)), "9007199254740991;\n"],
["hex", e(num(281474976710655)), "0xffffffffffff;\n"],
["hex-negative", e(num(-281474976710655)), "-0xffffffffffff;\n"],
// hexadecimal is tried first and wins, though `1e12` is shorter
["hex-over-exponent", e(num(1e12)), "0xe8d4a51000;\n"],
// The two cases above reach the hexadecimal test from plain digits. These reach it from
// exponent notation, where the length it is judged against is one less than the text `String`
// gave, because the `+` of the exponent always goes. The second is the boundary: hexadecimal is
// exactly as long as the exponent form, so it must lose - and would wrongly win, as
// `0x21e2073de72ea800000`, if that one character were not taken off.
["hex-from-exponent", e(num(1.0000990573316814e21)), "0x36372999e429e40000;\n"],
["hex-from-exponent-boundary", e(num(1.0000473745167254e22)), "10000473745167254e6;\n"],
["exponent-fold", e(num(1.2e101)), "12e100;\n"], // the point folds into the exponent
["max-value", e(num(1.7976931348623157e308)), "17976931348623157e292;\n"],
["min-value", e(num(5e-324)), "5e-324;\n"],
["small-fold", e(num(1.5e-7)), "15e-8;\n"],
["small-no-fold", e(num(1.5e-9)), "1.5e-9;\n"], // folding would lengthen the exponent
["decimal-thousandth", e(num(0.001)), ".001;\n"], // leading zeros too few to pay for the `e-`
["decimal-ten-thousandth", e(num(0.0001)), "1e-4;\n"],
]);
});
// A negative bigint is parenthesized where the position binds tighter than a prefix operator, the
// same rule `printNumericLiteral` follows. `-1n` parses as a unary minus around a positive literal,
// so a `BigIntLiteral` whose text starts with `-` never comes from a parser and this whole branch
// is unreachable from any fixture.
describe("bigints", () => {
checkCases([
["positive", e(big("1")), "1n;\n"],
["negative", e(big("-1")), "-1n;\n"],
["negative-hex", e(big("-0x10")), "-0x10n;\n"],
["positive-member", e(member(big("1"), id("x"))), "1n.x;\n"],
["negative-member", e(member(big("-1"), id("x"))), "(-1n).x;\n"],
["negative-typeof", e(unary("typeof", big("-1"))), "typeof -1n;\n"],
]);
});
// A regex with no flags leaves the closing `/` as the last thing written, which is what stops
// `/a//b/` lexing as a line comment and what forces a space before a following identifier.
describe("regexes", () => {
checkCases([
["no-flags", e(regex("a", "")), "/a/;\n"],
["flags", e(regex("a", "g")), "/a/g;\n"],
["member-no-flags", e(call(member(regex("a", ""), id("test")), [id("x")])), "/a/.test(x);\n"],
["member-flags", e(call(member(regex("a", "gi"), id("test")), [id("x")])), "/a/gi.test(x);\n"],
["in-array", e(arr(regex("a", ""), regex("b", "g"))), "[/a/, /b/g];\n"],
["sequence", e(seq(regex("a", ""), regex("b", ""))), "/a/, /b/;\n"],
["typeof", e(unary("typeof", regex("a", ""))), "typeof /a/;\n"],
["divide", e(bin("/", regex("a", ""), id("x"))), "/a/ / x;\n"],
["instanceof", e(bin("instanceof", regex("a", ""), id("RegExp"))), "/a/ instanceof RegExp;\n"],
["script-pattern", e(regex("script", "")), "/script/;\n"],
["return-no-flags", program(ret(regex("a", ""))), "return /a/;\n"],
]);
});
// `+ +x` and `- -x` need a separating space; `+ -x` does not. This is the whole
// `printSpaceBeforeOperator` family.
describe("unary and update expressions", () => {
checkCases([
["plus-plus", e(unary("+", unary("+", id("x")))), "+ +x;\n"],
["minus-minus", e(unary("-", unary("-", id("x")))), "- -x;\n"],
["plus-minus", e(unary("+", unary("-", id("x")))), "+-x;\n"],
["minus-plus", e(unary("-", unary("+", id("x")))), "-+x;\n"],
["plus-preinc", e(unary("+", update("++", id("x")))), "+ ++x;\n"],
["minus-predec", e(unary("-", update("--", id("x")))), "- --x;\n"],
["plus-predec", e(unary("+", update("--", id("x")))), "+--x;\n"],
["minus-preinc", e(unary("-", update("++", id("x")))), "-++x;\n"],
["not-not", e(unary("!", unary("!", id("x")))), "!!x;\n"],
["tilde-tilde", e(unary("~", unary("~", id("x")))), "~~x;\n"],
["not-predec", e(unary("!", update("--", id("x")))), "!--x;\n"],
["typeof", e(unary("typeof", id("x"))), "typeof x;\n"],
["void", e(unary("void", num(0))), "void 0;\n"],
["delete", e(unary("delete", member(id("x"), id("y")))), "delete x.y;\n"],
["delete-infinity", e(unary("delete", num(Infinity))), "delete (0, Infinity);\n"],
["minus-negative-literal", e(unary("-", num(-1))), "- -1;\n"],
["plus-negative-literal", e(unary("+", num(-1))), "+-1;\n"],
["preinc", e(update("++", id("x"))), "++x;\n"],
["postinc", e(update("++", id("x"), false)), "x++;\n"],
["predec", e(update("--", id("x"))), "--x;\n"],
["postdec", e(update("--", id("x"), false)), "x--;\n"],
["postinc-plus", e(bin("+", update("++", id("x"), false), id("y"))), "x++ + y;\n"],
["preinc-of-member-int", e(update("++", member(id("x"), id("y")))), "++x.y;\n"],
]);
});
// The directive prologue. A parenthesised string is NOT a directive, so the parentheses have to
// survive printing or it is read back as one - which can silently make a function strict. The
// parser never produces these shapes ambiguously, but it does produce ASTs where the only thing
// distinguishing the two is `.directive`, so these lock the round-trip in.
//
// These cases give source rather than an AST, because `.directive` - the field under test - is one
// the parser sets, and hand-building it correctly is exactly what is in question.
describe("directives", () => {
test.each<[name: string, source: string, output: string]>([
["paren-string-alone", `("moon");`, `("moon");\n`],
["after-one", `"use strict";\n("moon");`, `"use strict";\n("moon");\n`],
["after-two", `"use strict";\n"use asm";\n("moon");`, `"use strict";\n"use asm";\n("moon");\n`],
["prologue-closed", `"use strict";\nfoo;\n("moon");`, `"use strict";\nfoo;\n"moon";\n`],
["mode-change", `"use asm";\n("use strict");`, `"use asm";\n("use strict");\n`],
[
"mode-change-fn",
`function f(){ "use asm"; ("use strict"); return 1; }`,
`function f() {\n\t"use asm";\n\t("use strict");\n\treturn 1;\n}\n`,
],
["plain", `"use strict";\nfoo;`, `"use strict";\nfoo;\n`],
["only", `"use strict";`, `"use strict";\n`],
["none", `foo;\nbar;`, `foo;\nbar;\n`],
["string-mid-body", `foo;\n"bar";`, `foo;\n"bar";\n`],
])("%s", (_name, source, output) => {
const { program: parsed, errors } = parseSync("fixture.js", source, PARSE_OPTIONS);
if (errors.length > 0) throw new Error(`fixture parse failed: ${errors[0].message}`);
expect(printSync(parsed)).toBe(output);
});
});
// A keyword or identifier straight after something which ends in an identifier character needs
// a space - the `printSpaceBeforeIdentifier` path
describe("space before identifiers", () => {
checkCases([
["return-ident", program(ret(id("x"))), "return x;\n"],
["typeof-typeof", e(unary("typeof", unary("typeof", id("x")))), "typeof typeof x;\n"],
["void-typeof", e(unary("void", unary("typeof", id("x")))), "void typeof x;\n"],
["string-concat", e(bin("+", str("a"), id("b"))), '"a" + b;\n'],
]);
});
// The three `export default` declaration forms - interface, class and function - each end with a
// newline, so whatever follows starts on its own line. `export default interface` printed without
// one until `oxc_codegen` was fixed, gluing the next statement onto the closing brace.
//
// No conformance fixture contains the construct, so these cases are the only thing holding it in
// place. They give source rather than an AST, because what is under test is the separation between
// two statements.
describe("export default declarations", () => {
test.each<[name: string, source: string, output: string]>([
[
"interface",
`export default interface X {}\nconst y = 1;`,
`export default interface X {}\nconst y = 1;\n`,
],
["interface-alone", `export default interface X {}`, `export default interface X {}\n`],
[
"class",
`export default class X {}\nconst y = 1;`,
`export default class X {}\nconst y = 1;\n`,
],
[
"function",
`export default function X() {}\nconst y = 1;`,
`export default function X() {}\nconst y = 1;\n`,
],
])("%s", (_name, source, output) => {
const { program: parsed, errors } = parseSync("fixture.ts", source, PARSE_OPTIONS);
if (errors.length > 0) throw new Error(`fixture parse failed: ${errors[0].message}`);
expect(printSync(parsed, { ts: true })).toBe(output);
});
});
// A JSX string prints from its raw source text, and the only decision is which quote to wrap it in -
// the text itself is never escaped, because JSX strings have no escape sequences. The parser does
// not decode HTML entities at present, so `&quot;` is six characters and not a `"`, and `raw` and
// `value` therefore hold the same text.
//
// Each case is printed twice: once as parsed, which takes the `raw` path, and once with `raw`
// blanked, which takes the `value` path. Both must give the same output, and that is what breaks
// when entities start being decoded - `value` becomes the decoded text while `raw` does not, so the
// second assertion fails. Checking only parsed output would prove nothing, since the printer would
// go on reading the unchanged `raw`.
//
// The conformance suites cannot catch this either: both printers read the same parser output, so
// they would move together and stay byte-identical. Hence pinned expected strings here.
describe("JSX strings", () => {
test.each<[name: string, source: string, output: string]>([
["plain", `<Foo bar="x" />;`, `<Foo bar="x" />;\n`],
["single-quote-in-value", `<Foo bar="'" />;`, `<Foo bar="'" />;\n`],
["double-quote-in-value", `<Foo bar='"' />;`, `<Foo bar='"' />;\n`],
["quote-normalized", `<Foo bar='&apos;' />;`, `<Foo bar="&apos;" />;\n`],
["entity-amp", `<Foo bar="&amp;" />;`, `<Foo bar="&amp;" />;\n`],
// The three which change the moment entities are decoded - each holds an apostrophe alongside
// a `&quot;` which becomes the double quote the chosen wrapper then cannot contain
["entity-quot", `<Foo bar="&quot;" />;`, `<Foo bar="&quot;" />;\n`],
["entity-quot-with-apostrophe", `<Foo bar="'&quot;" />;`, `<Foo bar="'&quot;" />;\n`],
["entity-quot-mixed", `<Foo bar="a'b&quot;c" />;`, `<Foo bar="a'b&quot;c" />;\n`],
// `JSXText` reads `raw` the same way, so it carries the same hazard
["text-entity", `<Foo>a&amp;b</Foo>;`, `<Foo>a&amp;b</Foo>;\n`],
["text-entity-quot", `<Foo>&quot;</Foo>;`, `<Foo>&quot;</Foo>;\n`],
])("%s", (_name, source, output) => {
const parse = () => {
const { program: parsed, errors } = parseSync("fixture.jsx", source, PARSE_OPTIONS);
if (errors.length > 0) throw new Error(`fixture parse failed: ${errors[0].message}`);
return parsed;
};
// From `raw`, as a parser supplies it
expect(printSync(parse(), { jsx: true })).toBe(output);
// From `value`, which is where a change in the parser would show
const fromValue = parse();
dropJsxRaw(fromValue);
expect(printSync(fromValue, { jsx: true })).toBe(output);
});
});
/**
* Blank `raw` on every JSX string node, so the printer prints from `value` instead.
*
* `printJSXAttributeValue` and the `JSXText` arm both prefer `raw` wherever there is one,
* and a parser always supplies one, so parsed input never reaches the `value` path at all.
* Only a hand-built AST does - and that path is the one which moves when the parser changes.
*
* @param node - Node, or any value which might contain one
*/
function dropJsxRaw(node: unknown): void {
if (typeof node !== "object" || node === null) return;
if (Array.isArray(node)) {
for (const child of node) {
dropJsxRaw(child);
}
return;
}
const fields = node as Record<string, unknown>;
if (fields.type === "JSXText") {
fields.raw = undefined;
} else if (fields.type === "JSXAttribute") {
const value = fields.value as Record<string, unknown> | null | undefined;
if (value != null && value.type === "Literal") value.raw = undefined;
}
for (const key in fields) {
dropJsxRaw(fields[key]);
}
}
+392
View File
@@ -0,0 +1,392 @@
// Source map tests.
//
// Source maps are the one part of the printer whose output is not the printed code, so the
// conformance suites say nothing about them. These check the mappings a print emits are
// self-consistent and point where they claim to, and that turning source maps on does not change
// the code that comes out.
//
// `oxc-parser` emits no `loc`, so it is synthesized here from `start` / `end` offsets.
import { existsSync, readFileSync } from "node:fs";
import { join as pathJoin } from "node:path";
import { parseSync } from "oxc-parser";
import { beforeAll, describe, expect, test } from "vitest";
import { printSync } from "../dist/index.js";
import type { Program } from "oxc-parser";
import type { Mapping, Position, SourceMapGenerator } from "../dist/index.js";
// Same directory the benchmarks download their fixtures to. Whichever are already cached are used;
// the inline fixtures below always run.
const CACHE_DIR_PATH = pathJoin(import.meta.dirname, "../../../target");
// `preserveParens: false` because this printer deliberately does not support the redundant
// `ParenthesizedExpression` / `TSParenthesizedType` nodes. `experimentalRawTransfer` is how the
// printer is meant to be used, so the ASTs here are the shape it really sees.
const PARSE_OPTIONS = {
preserveParens: false,
experimentalRawTransfer: true,
};
const INLINE_JS = `
class Foo extends Bar {
static #x = 1;
get y() {
if (this.z) {
for (const k of [1, 2, 3]) {
console.log(\`\${k}-\${this.z}\`);
}
}
return { a: 1, b: 2, c: this.#x };
}
}
function outer(a, b = 2, ...rest) {
label: while (a) {
try {
a = b;
} catch (e) {
break label;
} finally {
b++;
}
}
return outer;
}
export default outer;
`;
const INLINE_TS = `
namespace NS {
export interface Shape<T extends object = {}> {
readonly kind: "a" | "b";
fn?(x: T): asserts x is T;
}
export abstract class Impl<T> implements Shape<object> {
declare readonly kind: "a";
constructor(private readonly x: number, protected y?: string) { super(); }
abstract go<U>(v: U): U;
}
export enum E { A = 1, B }
export type Mapped<T> = { [K in keyof T]-?: T[K] };
}
export default NS;
`;
interface Fixture {
name: string;
/** Source text, or `null` if the fixture is a cached file which has not been downloaded */
code: string | null;
ts: boolean;
jsx: boolean;
}
/**
* Read a fixture the benchmarks download, or `null` if it is not in the cache.
*
* @param name - Fixture filename
* @returns Source text, or `null` if not cached
*/
function cached(name: string): string | null {
const path = pathJoin(CACHE_DIR_PATH, name);
return existsSync(path) ? readFileSync(path, "utf8") : null;
}
const FIXTURES: Fixture[] = [
{ name: "inline.js", code: INLINE_JS, ts: false, jsx: false },
{ name: "inline.ts", code: INLINE_TS, ts: true, jsx: false },
{ name: "react.development.js", code: cached("react.development.js"), ts: false, jsx: false },
{
name: "RadixUIAdoptionSection.jsx",
code: cached("RadixUIAdoptionSection.jsx"),
ts: false,
jsx: true,
},
{ name: "binder.ts", code: cached("binder.ts"), ts: true, jsx: false },
{ name: "kitchen-sink.tsx", code: cached("kitchen-sink.tsx"), ts: true, jsx: true },
];
// --- Helpers --------------------------------------------------------------------------------
/**
* Build a function converting a source offset to a line and column.
*
* @param source - Source text
* @returns Function taking an offset and returning a 1-based line and 0-based column
*/
function lineColTable(source: string): (offset: number) => Position {
const lineStarts = [0];
for (let i = 0; i < source.length; i++) {
if (source.charCodeAt(i) === 10) lineStarts.push(i + 1);
}
return (offset) => {
let lo = 0,
hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= offset) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: offset - lineStarts[lo] };
};
}
/**
* Add a `loc` to every node in the AST, computed from its `start` / `end` offsets.
*
* @param root - AST to walk
* @param posOf - Function converting an offset to a line and column
*/
function addLocs(root: object, posOf: (offset: number) => Position): void {
const seen = new Set<object>();
const stack: unknown[] = [root];
while (stack.length > 0) {
const node = stack.pop();
if (node === null || typeof node !== "object") continue;
if (seen.has(node)) continue;
seen.add(node);
if (Array.isArray(node)) {
for (const child of node) stack.push(child);
continue;
}
const record = node as Record<string, unknown>;
if (typeof record.type === "string" && typeof record.start === "number") {
record.loc = { start: posOf(record.start), end: posOf(record.end as number) };
}
for (const key in record) {
if (key === "loc" || key === "parent") continue;
const child = record[key];
if (child !== null && typeof child === "object") stack.push(child);
}
}
}
/** A mapping, copied out of the `Mapping` object the printer reuses across calls. */
interface Recorded {
generatedLine: number;
generatedColumn: number;
originalLine: number;
originalColumn: number;
name: string | undefined;
source: string;
}
/** Collects the mappings a print emits. This is the `sourceMap` option's whole interface. */
class Collector implements SourceMapGenerator {
file: string;
mappings: Recorded[] = [];
constructor(file: string) {
this.file = file;
}
addMapping(mapping: Mapping): void {
// `Mapping` objects are reused across calls - must copy
this.mappings.push({
generatedLine: mapping.generated.line,
generatedColumn: mapping.generated.column,
originalLine: mapping.original.line,
originalColumn: mapping.original.column,
name: mapping.name,
source: mapping.source,
});
}
}
/**
* Parse a fixture and add a `loc` to every node.
*
* @param name - Fixture filename
* @param code - Source text
* @returns The AST
*/
function parseWithLocs(name: string, code: string): Program {
const { program, errors } = parseSync(name, code, PARSE_OPTIONS);
if (errors.length > 0) throw new Error(`parse ${name}: ${errors[0].message}`);
addLocs(program, lineColTable(code));
return program;
}
const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;
// --- Per-fixture tests ----------------------------------------------------------------------
interface Printed {
withMaps: string;
withoutMaps: string;
mappings: Recorded[];
outLines: string[];
srcLines: string[];
}
for (const fixture of FIXTURES) {
// A cached fixture which has not been downloaded is reported as skipped rather than passing
// quietly. `pnpm run bench` downloads them.
const describeFixture = fixture.code === null ? describe.skip : describe;
describeFixture(fixture.name, () => {
let printed!: Printed;
beforeAll(() => {
const code = fixture.code as string;
const program = parseWithLocs(fixture.name, code);
const collector = new Collector(fixture.name);
const { jsx, ts } = fixture;
// Both builds through the public API - `printSync` picks the maps build when given a
// `sourceMap`, and the no-maps build when not
const withMaps = printSync(program, { jsx, ts, sourceMap: collector });
const withoutMaps = printSync(program, { jsx, ts });
printed = {
withMaps,
withoutMaps,
mappings: collector.mappings,
outLines: withMaps.split("\n"),
srcLines: code.split("\n"),
};
});
test("source maps do not change the printed code", () => {
expect(printed.withMaps).toBe(printed.withoutMaps);
});
test("emits mappings", () => {
expect(printed.mappings.length).toBeGreaterThan(0);
});
test("generated positions never go backwards", () => {
let prevLine = 0,
prevCol = 0;
let outOfOrder = null;
for (const mapping of printed.mappings) {
const { generatedLine: line, generatedColumn: col } = mapping;
if (line < prevLine || (line === prevLine && col < prevCol)) {
outOfOrder = `${line}:${col} follows ${prevLine}:${prevCol}`;
break;
}
prevLine = line;
prevCol = col;
}
expect(outOfOrder).toBeNull();
});
test("generated positions are within the printed output", () => {
let outOfRange = null;
for (const { generatedLine, generatedColumn } of printed.mappings) {
const line = printed.outLines[generatedLine - 1];
if (line === undefined || generatedColumn > line.length) {
outOfRange = `${generatedLine}:${generatedColumn}`;
break;
}
}
expect(outOfRange).toBeNull();
});
test("original positions are within the source", () => {
let outOfRange = null;
for (const { originalLine, originalColumn } of printed.mappings) {
const line = printed.srcLines[originalLine - 1];
if (line === undefined || originalColumn > line.length) {
outOfRange = `${originalLine}:${originalColumn}`;
break;
}
}
expect(outOfRange).toBeNull();
});
test("every mapping names the source file", () => {
const wrong = printed.mappings.find(
(mapping) => mapping.source !== fixture.name && mapping.source !== undefined,
);
expect(wrong).toBeUndefined();
});
// Where a mapping lands on an identifier in the output, the source position it points at
// should hold the same identifier. A handful disagree legitimately - a printed name can come
// from a node whose span starts at a keyword - so this is a ratio rather than an absolute.
test("identifier text agrees at mapped positions", () => {
let checked = 0,
mismatched = 0;
for (const mapping of printed.mappings) {
const outLine = printed.outLines[mapping.generatedLine - 1];
const srcLine = printed.srcLines[mapping.originalLine - 1];
if (outLine === undefined || srcLine === undefined) continue;
const generatedIdent = IDENT_RE.exec(outLine.slice(mapping.generatedColumn));
const originalIdent = IDENT_RE.exec(srcLine.slice(mapping.originalColumn));
if (generatedIdent !== null && originalIdent !== null) {
checked++;
if (generatedIdent[0] !== originalIdent[0]) mismatched++;
}
}
// Guard against the ratio passing because nothing was compared
expect(checked).toBeGreaterThan(0);
expect(1 - mismatched / checked).toBeGreaterThan(0.97);
});
});
}
// --- Indentation ----------------------------------------------------------------------------
// `printIndent` goes through the same write path as everything else, so the `indent` option and
// the mappings have to agree: indentation is honoured (or falls back to a tab), and no mapping
// ever points into the middle of an indent run.
//
// The invalid values are typed `unknown` because they are what an untyped JS caller can pass -
// that they fall back to a tab rather than corrupting the output is the point of testing them.
const INDENT_CASES: unknown[] = [
// Valid - used as given
undefined,
"\t",
" ",
" ",
"\t\t",
" \t",
"\t ",
// Invalid - must fall back to a tab
"",
"x",
"\n",
"\r\n",
" x ",
" ",
4,
null,
{},
];
describe("indent option", () => {
test.each(
INDENT_CASES.map((indent) => ({ label: JSON.stringify(indent) ?? "undefined", indent })),
)("indent=$label", ({ indent }) => {
const program = parseWithLocs("indent.js", INLINE_JS);
const collector = new Collector("indent.js");
const out = printSync(program, {
indent: indent as string | undefined,
sourceMap: collector,
});
const expectedIndent = typeof indent === "string" && /^[ \t]+$/.test(indent) ? indent : "\t";
const lines = out.split("\n");
const indented = lines.filter((line) => line.startsWith("\t") || line.startsWith(" "));
expect(indented.length).toBeGreaterThan(0);
// Every indented line's leading whitespace is a whole number of indent levels
for (const line of indented) {
const lead = /^[\t ]+/.exec(line)![0];
expect(lead.length % expectedIndent.length).toBe(0);
expect(lead).toBe(expectedIndent.repeat(lead.length / expectedIndent.length));
}
// No mapping points into an indent run
let insideIndent = null;
for (const mapping of collector.mappings) {
const lead = /^[\t ]*/.exec(lines[mapping.generatedLine - 1])![0].length;
if (mapping.generatedColumn !== 0 && mapping.generatedColumn < lead) {
insideIndent = `${mapping.generatedLine}:${mapping.generatedColumn}`;
break;
}
}
expect(insideIndent).toBeNull();
});
});
+24
View File
@@ -0,0 +1,24 @@
// Test262 conformance.
//
// Tests this package prints every Test262 fixture exactly as Rust `oxc_codegen` prints it.
import { readdir, readFile } from "node:fs/promises";
import { join as pathJoin } from "node:path";
import { describe, it } from "vitest";
import { checkFixture, TEST262_DIR_PATH, test262SourceType } from "./utils/common.ts";
// Test262 keeps its fixtures' shared helpers in `harness`, which are not test cases
const fixturePaths = (await readdir(TEST262_DIR_PATH, { recursive: true }))
.filter((path) => path.endsWith(".js") && !path.startsWith("harness/"))
.sort();
describe.concurrent("test262", () => {
for (const path of fixturePaths) {
// oxlint-disable-next-line vitest/valid-title, vitest/expect-expect
it(path, async (ctx) => {
const sourceText = await readFile(pathJoin(TEST262_DIR_PATH, path), "utf8");
if (!checkFixture(path, sourceText, "js", test262SourceType(sourceText))) ctx.skip();
});
}
});
+79
View File
@@ -0,0 +1,79 @@
// TypeScript conformance.
//
// Tests this package prints every TypeScript fixture exactly as Rust `oxc_codegen` prints it.
//
// A TypeScript test file can hold several units, separated by `// @filename:` directives,
// each with its own language and module setting. `makeUnitsFromTest` splits them out,
// and every unit is checked.
import { readdir, readFile } from "node:fs/promises";
import { join as pathJoin } from "node:path";
import { describe, it } from "vitest";
import { checkFixture, langOf, TS_CASES_DIR_PATH } from "./utils/common.ts";
import { makeUnitsFromTest } from "./utils/typescript-make-units-from-test.ts";
// The two directories which hold the test cases.
// The rest of `tests/cases` is editor scenarios and multi-file projects, which are not
// standalone sources. This is the same pair `tasks/coverage` takes its TypeScript suite from.
const CASE_DIR_NAMES = ["compiler", "conformance"];
// Every extension `makeUnitsFromTest` knows how to give a source type to
const EXTENSION_REGEX = /\.([cm]?[jt]sx?)$/;
const fixturePaths = (
await Promise.all(
CASE_DIR_NAMES.map(async (dirName) => {
const paths = await readdir(pathJoin(TS_CASES_DIR_PATH, dirName), { recursive: true });
return paths.filter((path) => EXTENSION_REGEX.test(path)).map((path) => `${dirName}/${path}`);
}),
)
)
.flat()
.sort();
// `oxc-parser` deserializes the AST with recursive JS functions, and these fixtures nest binary
// expressions deeply enough to overflow the stack.
// They sit right on the limit, so they fail intermittently rather than every time.
// That is a limit of the parser rather than of the printer.
//
// `napi/parser`'s tests never meet them. Their TypeScript fixture list comes from the `estree-conformance` submodule,
// which holds no recording for either file.
const SKIPPED_PATHS = new Set([
"compiler/binderBinaryExpressionStress.ts",
"compiler/binderBinaryExpressionStressJs.ts",
]);
describe.concurrent("TypeScript", () => {
for (const path of fixturePaths) {
const itFixture = SKIPPED_PATHS.has(path) ? it.skip : it;
itFixture(path, async (ctx) => {
let sourceText = await readFile(pathJoin(TS_CASES_DIR_PATH, path), "utf8");
// Trim off UTF-8 BOM
if (sourceText.charCodeAt(0) === 0xfeff) sourceText = sourceText.slice(1);
const { tests } = makeUnitsFromTest(path, sourceText);
let checked = 0;
for (const { name, content, sourceType } of tests) {
const lang = langOf(sourceType.typescript, sourceType.jsx);
// Always a TS-shaped AST, even for the `.js` units. The TypeScript suite has fixtures
// which put TS syntax in a `.js` file on purpose, and Oxc's Rust parser keeps and prints it,
// so the JS side has to be able to see it too.
const checkedUnit = checkFixture(
name,
content,
lang,
sourceType.module ? "module" : "unambiguous",
"ts",
);
if (checkedUnit) checked++;
}
// Every unit failed to parse, so the fixture contributed nothing to check
if (checked === 0) ctx.skip();
});
}
});
+149
View File
@@ -0,0 +1,149 @@
// Shared machinery for the conformance tests.
//
// Each test takes one fixture's source text and prints it twice:
//
// 1. In Rust, with `oxc_parser` + `oxc_codegen`, via the `oxc_codegen_conformance` Node addon.
// 2. In JS, by parsing with `oxc-parser` (the copy in this repo) and printing with this package.
//
// The two must agree byte for byte. Both sides are given the same source text and the same `lang`
// and `sourceType`, and the addon derives its `SourceType` with `oxc_napi::get_source_type` -
// the same function `oxc-parser` uses. So the two printers are handed the same AST and the only
// thing under test is the printing.
//
// Fixtures which do not parse cleanly have no AST to print, so the addon returns `null` for them
// and the test is reported as skipped rather than passing quietly.
import { join as pathJoin } from "node:path";
import { codegen as rustPrint } from "oxc-codegen-conformance";
import { parseSync } from "oxc-parser";
import { expect } from "vitest";
import { printSync } from "../../dist/index.js";
export const ROOT_DIR_PATH = pathJoin(import.meta.dirname, "../../../..");
// The fixture suites, all of them git submodules of this repo. `just submodules` clones them.
export const TEST262_DIR_PATH = pathJoin(ROOT_DIR_PATH, "tasks/coverage/test262/test");
export const TS_CASES_DIR_PATH = pathJoin(ROOT_DIR_PATH, "tasks/coverage/typescript/tests/cases");
export const JSX_DIR_PATH = pathJoin(
ROOT_DIR_PATH,
"tasks/coverage/estree-conformance/tests/acorn-jsx/pass",
);
// How the source text is to be parsed. The same values go to both printers.
export type Lang = "js" | "jsx" | "ts" | "tsx";
export type SourceTypeOption = "script" | "module" | "unambiguous";
// --- The check --------------------------------------------------------------------------------
// Both parse modes are checked for every fixture.
//
// `true` is `oxc-parser`'s default, and puts `ParenthesizedExpression` / `TSParenthesizedType`
// wrapper nodes in the AST. `false` drops them, leaving the printer to re-derive every
// parenthesis from precedence. The two are different code paths through the printer,
// so both are worth checking.
const PRESERVE_PARENS_MODES = [false, true];
/**
* Check this package prints a fixture exactly as Rust `oxc_codegen` does.
*
* Checked in both parse modes - see `PRESERVE_PARENS_MODES`.
*
* Throws if the two disagree.
*
* Returns `false` without checking anything if the fixture does not parse cleanly,
* so the caller can report the test as skipped.
*
* @param filename - Fixture filename, which is what decides the language when `lang` is absent
* @param sourceText - Fixture source text
* @param lang - Language to parse as
* @param sourceType - Script/module/unambiguous
* @param astType - Which properties the AST carries, defaulting to whatever `lang` implies.
* A JS-shaped AST leaves out the TypeScript-only properties - `optional`, `accessibility`,
* `override`, `declare` and the rest - which Oxc's Rust AST holds for a `.js` file all the same,
* and prints. Fixtures which can contain that syntax must ask for a TS-shaped AST, or the JS
* side is being asked to reproduce something it was never told.
* @returns `true` if the fixture was checked, `false` if it does not parse
*/
export function checkFixture(
filename: string,
sourceText: string,
lang: Lang,
sourceType: SourceTypeOption,
astType?: "js" | "ts",
): boolean {
const ts = astType === undefined ? lang[0] === "t" : astType === "ts";
const jsx = lang.endsWith("x");
let checked = false;
for (const preserveParens of PRESERVE_PARENS_MODES) {
// Rust first.
// `null` means the fixture does not parse cleanly, so nothing to compare.
const expected = rustPrint(filename, sourceText, { lang, sourceType, preserveParens });
if (expected === null) continue;
const { program, errors } = parseSync(filename, sourceText, {
preserveParens,
lang,
sourceType,
astType,
// @ts-expect-error `experimentalRawTransfer` is experimental, so is not in `ParserOptions`
experimentalRawTransfer: true,
});
// Rust parsed it cleanly, so this should not happen - if it does, it is a parser difference
// rather than a printer one, and saying so is more use than a diff of printed output.
expect(errors, "Rust parsed this fixture cleanly but `oxc-parser` did not").toEqual([]);
const actual = printSync(program, { ts, jsx });
expect(actual, `preserveParens: ${preserveParens}`).toBe(expected);
checked = true;
}
return checked;
}
/**
* The `lang` to parse a unit as, from what it is.
*
* @param ts - `true` if the unit is TypeScript
* @param jsx - `true` if JSX syntax is enabled
* @returns The `lang` to give both printers
*/
export function langOf(ts: boolean, jsx: boolean): Lang {
if (ts) return jsx ? "tsx" : "ts";
return jsx ? "jsx" : "js";
}
// The `flags` value in a Test262 frontmatter block, as either a flow sequence (`[a, b]`)
// or the indented lines of a block sequence.
const FLAGS_REGEX = /^flags:[ \t]*(\[[^\]]*\]|(?:\r?\n[ \t]+-[^\r\n]*)+)/m;
/**
* Read the `sourceType` a Test262 fixture asks for.
*
* Test262 files carry a YAML frontmatter block, in which a `module` flag marks the ones
* which must be parsed as modules. Everything else is a script.
*
* The flags are written either as a YAML flow sequence, `flags: [module]`,
* or as a block sequence on the lines below `flags:`. Both forms are in use.
*
* The `_FIXTURE.js` files are the exception. They are module dependencies of other tests
* rather than tests themselves, so they carry no frontmatter at all,
* and plenty of them use `import` or top-level `await`.
*
* The comparison stays valid whatever this returns, since both printers are given the same value -
* getting it right only decides how many fixtures parse, not whether the check is sound.
*
* @param sourceText - Fixture source text
* @returns The source type to parse it as
*/
export function test262SourceType(sourceText: string): SourceTypeOption {
const frontmatterEnd = sourceText.indexOf("---*/");
if (frontmatterEnd === -1) return "unambiguous";
const flags = FLAGS_REGEX.exec(sourceText.slice(0, frontmatterEnd));
if (flags === null) return "script";
return /\bmodule\b/.test(flags[1]) ? "module" : "script";
}
@@ -0,0 +1,161 @@
// TypeScript test file parser.
//
// Port of Rust implementation from `oxc/tasks/coverage/src/typescript/meta.rs`.
// Generated by GitHub Copilot initially and amended since.
//
// Only the part which splits a test file into units is ported. The Rust version also reads
// TypeScript's `*.errors.txt` baselines, to find the fixtures TypeScript itself expects not to compile,
// and skips them. Nothing here needs that - these tests compare two implementations on the same source,
// and whether TypeScript would reject that source has no bearing on whether the two agree about it.
//
// This file is duplicated in `oxc-project/estree-conformance` repo in `src/utils` directory,
// and in `napi/parser/test` in this repo. Any changes made here should also be replicated there.
import path from "path";
// Regex patterns equivalent to the Rust version
const META_OPTIONS_REGEX = /^\/\/\s*@(\w+)\s*:\s*([^\r\n]*)/gm;
interface SourceType {
typescript: boolean;
jsx: boolean;
module: boolean;
}
interface TestUnitData {
name: string;
content: string;
sourceType: SourceType;
}
interface TestCaseContent {
tests: TestUnitData[];
}
/**
* Whether the test file sets a `@jsx` compiler option, which forces JSX on for every unit in it.
*
* The only compiler option any of this needs. Values are already trimmed when they go into the map,
* so an empty string means the directive was there with nothing after it.
*
* @param options - Compiler options
*/
function hasJsxOption(options: Map<string, string>): boolean {
const jsx = options.get("jsx");
return jsx !== undefined && jsx !== "";
}
// Mapping from file extension to source type
const EXTENSIONS = {
".js": { typescript: false, jsx: false, module: true },
".mjs": { typescript: false, jsx: false, module: true },
".cjs": { typescript: false, jsx: false, module: false },
".jsx": { typescript: false, jsx: true, module: true },
".ts": { typescript: true, jsx: false, module: true },
".mts": { typescript: true, jsx: false, module: true },
".cts": { typescript: true, jsx: false, module: false },
".tsx": { typescript: true, jsx: true, module: true },
};
function isSupportedExtension(ext: string): ext is keyof typeof EXTENSIONS {
return Object.hasOwn(EXTENSIONS, ext);
}
/**
* Check if extension explicitly indicates module type
* @param ext - File extension (lowercase, with dot)
*/
function isExplicitModuleExtension(ext: string): boolean {
return [".mts", ".mjs", ".cjs", ".cts"].includes(ext);
}
/**
* Get source type from file path
* @param filePath - Path to the file
* @param jsxOption - `true` if the test file sets a `@jsx` compiler option
* @returns Source type, or `null` for an unsupported extension
*/
function getSourceType(filePath: string, jsxOption: boolean): SourceType | null {
const ext = path.extname(filePath).toLowerCase();
if (!isSupportedExtension(ext)) return null;
// For explicit module extensions (.mts, .mjs, .cjs, .cts), preserve the extension-based module value.
// For ambiguous extensions (.js, .jsx, .ts, .tsx), use content-based detection (start with false).
const sourceType: SourceType = {
...EXTENSIONS[ext],
module: isExplicitModuleExtension(ext) ? EXTENSIONS[ext].module : false,
};
if (jsxOption) sourceType.jsx = true;
return sourceType;
}
/**
* Extract individual test units from a TypeScript test file
* @param filePath - Path to the test file
* @param code - Content of the test file
* @returns Parsed test case content
*/
export function makeUnitsFromTest(filePath: string, code: string): TestCaseContent {
const currentFileOptions = new Map<string, string>();
let currentFileName: string | null = null;
const testUnitData: Omit<TestUnitData, "sourceType">[] = [];
let currentFileContent = "";
// Process the file line by line
const lines = code.split(/\r?\n/);
if (lines[lines.length - 1] === "") lines.length--;
for (const line of lines) {
// Reset regex state
META_OPTIONS_REGEX.lastIndex = 0;
const match = META_OPTIONS_REGEX.exec(line);
if (match) {
const metaName = match[1].toLowerCase();
const metaValue = match[2].trim();
if (metaName === "filename") {
if (currentFileName !== null) {
testUnitData.push({
name: currentFileName,
content: currentFileContent,
});
currentFileContent = "";
}
currentFileName = metaValue;
} else {
currentFileOptions.set(metaName, metaValue);
}
} else {
if (currentFileContent.length > 0) {
currentFileContent += "\n";
}
currentFileContent += line;
}
}
// Handle the last file (or the only file if no @filename is used)
const fileName = currentFileName || path.basename(filePath);
testUnitData.push({
name: fileName,
content: currentFileContent,
});
const jsxOption = hasJsxOption(currentFileOptions);
// Update source types and filter out unsupported files
const isModule = testUnitData.length > 1;
const validTestUnits: TestUnitData[] = [];
for (const unit of testUnitData) {
const sourceType = getSourceType(unit.name, jsxOption);
if (!sourceType) continue;
// Only upgrade to module for ambiguous extensions (.ts, .js, .tsx, .jsx).
// Explicit extensions (.mts, .mjs, .cjs, .cts) should keep their extension-based module kind.
const ext = path.extname(unit.name).toLowerCase();
if (isModule && !isExplicitModuleExtension(ext)) sourceType.module = true;
validTestUnits.push({ ...unit, sourceType });
}
return { tests: validTestUnits };
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "Preserve",
"moduleResolution": "Bundler",
"noEmit": true,
"target": "ESNext",
"allowImportingTsExtensions": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"exclude": ["node_modules", "dist"]
}
+108
View File
@@ -0,0 +1,108 @@
// Build configuration.
//
// 5 builds out of one source tree - the entry point, and the printer over the 2 build-time feature flags.
// The plugin order matters: `strip_ts` is a text transform, so it has to run before the plugins which parse.
import { defineConfig } from "tsdown";
import removeAssertsPlugin from "./tsdown_plugins/remove_asserts.ts";
import stripTsPlugin from "./tsdown_plugins/strip_ts.ts";
import unmapWritesPlugin from "./tsdown_plugins/unmap_writes.ts";
import constFunctionsPlugin from "./tsdown_plugins/const_functions.ts";
const isEnabled = (env: string | undefined) => env === "true" || env === "1";
// When run with `pnpm run build-dev`, generate a debug build with extra assertions.
// This is the build to run the conformance suites against - see `conformance/run.mjs`.
// It replaces the release build in `dist`, so rebuild with `pnpm run build` before benchmarking.
const DEBUG = isEnabled(process.env.DEBUG);
// Only remove assertions in release build. Debug builds keep `debugAssert` calls live.
const assertPlugins = DEBUG ? [] : [removeAssertsPlugin];
// Global constants defined at build time. See `src-js/globals.d.ts`.
// `DEBUG: false` lets the minifier remove the body of `debugAssert` and any other debug-only code.
const definedGlobals = { DEBUG: DEBUG ? "true" : "false" };
// Base config.
// `platform: "node"` because the entry point loads the printer with `createRequire`.
const commonConfig = defineConfig({
platform: "node",
target: "node20",
outDir: "dist",
format: "esm",
unbundle: false,
hash: false,
fixedExtension: false,
// `scripts/build.ts` deletes `dist` before TSDown runs.
// This allows generating all 5 builds into the same directory.
clean: false,
plugins: [...assertPlugins],
inputOptions: {
// For plugins
experimental: { nativeMagicString: true },
},
});
// Minification options.
// Release builds are minified in full - that is what is published, and it is half the size.
// Debug builds keep names and whitespace, so `dist` stays readable while working on the printer.
const minifyConfig = DEBUG
? {
compress: { keepNames: { function: true, class: true } },
mangle: false,
codegen: { removeWhitespace: false },
}
: true;
// One printer build. The printer is built 4 times from `src-js/print/index.ts`,
// over 2 build-time feature flags (see `src-js/globals.d.ts`):
//
// - `SOURCEMAPS`: Source map support costs a little speed even when unused.
// - `TS`: TypeScript syntax support. JS-only builds lose all the TS field checks (via minifier dead-code removal)
// and the TS switch arms + printer functions (via `strip_ts.ts`).
//
// `src-js/index.ts` loads whichever build the caller's options call for.
//
// In builds without source maps, nothing reads the `node` argument the mapping writes take,
// so `unmap_writes` rewrites every `writeWithMap` / `writeWithMapNoLast` call, and the imports
// which bring them in, into the plain `write` / `writeNoLast` they become without it.
const printerConfig = (name: string, { sourcemaps, ts }: { sourcemaps: boolean; ts: boolean }) => ({
...commonConfig,
minify: minifyConfig,
// Only the entry point's types are published
dts: false,
entry: { [name]: "src-js/print/index.ts" },
define: {
...definedGlobals,
SOURCEMAPS: sourcemaps ? "true" : "false",
TS: ts ? "true" : "false",
},
plugins: [
// `strip_ts` is a text transform, so must run before the AST-based plugins
...(ts ? [] : [stripTsPlugin()]),
...(sourcemaps ? [] : [unmapWritesPlugin]),
constFunctionsPlugin,
...assertPlugins,
],
});
export default defineConfig([
// Entry point
{
...commonConfig,
entry: { index: "src-js/index.ts" },
minify: minifyConfig,
dts: true,
define: definedGlobals,
deps: {
// The printer builds are loaded at runtime, so must not be bundled in
neverBundle: ["./print_js.js", "./print_js_maps.js", "./print_ts.js", "./print_ts_maps.js"],
},
},
// Printers
printerConfig("print_js", { sourcemaps: false, ts: false }),
printerConfig("print_js_maps", { sourcemaps: true, ts: false }),
printerConfig("print_ts", { sourcemaps: false, ts: true }),
printerConfig("print_ts_maps", { sourcemaps: true, ts: true }),
]);
@@ -0,0 +1,68 @@
import { parseSync } from "oxc-parser";
import type { Plugin } from "rolldown";
/**
* Plugin to rewrite the printer's top-level function declarations as `const` bindings.
*
* ```ts
* // Original code
* function printIf(node: ESTree.IfStatement, state: State): void {
* // ...
* }
*
* // After transform
* const printIf = (node: ESTree.IfStatement, state: State): void => {
* // ...
* };
* ```
*
* A function declaration creates a binding which could be assigned to later, so a call has to read
* the current value out of the module's scope. A `const` binding cannot, so V8 can treat it
* as a constant and call it directly. Measured on all 7 benchmark fixtures, it is worth ~3%.
*
* It really is the `const`, and not the arrow function or the smaller output it allows - the same rewrite
* with `let` measures ~4% slower than this one, and slower than leaving the declarations alone.
* Do not relax it to `let`.
*
* The source keeps its declarations - they hoist, they read better, and they are what the rest of
* the codebase looks like. This is why the rewrite is a build step rather than how printer is written.
*/
const plugin: Plugin = {
name: "const-functions",
transform: {
// Only process TS files in `src-js/print` directory
filter: { id: /\/src-js\/print\/.+\.ts$/ },
handler(code, path, meta) {
const magicString = meta.magicString!;
const { program, errors } = parseSync(path, code);
if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`);
for (const statement of program.body) {
let fn;
if (statement.type === "FunctionDeclaration") {
fn = statement;
} else if (
statement.type === "ExportNamedDeclaration" &&
statement.declaration?.type === "FunctionDeclaration"
) {
fn = statement.declaration;
} else {
continue;
}
if (fn.async || fn.generator) continue;
// `function name` -> `const name =`, `{` of the body -> `=> {`, and `}` -> `};`
magicString.overwrite(fn.start, fn.id!.end, `const ${fn.id!.name} =`);
magicString.appendLeft(fn.body!.start, "=> ");
magicString.appendRight(fn.end, ";");
}
return { code: magicString };
},
},
};
export default plugin;
@@ -0,0 +1,99 @@
import { join as pathJoin } from "node:path";
import { parseSync, Visitor } from "oxc-parser";
import type { Plugin } from "rolldown";
// Path to file which defines assertion functions
const ASSERTS_PATH = pathJoin(import.meta.dirname, "../src-js/asserts.ts");
/**
* Plugin to remove imports of `typeAssertIs` from `src-js/asserts.ts`, and all its call sites.
*
* ```ts
* // Original code
* import { typeAssertIs } from "./asserts.ts";
* typeAssertIs<Foo>(node.value);
* print(node.value);
*
* // After transform
* print(node.value);
* ```
*
* The minifier can already remove the calls on its own, as the function body is empty and lands
* in the same chunk. But it can't prove that the expressions *inside* the calls have no side effects -
* `node.value` could be a getter - so it leaves those behind as bare expression statements.
*
* This plugin removes the calls entirely, expressions included, which makes `typeAssertIs`
* cost nothing at all. Adapted from `apps/oxlint/tsdown_plugins/replace_asserts.ts`.
*/
const plugin: Plugin = {
name: "remove-asserts",
transform: {
// Only process TS files in `src-js` directory
filter: { id: /\/src-js\/.+(?<!\.d)\.ts$/ },
async handler(code, path, meta) {
const magicString = meta.magicString!;
const { program, errors } = parseSync(path, code);
if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`);
// Gather names of assertion functions imported from `asserts.ts`.
// Also gather all identifiers used in the `import` statements, so can avoid erroring on them
// in visitor below.
const assertFnNames = new Set<string>(),
idents = new Set();
for (const stmt of program.body) {
if (stmt.type !== "ImportDeclaration") continue;
// Check if import is from `asserts.ts`.
// `endsWith` check is just a shortcut to avoid resolving the specifier to a full path for most imports.
const source = stmt.source.value;
if (!source.endsWith("/asserts.ts")) continue;
// oxlint-disable-next-line no-await-in-loop
const importedId = await this.resolve(source, path);
if (importedId === null || importedId.id !== ASSERTS_PATH) continue;
// Remove `import` statement
for (const specifier of stmt.specifiers) {
if (specifier.type !== "ImportSpecifier") {
throw new Error(`Only use named imports when importing from \`asserts.ts\`: ${path}`);
}
idents.add(specifier.local);
if (specifier.imported.type === "Identifier") idents.add(specifier.imported);
assertFnNames.add(specifier.local.name);
}
magicString.remove(stmt.start, stmt.end);
}
if (assertFnNames.size === 0) return;
// Visit AST and remove all calls to assertion functions
const visitor = new Visitor({
// Replace `typeAssertIs(...)` calls with `null`. Minifier will remove the `null`.
CallExpression(node) {
const { callee } = node;
if (callee.type !== "Identifier") return;
if (assertFnNames.has(callee.name)) {
idents.add(callee);
magicString.overwrite(node.start, node.end, "null");
}
},
// Error if assertion functions are used in any other way. We lack logic to deal with that.
Identifier(node) {
const { name } = node;
if (assertFnNames.has(name) && !idents.has(node)) {
throw new Error(
`Do not use \`${name}\` imported from \`asserts.ts\` except in function calls: ${path}`,
);
}
},
});
visitor.visit(program);
return { code: magicString };
},
},
};
export default plugin;
@@ -0,0 +1,71 @@
import type { Plugin } from "rolldown";
/**
* Plugin to remove the TypeScript-only regions of `print.ts` from the JS-only builds.
*
* The `TS` build-time constant (see `src-js/globals.d.ts`) lets the minifier drop TS-only code
* in expression positions - `if (TS && node.declare)` folds to nothing when `TS` is `false`.
* But `case "TSEnumDeclaration":` switch arms are not expressions, and no minifier can remove an arm
* on the grounds that its node type never occurs. Those arms are fenced with `IF TS` / `END_IF`
* block-comment markers (the same convention as the raw transfer deserializer builds):
*
* ```ts
* /* IF TS *\/
* case "TSEnumDeclaration":
* ...
* break;
* /* END_IF *\/
* ```
*
* (`*\/` escapes above only because this is itself a block comment.)
*
* This plugin deletes everything from each `IF TS` marker line through its `END_IF` marker line.
* Once the arms are gone, the TS-only printer functions they called lose their last references,
* and tree-shaking removes them wholesale.
*
* It's a plain text transform (the fences are only visible in source text), so it must run before
* the AST-based plugins in the plugin list, and only in builds where `TS` is `false`.
*/
const REGION = /[ \t]*\/\* IF TS \*\/\n[\s\S]*?\/\* END_IF \*\/\n/g;
// A factory, so each build counts its own regions rather than sharing a total with the others
const stripTsPlugin = (): Plugin => {
let strippedRegions = 0;
return {
name: "strip-ts",
buildStart() {
strippedRegions = 0;
},
transform: {
// Only process TS files in `src-js/print` directory
filter: { id: /\/src-js\/print\/.+\.ts$/ },
handler(code) {
code = code.replace(REGION, () => {
strippedRegions++;
return "";
});
// Unbalanced or nested fences leave a marker behind.
// Most files have no fences at all, so the "did this plugin do anything" check is per build, in `buildEnd`.
if (code.includes("IF TS") || code.includes("END_IF")) {
throw new Error("strip-ts: unbalanced or nested `/* IF TS */` / `/* END_IF */` fences");
}
return code;
},
},
buildEnd() {
// No fences anywhere means they have been lost and this plugin is silently doing nothing
if (strippedRegions === 0) {
throw new Error("strip-ts: no `/* IF TS */` / `/* END_IF */` fences found");
}
},
};
};
export default stripTsPlugin;
@@ -0,0 +1,169 @@
import { parseSync, Visitor } from "oxc-parser";
import type { Plugin } from "rolldown";
/**
* Plugin to turn every mapped write back into a plain one, for builds without source maps.
*
* ```ts
* // Original code
* writeWithMap(state, "declare ", CAT_OTHER, node);
*
* // After transform
* write(state, "declare ", CAT_OTHER);
* ```
*
* `writeWithMap` and `writeWithMapNoLast` exist to record a source mapping for the node they are given.
* A build without source map support has nothing to record, so the call becomes the plain write
* it would otherwise be, and the node argument goes with it - it would still be evaluated,
* and held live across the call, for a function which ignores it.
*
* The import is rewritten to match, which both keeps the plain name in scope and leaves the two
* mapped functions unreferenced for the minifier to remove.
*
* Only valid where `SOURCEMAPS` is `false`, which is where the config includes it.
*
* The checks afterwards fail the build rather than let a mismatch through. A rewrite which dropped
* the wrong argument, or left a call to a name nothing imports, produces a printer that is broken
* everywhere or subtly wrong everywhere, and nothing downstream names the plugin as the cause.
*/
const REWRITES = {
// `write` takes the `last` category between the code and the node, `writeNoLast` does not
writeWithMap: { arity: 4, plain: "write" },
writeWithMapNoLast: { arity: 3, plain: "writeNoLast" },
} as const;
const WRITE_MODULE = "./write.ts";
type MappedName = keyof typeof REWRITES;
const isMapped = (name: string): name is MappedName => name in REWRITES;
const plugin: Plugin = {
name: "unmap-writes",
transform: {
// Only process TS files in `src-js/print` directory
filter: { id: /\/src-js\/print\/.+\.ts$/ },
handler(code, path, meta) {
const magicString = meta.magicString!;
const { program, errors } = parseSync(path, code);
if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`);
// Plain names this file will need in scope once its calls are rewritten
const needed = new Set<string>();
new Visitor({
CallExpression(node) {
const { callee } = node;
if (callee.type !== "Identifier" || !isMapped(callee.name)) return;
const { arity, plain } = REWRITES[callee.name];
const args = node.arguments;
if (args.length !== arity) {
throw new Error(
`\`${callee.name}\` takes ${arity} arguments, found ${args.length}: ` +
`${path}:${node.start}`,
);
}
magicString.overwrite(callee.start, callee.end, plain);
// Remove `, node`, from the end of the argument before it to the end of it
magicString.remove(args[arity - 2].end, args[arity - 1].end);
needed.add(plain);
},
}).visit(program);
if (needed.size !== 0) {
// Rewrite the import to drop the mapped names and carry whatever the rewrites now need.
// `write.ts` declares them itself and imports nothing, so it never reaches this.
// A file may import from `write.ts` more than once - the types separately from the values.
// The one to rewrite is whichever brought the mapped names in.
const imports = program.body.filter(
(statement) =>
statement.type === "ImportDeclaration" &&
statement.source.value === WRITE_MODULE &&
statement.specifiers.some(
(specifier) =>
specifier.type === "ImportSpecifier" &&
specifier.imported.type === "Identifier" &&
isMapped(specifier.imported.name),
),
);
if (imports.length !== 1) {
throw new Error(
`Expected 1 \`${WRITE_MODULE}\` import bringing in mapped writes, found ` +
`${imports.length}: ${path}`,
);
}
const importNode = imports[0];
if (importNode.type !== "ImportDeclaration") throw new Error(`Unreachable: ${path}`);
const names = new Set(needed);
for (const specifier of importNode.specifiers) {
if (specifier.type !== "ImportSpecifier" || specifier.imported.type !== "Identifier") {
throw new Error(`Unexpected \`${WRITE_MODULE}\` import specifier: ${path}`);
}
if (!isMapped(specifier.imported.name)) names.add(specifier.local.name);
}
const sorted = [...names].sort();
magicString.overwrite(
importNode.start,
importNode.end,
`import { ${sorted.join(", ")} } from "${WRITE_MODULE}";`,
);
}
const transformed = magicString.toString();
const { program: after, errors: afterErrors } = parseSync(path, transformed);
if (afterErrors.length !== 0) {
throw new Error(`Transform produced invalid code: ${path}: ${afterErrors[0].message}`);
}
// Nothing may still reach a mapped write.
// Their declarations stay, for the minifier to remove once nothing imports them.
const bound = new Set<string>();
const problems: string[] = [];
new Visitor({
ImportSpecifier(node) {
if (node.imported.type !== "Identifier") return;
if (isMapped(node.imported.name)) {
problems.push(`import of ${node.imported.name} at ${node.start}`);
}
bound.add(node.local.name);
},
FunctionDeclaration(node) {
if (node.id !== null) bound.add(node.id.name);
},
}).visit(after);
const called = new Set<string>();
new Visitor({
CallExpression(node) {
const { callee } = node;
if (callee.type !== "Identifier") return;
if (isMapped(callee.name)) problems.push(`call to ${callee.name} at ${node.start}`);
if (callee.name === "write" || callee.name === "writeNoLast") called.add(callee.name);
},
}).visit(after);
// A rewritten call to a name this file does not have in scope would be a `ReferenceError`
// on the first print, so check rather than assume the import rewrite above got it right
for (const name of called) {
if (!bound.has(name)) problems.push(`\`${name}\` is called but not in scope`);
}
if (problems.length !== 0) {
throw new Error(`unmap-writes left ${path} broken: ${problems.join(", ")}`);
}
return { code: magicString };
},
},
};
export default plugin;
+30
View File
@@ -402,6 +402,36 @@ importers:
npm/runtime: {}
packages/codegen:
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.1.0
cross-env:
specifier: 'catalog:'
version: 10.1.0
oxc-codegen-conformance:
specifier: workspace:*
version: link:../../tasks/codegen_conformance
oxc-parser:
specifier: workspace:*
version: link:../../napi/parser
rolldown:
specifier: 1.2.4
version: 1.2.4
tsdown:
specifier: 'catalog:'
version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(tsx@4.23.9)(typescript@6.0.3)
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/node@24.1.0)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(happy-dom@20.0.11)(vite@7.3.0(@types/node@24.1.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.23.9))
tasks/codegen_conformance:
devDependencies:
'@napi-rs/cli':
specifier: 'catalog:'
version: 3.8.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.0.2)(emnapi@2.0.0-alpha.3)
tasks/compat_data:
devDependencies:
degit:
+2
View File
@@ -1,8 +1,10 @@
packages:
- apps/*
- napi/*
- packages/*
- wasm/*
- npm/*
- tasks/codegen_conformance
- tasks/e2e
- tasks/transform_conformance
- tasks/compat_data
+1
View File
@@ -0,0 +1 @@
*.node
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "oxc_codegen_conformance"
version = "0.0.0"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
publish = false
repository.workspace = true
rust-version.workspace = true
description = "Node API binding around `oxc_parser` + `oxc_codegen`, used as the reference output for the `oxc-codegen` package's conformance tests."
[lints]
workspace = true
[lib]
crate-type = ["cdylib", "lib"]
test = false
doctest = false
[dependencies]
oxc_allocator = { workspace = true }
oxc_ast = { workspace = true }
oxc_ast_visit = { workspace = true }
oxc_codegen = { workspace = true }
oxc_napi = { workspace = true }
oxc_parser = { workspace = true }
oxc_syntax = { workspace = true }
napi = { workspace = true }
napi-derive = { workspace = true }
[build-dependencies]
napi-build = { workspace = true }
# `napi` is not named in the source - the `#[napi]` macro expands to code which uses it, which
# `cargo-shear` cannot see through.
[package.metadata.cargo-shear]
ignored = ["napi"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
+56
View File
@@ -0,0 +1,56 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/**
* Parse `source_text` with `oxc_parser` and print the AST with `oxc_codegen`.
*
* Returns `null` if the source text could not be parsed without errors, which tells the caller
* there is nothing to compare for this fixture.
*
* Comments are not printed. `oxc-codegen` does not print comments, and the ESTree AST the JS side
* works from carries no comment data anyway.
*/
export declare function codegen(filename: string, sourceText: string, options?: Options | undefined | null): string | null
/**
* Options describing how a fixture should be parsed.
*
* These are the same options the JS side passes to `oxc-parser`, so that both sides parse
* the fixture identically.
*/
export interface Options {
/** `"js"`, `"jsx"`, `"ts"` or `"tsx"`. When absent, the language comes from the filename. */
lang?: string
/** `"script"`, `"module"` or `"unambiguous"`. When absent, it comes from the language. */
sourceType?: string
/**
* Whether to keep the `ParenthesizedExpression` / `TSParenthesizedType` wrapper nodes.
* Default `false`.
*/
preserveParens?: boolean
}
export interface Comment {
type: 'Line' | 'Block'
value: string
start: number
end: number
}
export interface ErrorLabel {
message: string | null
start: number
end: number
}
export interface OxcError {
severity: Severity
message: string
labels: Array<ErrorLabel>
helpMessage: string | null
codeframe: string | null
}
export declare const enum Severity {
Error = 'Error',
Warning = 'Warning',
Advice = 'Advice'
}
+708
View File
@@ -0,0 +1,708 @@
// prettier-ignore
/* eslint-disable */
// @ts-nocheck
/* auto-generated by NAPI-RS */
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const __dirname = new URL('.', import.meta.url).pathname
const { readFileSync } = require('fs')
let nativeBinding = null
const loadErrors = []
const isMusl = () => {
let musl = false
if (process.platform === 'linux') {
musl = isMuslFromFilesystem()
if (musl === null) {
musl = isMuslFromReport()
}
if (musl === null) {
musl = isMuslFromChildProcess()
}
}
return musl
}
const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-')
const isMuslFromFilesystem = () => {
try {
return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl')
} catch {
return null
}
}
const isMuslFromReport = () => {
let report = null
if (process.report && typeof process.report.getReport === 'function') {
process.report.excludeNetwork = true
report = process.report.getReport()
}
if (!report) {
return null
}
if (report.header && report.header.glibcVersionRuntime) {
return false
}
if (Array.isArray(report.sharedObjects)) {
if (report.sharedObjects.some(isFileMusl)) {
return true
}
}
return false
}
const isMuslFromChildProcess = () => {
try {
return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
} catch (e) {
// If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false
return false
}
}
function requireNative() {
if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
try {
return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH);
} catch (err) {
loadErrors.push(err)
}
} else if (process.platform === 'android') {
if (process.arch === 'arm64') {
try {
return require('./codegen_conformance.android-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-android-arm64')
const bindingPackageVersion = require('oxc-codegen-conformance-android-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm') {
try {
return require('./codegen_conformance.android-arm-eabi.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-android-arm-eabi')
const bindingPackageVersion = require('oxc-codegen-conformance-android-arm-eabi/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`))
}
} else if (process.platform === 'win32') {
if (process.arch === 'x64') {
if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) {
try {
return require('./codegen_conformance.win32-x64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-win32-x64-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-win32-x64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./codegen_conformance.win32-x64-msvc.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-win32-x64-msvc')
const bindingPackageVersion = require('oxc-codegen-conformance-win32-x64-msvc/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'ia32') {
try {
return require('./codegen_conformance.win32-ia32-msvc.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-win32-ia32-msvc')
const bindingPackageVersion = require('oxc-codegen-conformance-win32-ia32-msvc/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm64') {
try {
return require('./codegen_conformance.win32-arm64-msvc.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-win32-arm64-msvc')
const bindingPackageVersion = require('oxc-codegen-conformance-win32-arm64-msvc/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`))
}
} else if (process.platform === 'darwin') {
try {
return require('./codegen_conformance.darwin-universal.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-darwin-universal')
const bindingPackageVersion = require('oxc-codegen-conformance-darwin-universal/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
if (process.arch === 'x64') {
try {
return require('./codegen_conformance.darwin-x64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-darwin-x64')
const bindingPackageVersion = require('oxc-codegen-conformance-darwin-x64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm64') {
try {
return require('./codegen_conformance.darwin-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-darwin-arm64')
const bindingPackageVersion = require('oxc-codegen-conformance-darwin-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`))
}
} else if (process.platform === 'freebsd') {
if (process.arch === 'x64') {
try {
return require('./codegen_conformance.freebsd-x64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-freebsd-x64')
const bindingPackageVersion = require('oxc-codegen-conformance-freebsd-x64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm64') {
try {
return require('./codegen_conformance.freebsd-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-freebsd-arm64')
const bindingPackageVersion = require('oxc-codegen-conformance-freebsd-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`))
}
} else if (process.platform === 'linux') {
if (process.arch === 'x64') {
if (isMusl()) {
try {
return require('./codegen_conformance.linux-x64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-x64-musl')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-x64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./codegen_conformance.linux-x64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-x64-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-x64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'arm64') {
if (isMusl()) {
try {
return require('./codegen_conformance.linux-arm64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-arm64-musl')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-arm64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./codegen_conformance.linux-arm64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-arm64-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-arm64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'arm') {
if (isMusl()) {
try {
return require('./codegen_conformance.linux-arm-musleabihf.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-arm-musleabihf')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-arm-musleabihf/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./codegen_conformance.linux-arm-gnueabihf.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-arm-gnueabihf')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-arm-gnueabihf/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'loong64') {
if (isMusl()) {
try {
return require('./codegen_conformance.linux-loong64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-loong64-musl')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-loong64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./codegen_conformance.linux-loong64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-loong64-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-loong64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'riscv64') {
if (isMusl()) {
try {
return require('./codegen_conformance.linux-riscv64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-riscv64-musl')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-riscv64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./codegen_conformance.linux-riscv64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-riscv64-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-riscv64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'ppc64') {
try {
return require('./codegen_conformance.linux-ppc64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-ppc64-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-ppc64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 's390x') {
try {
return require('./codegen_conformance.linux-s390x-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-linux-s390x-gnu')
const bindingPackageVersion = require('oxc-codegen-conformance-linux-s390x-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`))
}
} else if (process.platform === 'openharmony') {
if (process.arch === 'arm64') {
try {
return require('./codegen_conformance.openharmony-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-openharmony-arm64')
const bindingPackageVersion = require('oxc-codegen-conformance-openharmony-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'x64') {
try {
return require('./codegen_conformance.openharmony-x64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-openharmony-x64')
const bindingPackageVersion = require('oxc-codegen-conformance-openharmony-x64/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm') {
try {
return require('./codegen_conformance.openharmony-arm.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('oxc-codegen-conformance-openharmony-arm')
const bindingPackageVersion = require('oxc-codegen-conformance-openharmony-arm/package.json').version
if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`))
}
} else {
loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`))
}
}
function createLoadErrorChain(errors) {
return errors.reduce((previous, current) => {
let message
try {
message =
current && typeof current.message === 'string'
? current.message
: String(current)
} catch {
message = 'Unknown error'
}
const error = new Error(message)
error.cause = previous
return error
}, null)
}
// NAPI_RS_FORCE_WASI is a tri-state flag:
// unset / any other value → native binding preferred, WASI is only a fallback
// 'true' → prefer WASI, but retain native as a lazy fallback
// 'error' → require WASI without initializing a native fallback
// Treating any non-empty string as truthy (the historical behavior) meant
// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered
// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file.
//
// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict
// WASI loading. It never crosses into another flavor or falls back to native.
const __napiWasiFlavors = ["wasm32-wasi"]
const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR
const __napiWasiFlavorRequested =
typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0
if (
__napiWasiFlavorRequested &&
__napiWasiFlavors.indexOf(__napiWasiFlavor) === -1
) {
throw new Error(
'Unsupported WASI flavor "' +
__napiWasiFlavor +
'". Available flavors: ' +
__napiWasiFlavors.join(', '),
)
}
const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error'
const forceWasi =
process.env.NAPI_RS_FORCE_WASI === 'true' ||
forceWasiError ||
__napiWasiFlavorRequested
if (!forceWasi) {
nativeBinding = requireNative()
}
if (!nativeBinding || forceWasi) {
let wasiBinding = null
let wasiBindingLoaded = false
const wasiBindingErrors = []
const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => {
try {
require.resolve(specifier)
} catch (resolveError) {
if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') {
throw resolveError
}
if (isPackage) {
try {
require.resolve(specifier + '/package.json')
} catch (packageError) {
if (packageError && packageError.code === 'MODULE_NOT_FOUND') {
return resolveError
}
// An exports restriction proves the package exists even when its
// package.json is not public. Preserve the root resolution failure.
throw resolveError
}
// The package exists but its main/export target is broken.
throw resolveError
}
return resolveError
}
if (localArtifacts) {
let artifactError = null
for (let i = 0; i < localArtifacts.length; i++) {
try {
require.resolve(localArtifacts[i])
return null
} catch (resolveError) {
if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') {
throw resolveError
}
artifactError = resolveError
}
}
return artifactError
}
return null
}
if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) {
let candidateError = null
let candidateFailed = false
try {
candidateError = __napiWasiResolveCandidate('./codegen_conformance.wasi.cjs', false, ["./codegen_conformance.wasm32-wasi.debug.wasm","./codegen_conformance.wasm32-wasi.wasm"])
candidateFailed = candidateError !== null
if (!candidateFailed) {
wasiBinding = require('./codegen_conformance.wasi.cjs')
nativeBinding = wasiBinding
wasiBindingLoaded = true
}
} catch (err) {
candidateError = err
candidateFailed = true
}
if (candidateFailed) {
wasiBindingErrors.push(candidateError)
loadErrors.push(candidateError)
}
}
if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) {
let candidateError = null
let candidateFailed = false
try {
candidateError = __napiWasiResolveCandidate('oxc-codegen-conformance-wasm32-wasi', true, undefined)
candidateFailed = candidateError !== null
if (!candidateFailed) {
if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
const bindingPackageVersion = require('oxc-codegen-conformance-wasm32-wasi/package.json').version
if (bindingPackageVersion !== '0.0.0') {
throw new Error(`WASI binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
}
wasiBinding = require('oxc-codegen-conformance-wasm32-wasi')
nativeBinding = wasiBinding
wasiBindingLoaded = true
}
} catch (err) {
candidateError = err
candidateFailed = true
}
if (candidateFailed) {
wasiBindingErrors.push(candidateError)
loadErrors.push(candidateError)
}
}
if (
!wasiBindingLoaded &&
forceWasi &&
!forceWasiError &&
!__napiWasiFlavorRequested
) {
nativeBinding = requireNative()
}
if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) {
const error = new Error(
__napiWasiFlavorRequested
? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found'
: 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error',
)
error.cause = createLoadErrorChain(wasiBindingErrors)
throw error
}
}
if (!nativeBinding) {
if (loadErrors.length > 0) {
const error = new Error(
`Cannot find native binding. ` +
`npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +
'Please try `npm i` again after removing both package-lock.json and node_modules directory.',
)
// assign instead of the `new Error(message, { cause })` options form,
// which Node < 16.9 silently ignores
error.cause = createLoadErrorChain(loadErrors)
throw error
}
throw new Error(`Failed to load native binding`)
}
const { codegen, Severity } = nativeBinding
export { codegen }
export { Severity }
+29
View File
@@ -0,0 +1,29 @@
{
"name": "oxc-codegen-conformance",
"version": "0.0.0",
"private": true,
"description": "Node API binding around `oxc_parser` + `oxc_codegen`, used as the reference output for the `oxc-codegen` package's conformance tests",
"license": "MIT",
"author": "Boshen and oxc contributors",
"repository": {
"type": "git",
"url": "git+https://github.com/oxc-project/oxc.git",
"directory": "tasks/codegen_conformance"
},
"type": "module",
"main": "index.js",
"scripts": {
"build-dev": "napi build --esm --platform",
"build-test": "pnpm run build-dev --profile coverage",
"build": "pnpm run build-dev --release"
},
"devDependencies": {
"@napi-rs/cli": "catalog:"
},
"napi": {
"binaryName": "codegen_conformance"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Node API binding which parses source text with [`oxc_parser`] and prints it with [`oxc_codegen`].
//!
//! This is the reference the `oxc-codegen` package's conformance tests are checked against.
//! For each fixture, the tests parse the source twice - once here in Rust, and once with `oxc-parser`
//! on the JS side - and require both printers to produce the same output.
//!
//! Both sides are given the same source text and the same `SourceType`, via [`oxc_napi::get_source_type`],
//! which is the same function `oxc-parser` itself uses to turn a filename and `lang` / `sourceType` options
//! into a `SourceType`. So the ASTs the two printers are handed are the same AST, and the only thing
//! under test is the printing.
use std::panic::{AssertUnwindSafe, catch_unwind};
use napi_derive::napi;
use oxc_allocator::Allocator;
use oxc_ast::ast::{
ArrowFunctionExpression, ExportAllDeclaration, ExportFromDeclaration, FormalParameterRest,
Function, ImportDeclaration, WithClauseKeyword,
};
use oxc_ast_visit::{VisitMut, walk_mut};
use oxc_codegen::{Codegen, CodegenOptions, CommentOptions};
use oxc_napi::get_source_type;
use oxc_parser::{ParseOptions, Parser};
use oxc_syntax::scope::ScopeFlags;
/// Options describing how a fixture should be parsed.
///
/// These are the same options the JS side passes to `oxc-parser`, so that both sides parse
/// the fixture identically.
#[napi(object)]
pub struct Options {
/// `"js"`, `"jsx"`, `"ts"` or `"tsx"`. When absent, the language comes from the filename.
pub lang: Option<String>,
/// `"script"`, `"module"` or `"unambiguous"`. When absent, it comes from the language.
pub source_type: Option<String>,
/// Whether to keep the `ParenthesizedExpression` / `TSParenthesizedType` wrapper nodes.
/// Default `false`.
pub preserve_parens: Option<bool>,
}
/// Parse `source_text` with `oxc_parser` and print the AST with `oxc_codegen`.
///
/// Returns `null` if the source text could not be parsed without errors, which tells the caller
/// there is nothing to compare for this fixture.
///
/// Comments are not printed. `oxc-codegen` does not print comments, and the ESTree AST the JS side
/// works from carries no comment data anyway.
#[napi]
pub fn codegen(filename: String, source_text: String, options: Option<Options>) -> Option<String> {
let options =
options.unwrap_or(Options { lang: None, source_type: None, preserve_parens: None });
// Oxc should never panic, but a panic in a Node addon aborts the process, which would throw
// away a whole conformance run over ~58,000 fixtures. Contain it instead - the fixture is
// reported as unprintable, and the run continues.
catch_unwind(AssertUnwindSafe(move || {
codegen_impl(
&filename,
&source_text,
options.lang.as_deref(),
options.source_type.as_deref(),
options.preserve_parens.unwrap_or(false),
)
}))
.unwrap_or(None)
}
fn codegen_impl(
filename: &str,
source_text: &str,
lang: Option<&str>,
source_type_option: Option<&str>,
preserve_parens: bool,
) -> Option<String> {
let source_type = get_source_type(filename, lang, source_type_option);
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, source_type)
.with_options(ParseOptions {
// Whatever the JS side was given.
// Parse the same way, or the two printers would be given different ASTs,
// and the comparison would be measuring the parse, not the print.
preserve_parens,
..ParseOptions::default()
})
.parse();
// Only print fixtures which parse cleanly. Oxc's parser recovers from errors, but a recovered
// AST is not something the JS side can be expected to reproduce.
if ret.panicked || !ret.diagnostics.is_empty() {
return None;
}
let mut program = ret.program;
Normalize { preserve_parens }.visit_program(&mut program);
let codegen_options =
CodegenOptions { comments: CommentOptions::disabled(), ..CodegenOptions::default() };
Some(Codegen::new().with_options(codegen_options).build(&program).code)
}
/// Erases the parts of Oxc's AST which its own ESTree representation cannot carry.
///
/// The JS side reaches the printer through `oxc-parser`'s ESTree AST. Where that AST holds less
/// than the Rust one, the JS printer cannot possibly reproduce what the Rust printer does with the
/// extra information, so the Rust AST is brought down to what ESTree can say before printing.
///
/// Each case below is a real difference in what the two representations can express,
/// not a workaround for a printer bug.
struct Normalize {
/// `true` when the AST keeps its `ParenthesizedExpression` / `TSParenthesizedType` wrappers.
preserve_parens: bool,
}
impl<'a> VisitMut<'a> for Normalize {
/// ESTree has no way to record that a function expression was parenthesized,
/// which is what Oxc's `pife` flag tracks (and prints parentheses for).
///
/// With `preserve_parens`, it does not need one - the parentheses are in the AST
/// as a `ParenthesizedExpression` of their own, which the JS side reads and prints.
/// Oxc's own printer ignores that node and goes by `pife`, so the flag has to survive
/// for the two to agree.
fn visit_function(&mut self, func: &mut Function<'a>, flags: ScopeFlags) {
if !self.preserve_parens {
func.pife = false;
}
walk_mut::walk_function(self, func, flags);
}
/// As `visit_function`, for arrow functions.
fn visit_arrow_function_expression(&mut self, arrow: &mut ArrowFunctionExpression<'a>) {
if !self.preserve_parens {
arrow.pife = false;
}
walk_mut::walk_arrow_function_expression(self, arrow);
}
/// A rest parameter's decorators are `#[estree(skip)]`ped - ESTree types them as
/// an always-empty array on `RestElement` - so the JS side never sees them.
/// Decorators on the other parameters do come through, and are left alone.
fn visit_formal_parameter_rest(&mut self, rest: &mut FormalParameterRest<'a>) {
rest.decorators.clear();
walk_mut::walk_formal_parameter_rest(self, rest);
}
/// ESTree cannot distinguish `import "m"` from `import {} from "m"` - `specifiers` is `[]` for both.
/// It also cannot distinguish an attribute-less import from one with an empty `with {}` clause -
/// `attributes` is `[]` for both.
///
/// It also does not record which keyword introduced the attributes, so `assert {...}` and `with {...}`
/// are indistinguishable.
fn visit_import_declaration(&mut self, decl: &mut ImportDeclaration<'a>) {
if decl.specifiers.as_ref().is_some_and(|specifiers| specifiers.is_empty()) {
decl.specifiers = None;
}
normalize_with_clause(&mut decl.with_clause);
walk_mut::walk_import_declaration(self, decl);
}
/// As `visit_import_declaration`, for `export ... from "m"`.
fn visit_export_from_declaration(&mut self, decl: &mut ExportFromDeclaration<'a>) {
normalize_with_clause(&mut decl.with_clause);
walk_mut::walk_export_from_declaration(self, decl);
}
/// As `visit_import_declaration`, for `export * from "m"`.
fn visit_export_all_declaration(&mut self, decl: &mut ExportAllDeclaration<'a>) {
normalize_with_clause(&mut decl.with_clause);
walk_mut::walk_export_all_declaration(self, decl);
}
}
/// Drop an empty `with` clause, and forget which keyword introduced a non-empty one.
fn normalize_with_clause(
with_clause: &mut Option<oxc_allocator::Box<'_, oxc_ast::ast::WithClause<'_>>>,
) {
if let Some(inner) = with_clause {
inner.keyword = WithClauseKeyword::With;
if inner.with_entries.is_empty() {
*with_clause = None;
}
}
}