Commit Graph

13 Commits

Author SHA1 Message Date
overlookmotel 07a07933c7 perf(packages/codegen): flatten output in chunks (#26109)
Continuation of #26108.

Previously we built the whole output as a single rope, and then flattened it in one go at the end. This is fine for smaller files, but for larger files it has 2 problems:

1. Each `output += segment` append allocates a 32-byte cons string cell. When printing a large AST, so many of these are allocated that they can fill "new space" and trigger garbage collection. When that happens, all these little cons strings have to be copied into the other half of new space, which is slow. Even worse, if they survive 2 rounds of GC, they get promoted into "old space" where (a) they may get scattered across memory, ruining cache coherence, and (b) they sit there, taking up memory, until the next major GC round.
2. Flattening a rope string which contains any segments which are 2-byte strings (non-Latin1 characters) is way more expensive than flattening a rope which is pure Latin1/ASCII. If a single segment contains Unicode, the entire flatten operation deopts and becomes several times slower.

This PR attacks both these problems by flattening a large output in chunks.

- When output reaches 16 KiB, flatten that as a chunk and store it in an array.
- This leaves all the little cons string cells unreferenced, so garbage collection will discard them all, instead of copying them across to keep them alive.
- If file contains some non-Latin1 characters, that does still heavily impact the performance of flattening the chunk they're in, but only that chunk - other chunks which are pure ASCII still take the fast path.

The 16 KiB limit is a heuristic, chosen after experimentation with different values.

### Benchmarks

The effect of this change is minimal on small files, but dramatic on large ones (negative is faster):

| Fixture | Bytes | Change |
|:---| ---:| ---:|
| `tiny.js` | 26 | \+3.2% |
| `RadixUIAdoptionSection.jsx` | 2,424 | \-0.1% |
| `react.development.js` | 50,496 | \+2.0% |
| `binder.ts` | 126,212 | \-1.9% |
| `lodash.js` | 182,262 | \-32.5% |
| `App.tsx` | 298,130 | \-26.4% |
| `kitchen-sink.tsx` | 662,560 | \-14.2% |
| `antd.js` | 5,100,647 | \-28.4% |

### Total cost of flattening

After this PR, compared to where we were prior to #26106 (positive is slower):

| Fixture | Bytes | Change |
|:---| ---:| ---:|
| `tiny.js` | 26 | \+51.5% |
| `RadixUIAdoptionSection.jsx` | 2,424 | \+134.2% |
| `react.development.js` | 50,496 | \+35.5% |
| `binder.ts` | 126,212 | \+39.0% |
| `lodash.js` | 182,262 | \+45.3% |
| `App.tsx` | 298,130 | \+55.0% |
| `kitchen-sink.tsx` | 662,560 | \+51.3% |
| `antd.js` | 5,100,647 | \+2.2% |

The effect of flattening is still very negative, but (as mentioned in #26106) it was always a cost, just one that we were ignoring. At least we've now managed to mitigate it.

In the case of really large files (`antd.js`), benchmark is pretty much unchanged from before flattening came into the picture. But now our measure _includes_ the large flattening cost which was previously _on top of_ the cost of printing - so the real cost for the user is much reduced from where it was previously.

### Sourcemap builds

Printing with sourcemaps on large files actually gets faster after this change. It was already paying the cost of string flattening, now it pays less. Printing `lodash.js` with sourcemaps is about 15% faster now.
2026-08-26 17:51:49 +00:00
overlookmotel 0dd4db384f perf(packages/codegen): store mapping positions in an Int32Array (#26085)
Perf optimization to sourcemap builds.

Use an `Int32Array` to store sourcemap positions, instead of a plain `Array`.

This `Int32Array` is held in a top-level var and reused for every file. It is replaced by a larger one when required, but will soon reach steady state - a size large enough to service all files being printed - and then ceases growing. Once it reaches that steady state, all the "does it need to grow?" branches become perfectly predictable.

`Int32Array` has 3 advantages over plain arrays:

1. 4 bytes per entry, instead of 8 - lower memory usage and less cache misses.
2. Low garbage collection cost - during garbage collection, GC has to loop through every item in an `Array` checking if it contains heap values (e.g. an `Object`) which the array references, and therefore can't be garbage-collected. `Int32Array`s are statically known to only contain small integers, which don't live on the heap, so this scan is skipped entirely.
3. Recycling - due to not having to worry about GC burden of keeping a typed array live across a GC run, a single `Int32Array` can be kept live and reused over and over for each file.

This change produces a decent perf bump:

| Fixture | Maps end-to-end | Recording pass only |
| --- | --- | --- |
| tiny.js | −7.3% | −34.5% |
| radix | −5.4% | −10.5% |
| react | −2.9% | −6.6% |
| App.tsx | −2.4% | −6.2% |
| binder.ts | −3.3% | −5.4% |
| kitchen-sink | −2.9% | −4.9% |
| lodash | −2.1% | −5.8% |
| antd | −5.1% | −5.3% |

### Why `Int32Array` not `Uint32Array`?

V8's native small integer type ("SMI") is a _signed_ 32-bit integer. Generally speaking, it's preferable to store numbers in this same format, as when reading from an `Int32Array`, V8 statically knows that the value is an SMI, and skips conversion logic and "does this number fit in an SMI?" checks. Ditto when writing - no conversion required to write a known SMI into an `Int32Array`.
2026-08-25 13:54:40 +00:00
overlookmotel d50c985fc2 refactor(packages/codegen): split categories into separate file (#26083)
Pure refactor. Move the category consts (`CAT_IDENT` etc) into a separate file.

2 reasons:

1. `write.ts` had got too large.
2. `index.ts`, which is not part of printer builds, imports `CAT_OTHER`. Importing it from a file containing only consts avoids any problems with minifier not being able to recognise dead code and remove it.
2026-08-25 13:46:38 +00:00
overlookmotel 53f62701f8 perf(packages/codegen): eagerly initialize mapNames (#25979)
Previously `mapNames` property of state was lazily initialized - it started as `null`, and an array is only created when a named mapping is written.

Often lazy initialization is a good strategy, but in this case IMO it's a poor trade-off - it adds a check for every named mapping that's written in return for saving a single allocation per file, and only in files which produce a source map with no names. Allocating 1 empty array is very cheap.
2026-08-24 08:59:56 +00:00
overlookmotel 3216cbb04c refactor(packages/codegen): rename write* and mark* functions (#25973)
Pure refactor. Just rename functions:

- `markWithMap` -> `markMap` for brevity.
- All functions which can produce a named mapping called `*Named`.

This mainly prepares the way for next PR #25975, which introduces separate `write` functions for where a mapping can never be named.
2026-08-22 13:24:58 +00:00
Boshen 1193c9dbaf chore(oxfmt): dogfood operator position at line start (#25937)
dogfood
2026-08-20 14:36:47 +00:00
overlookmotel 53ff08068d perf(packages/codegen): remove lastWasPostfixClose (#25888)
Follow-on after #25585.

Previously `printExpression` checked whether last character written was a `)` or `]` by consulting `lastWasPostfixClose`. Every write kept `lastWasPostfixClose` up to date by reading the last character that was written (`updatePostfixClose`).

It's statically known everywhere that writes a `)` or `]`, so instead add a category `CAT_CLOSE_BRACKET`, and write that as `state.last` using the existing machinery. `printExpression` then does its check with `state.last === CAT_CLOSE_BRACKET`.

This has a large effect on perf because checking the last character that was written in `updatePostfixClose` was expensive and on a hot path.

Benchmark without sourcemap generation:

| Fixture | Bytes | Before | After | Faster by |
|:---| ---:| ---:| ---:| ---:|
| `RadixUIAdoptionSection.jsx` | 2,518 | 0\.01221ms | 0\.00716ms | \+41.4% |
| `react.development.js` | 72,141 | 0\.38326ms | 0\.25377ms | \+33.8% |
| `binder.ts` | 193,077 | 0\.6824ms | 0\.6458ms | \+5.4% |
| `App.tsx` | 415,340 | 1\.8782ms | 1\.7846ms | \+5.0% |
| `lodash.js` | 544,096 | 1\.6802ms | 1\.0902ms | \+35.1% |
| `kitchen-sink.tsx` | 732,222 | 5\.2654ms | 4\.7936ms | \+9.0% |
| `antd.js` | 6,683,633 | 32\.0742ms | 22\.4061ms | \+30.1% |

With sourcemap generation included:

| Fixture | Bytes | Before | After | Faster by |
|:---| ---:| ---:| ---:| ---:|
| `RadixUIAdoptionSection.jsx` | 2,518 | 0\.02126ms | 0\.01616ms | \+24.0% |
| `react.development.js` | 72,141 | 0\.52684ms | 0\.39368ms | \+25.3% |
| `binder.ts` | 193,077 | 1\.0708ms | 1\.0335ms | \+3.5% |
| `App.tsx` | 415,340 | 3\.8221ms | 3\.7476ms | \+2.0% |
| `lodash.js` | 544,096 | 2\.9815ms | 2\.4294ms | \+18.5% |
| `kitchen-sink.tsx` | 732,222 | 9\.8293ms | 9\.5644ms | \+2.7% |
| `antd.js` | 6,683,633 | 54\.3088ms | 44\.3745ms | \+18.3% |

The main reason for the large impact on JS/JSX fixtures is that the JS build gets poisoned by JSX text. Most strings that `updatePostfixClose` sees are interned strings (source literals like `"function "`) but in JSX files it was also seeing dynamic strings (JSX text) which turned it polymorphic. Once it's gone polymorphic, it never goes back, affecting all following JS fixtures (which use the JS build).

Removing the string accesses removes this cost.
2026-08-19 09:20:28 +00:00
overlookmotel 603b904d72 perf(packages/codegen): check sourceText only once (#25887)
Follow-on after #25585. Small optimization.

Sourcemaps can't be generated unless `options.sourceText` is provided. This is already checked at the start. So everywhere else can then rely on `state.sourceText != null`, rather than re-checking it on each write.
2026-08-19 08:12:02 +00:00
overlookmotel 5ad330e96d docs(packages/codegen): reformat comments (#25876)
Continuation of #25848. Pure style change. Reformat a couple of comments I missed previously.
2026-08-18 15:56:13 +00:00
camc314 300763f328 feat(packages/codegen): add full source map support (#25585)
## Summary

Align `oxc-codegen` source maps with Rust `oxc_codegen`, using the Rust implementation as the conformance oracle.

This PR:

- changes `printSync` to return `{ code, map? }`;
- returns a standard Source Map v3 object;
- exposes Rust’s generated source map through `codegen_conformance`;
- compares the complete JS and Rust source maps;
- supports Oxc `start`/`end` offsets and TS-ESTree `range` offsets;
- uses `sourceText` to convert UTF-16 offsets and recover original identifier spellings;
- validates both maps and no-maps builds in both `preserveParens` modes;
- aligns mapping placement across JavaScript, TypeScript, JSX, templates, imports, destructuring, delimiters, and postfix chains;
- handles astral characters and all ECMAScript line terminators.
`loc`-only ASTs are not supported for source maps.

## API change

```ts
const { code, map } = printSync(program, {
  sourcemap: true,
  sourceFileName: "input.js",
  sourceText,
});
```

`map` is a Source Map v3 object:

```ts
interface SourceMap {
  version: 3;
  mappings: string;
  names: string[];
  sources: string[];
  sourcesContent?: string[];
}
```

## Implementation

Mapped writes collect only:

- the generated UTF-16 offset;
- the original source offset;
- the optional original identifier name.

After printing, the generator builds line tables for the generated output and `sourceText`, converts the offsets to line/column positions, suppresses duplicate source positions, and encodes the result as base64 VLQ.

The no-maps builds continue to remove mapping-specific writes at build time.

## Validation flow

```mermaid
flowchart TB
    fixture["Fixture<br/>filename + sourceText + parse options"]

    fixture --> rustParse
    fixture --> jsParse

    subgraph rust["Rust oracle"]
        direction TB
        rustParse["Parse with oxc_parser"]
        normalize["Normalize Rust-only AST information"]
        rustPrint["Print with oxc_codegen<br/>source maps enabled"]
        rustResult["Expected result<br/>{ code, map }"]

        rustParse --> normalize
        normalize --> rustPrint
        rustPrint --> rustResult
    end

    subgraph js["JavaScript candidate"]
        direction TB
        jsParse["Parse ESTree with oxc-parser<br/>start/end or range offsets"]

        plainPrint["Print with no-maps build"]
        plainResult["Actual code"]

        mappedPrint["Print with maps-enabled build"]
        deferred["Record generated offsets,<br/>source offsets, and names"]
        encode["Convert UTF-16 offsets to positions<br/>deduplicate and encode VLQ"]
        mappedResult["Actual result<br/>{ code, map }"]

        jsParse --> plainPrint
        plainPrint --> plainResult

        jsParse --> mappedPrint
        mappedPrint --> deferred
        deferred --> encode
        encode --> mappedResult
    end

    rustParse --> parseGate
    jsParse --> parseGate

    rustResult --> codeGate
    plainResult --> codeGate
    mappedResult --> codeGate

    rustResult --> mapGate
    mappedResult --> mapGate

    parseGate["Parse agreement<br/>Rust success requires no JS errors"]
    codeGate["Code equality<br/>both JS builds equal Rust"]
    mapGate["Source Map v3 equality<br/>mappings + names + sources + content"]

    parseGate --> pass["Fixture passes"]
    codeGate --> pass
    mapGate --> pass
```

Each fixture runs with both `preserveParens: false` and `preserveParens: true`.

## AST normalization

Rust’s AST contains information that ESTree cannot represent. The Rust AST is normalized before printing so the comparison measures code generation rather than representation differences.

This includes:

- parenthesized function and arrow-expression flags;
- `import "m"` versus `import {} from "m"`;
- absent versus empty import-attribute clauses;
- `assert` versus `with` attribute keywords;
- the unavailable ESTree span for a complete `with` clause.
2026-08-18 11:41:34 +00:00
camc314 4cc7ea4595 fix(codegen): reject invalid indent options (#25807)
## Summary

 - throw a TypeError when indent is empty, contains characters other than spaces or tabs, or is not a string
 - preserve the existing default tab indentation and unchanged printIndent hot path
 - update API documentation and validation coverage
2026-08-17 14:22:56 +00:00
camc314 59e8895162 fix(codegen): validate starting indent level (#25550)
## Summary

Validate `startingIndentLevel` before printing to prevent unbounded indentation-cache growth.

- Reject non-finite, fractional, negative, and unsafe integer values with a `RangeError`.
- Document the option as a non-negative integer.
2026-08-12 15:46:26 +00:00
overlookmotel a4478e960a 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>
2026-08-12 13:41:31 +00:00