mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
07a07933c7
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.
912 lines
33 KiB
TypeScript
912 lines
33 KiB
TypeScript
// Writing to the output.
|
|
//
|
|
// Every write records what it ends with, as a category rather than as the character itself -
|
|
// see `categories.ts` for what the categories are, and why.
|
|
|
|
import { debugAssert } from "../asserts.ts";
|
|
import {
|
|
CAT_CLOSE_BRACKET,
|
|
CAT_IDENT,
|
|
CAT_INT_DIGIT,
|
|
CAT_LT,
|
|
CAT_OP_UN_NEG,
|
|
CAT_OP_UN_NOT,
|
|
CAT_OP_UN_NOT_AFTER_LT,
|
|
CAT_OP_UN_PLUS,
|
|
CAT_OP_UPD_DEC,
|
|
CAT_OP_UPD_INC,
|
|
CAT_OTHER,
|
|
CAT_QUESTION,
|
|
CAT_REGEX_SLASH,
|
|
} from "./categories.ts";
|
|
|
|
import type { Category } from "./categories.ts";
|
|
import type {
|
|
MappableNode,
|
|
NamedMappableNode,
|
|
IdentMappableNode,
|
|
UnnamedMappableNode,
|
|
} from "./types.ts";
|
|
import type { State } from "../state.ts";
|
|
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
|
|
|
|
/**
|
|
* Append `code` to the output, and record what it ends with.
|
|
*
|
|
* @param state - Printer state
|
|
* @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 {
|
|
debugAssert(code.length > 0, "`code` should not be an empty string");
|
|
debugAssertCategoryMatches(state, code, last);
|
|
|
|
state.last = last;
|
|
state.output += code;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = code[code.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `name` to the output, and record what it ends with as `CAT_IDENT` -
|
|
* an identifier, a keyword, or the trailing digits of a number.
|
|
*
|
|
* This is `write` with the category fixed. `CAT_IDENT` one of the most common categories,
|
|
* so passing it at every call site is an argument spent on a value which is never anything else.
|
|
*
|
|
* @param state - Printer state
|
|
* @param code - Text to append, never empty
|
|
*/
|
|
export function writeIdent(state: State, code: string): void {
|
|
debugAssert(code.length > 0, "`code` should not be an empty string");
|
|
debugAssertCategoryMatches(state, code, CAT_IDENT);
|
|
|
|
state.last = CAT_IDENT;
|
|
state.output += code;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = code[code.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a private identifier - `#` and `name` - to the output, and record what it ends with.
|
|
*
|
|
* The plain form of `writeWithMapNamedPrivate`.
|
|
* Nothing calls it directly - the TSDown plugin rewrites the mapped calls to it for builds without source map support.
|
|
*
|
|
* `last` is always `CAT_IDENT`, since the name is what is written last, so the caller does not pass it.
|
|
*
|
|
* @param state - Printer state
|
|
* @param name - The identifier's name, which is what follows the `#`, so never empty
|
|
*/
|
|
export function writePrivate(state: State, name: string): void {
|
|
debugAssert(name.length > 0, "`name` should not be an empty string");
|
|
debugAssertCategoryMatches(state, name, CAT_IDENT);
|
|
|
|
state.last = CAT_IDENT;
|
|
state.output += "#";
|
|
state.output += name;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = name[name.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `code` to the output, record what it ends with, and record an unnamed source mapping for `node`.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
*
|
|
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
|
|
* into `write` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param code - Text to append, never empty
|
|
* @param last - Category of the last character of `code`
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node this text came from. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMap(
|
|
state: State,
|
|
code: string,
|
|
last: Category,
|
|
start: number,
|
|
end: number,
|
|
node: UnnamedMappableNode,
|
|
): void {
|
|
debugAssert(code.length > 0, "`code` should not be an empty string");
|
|
debugAssertCategoryMatches(state, code, last);
|
|
|
|
markMapStart(state, start, end, node);
|
|
|
|
state.last = last;
|
|
state.output += code;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = code[code.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `name` to the output, record what it ends with, and record a named source mapping for `node`.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
*
|
|
* `last` is always `CAT_IDENT`, since a name ends in an identifier character, so the caller does not pass it.
|
|
*
|
|
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
|
|
* into `writeIdent` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for
|
|
* the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param name - Name to append, never empty
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node this text came from. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMapNamed(
|
|
state: State,
|
|
name: string,
|
|
start: number,
|
|
end: number,
|
|
node: IdentMappableNode,
|
|
): void {
|
|
debugAssert(name.length > 0, "`name` should not be an empty string");
|
|
debugAssertCategoryMatches(state, name, CAT_IDENT);
|
|
debugAssertNameMatches(node, name);
|
|
|
|
markMapNamed(state, name, false, 0, start, end, node);
|
|
|
|
state.last = CAT_IDENT;
|
|
state.output += name;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = name[name.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a private identifier - `#` and `name` - to the output, record what it ends with,
|
|
* and record a named source mapping for `node`.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
*
|
|
* `last` is always `CAT_IDENT`, since the name is what is written last, so the caller does not pass it.
|
|
*
|
|
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
|
|
* into `writePrivate` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for
|
|
* the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param name - The identifier's name, which is what follows the `#`, so never empty
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Private identifier this text came from. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMapNamedPrivate(
|
|
state: State,
|
|
name: string,
|
|
start: number,
|
|
end: number,
|
|
node: ESTree.PrivateIdentifier,
|
|
): void {
|
|
debugAssert(name.length > 0, "`name` should not be an empty string");
|
|
debugAssertCategoryMatches(state, name, CAT_IDENT);
|
|
debugAssertNameMatches(node, name);
|
|
|
|
markMapNamed(state, name, false, 1, start, end, node);
|
|
|
|
state.last = CAT_IDENT;
|
|
state.output += "#";
|
|
state.output += name;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = name[name.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 the two space functions, the `CAT_INT_DIGIT`, `CAT_LT` and `CAT_QUESTION` adjacency checks,
|
|
* the `CAT_START_OF_*` marker checks, and the `CAT_CLOSE_BRACKET` source map hook - which is the one that runs
|
|
* at the end of printing an expression rather than at the start of one.
|
|
* Every one of them calls `debugAssertLastFresh` first.
|
|
*
|
|
* 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 state - Printer state
|
|
* @param code - Text to append, which unlike `write` may be empty
|
|
*/
|
|
export function writeNoLast(state: State, code: string): void {
|
|
state.output += code;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = true;
|
|
if (code.length > 0) state.lastCharWritten = code[code.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `code` and record an unnamed source mapping for `node`, leaving `state.last` alone.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
*
|
|
* `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 `writeNoLast` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for
|
|
* the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param code - Text to append, which unlike `writeWithMap` may be empty
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node this text came from. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMapNoLast(
|
|
state: State,
|
|
code: string,
|
|
start: number,
|
|
end: number,
|
|
node: UnnamedMappableNode,
|
|
): void {
|
|
markMapStart(state, start, end, node);
|
|
|
|
state.output += code;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = true;
|
|
if (code.length > 0) state.lastCharWritten = code[code.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `name` and record a named source mapping for `node`, leaving `state.last` alone.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
*
|
|
* `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 `writeNoLast` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for
|
|
* the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param name - The identifier's name, so never empty, unlike the plain `NoLast` forms
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node this text came from. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMapNamedNoLast(
|
|
state: State,
|
|
name: string,
|
|
start: number,
|
|
end: number,
|
|
node: IdentMappableNode,
|
|
): void {
|
|
debugAssert(name.length > 0, "`name` should not be an empty string");
|
|
debugAssertNameMatches(node, name);
|
|
|
|
markMapNamed(state, name, false, 0, start, end, node);
|
|
|
|
state.output += name;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = true;
|
|
if (name.length > 0) state.lastCharWritten = name[name.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `name` and record a named source mapping for a JSX identifier, leaving `state.last` alone.
|
|
*
|
|
* The JSX form of `writeWithMapNamedNoLast`. A JSX identifier's name can hold a `-`,
|
|
* so recovering its original name takes a different scan of the source.
|
|
*
|
|
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
|
|
* into `writeNoLast` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for
|
|
* the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param name - The identifier's name, so never empty, unlike the other `NoLast` forms
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - JSX identifier this text came from. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMapNamedJSXNoLast(
|
|
state: State,
|
|
name: string,
|
|
start: number,
|
|
end: number,
|
|
node: ESTree.JSXIdentifier,
|
|
): void {
|
|
debugAssert(name.length > 0, "`name` should not be an empty string");
|
|
debugAssertNameMatches(node, name);
|
|
|
|
markMapNamed(state, name, true, 0, start, end, node);
|
|
|
|
state.output += name;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = true;
|
|
if (name.length > 0) state.lastCharWritten = name[name.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append `code`, recording a mapping for the last source character in `node` immediately before it.
|
|
*
|
|
* Rust uses this for emitted closing delimiters, including synthesized ones. The node end offset is
|
|
* exclusive, so move back by one UTF-16 unit to match Rust's byte-span lookup. That can land on
|
|
* the low surrogate of an astral character, which `generateSourceMap` normalizes back to its start.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
*
|
|
* Builds without source map support have no use for this. For those builds, TSDown plugin rewrites every call
|
|
* into `write` and drops the `start`, `end` and `node` arguments, leaving this unreferenced for the minifier to remove.
|
|
*
|
|
* @param state - Printer state
|
|
* @param code - Text to append, never empty
|
|
* @param last - Category of the last character of `code`
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node whose last source character this text maps to. Read only by debug asserts.
|
|
*/
|
|
export function writeWithMapEnd(
|
|
state: State,
|
|
code: string,
|
|
last: Category,
|
|
start: number,
|
|
end: number,
|
|
node: MappableNode,
|
|
): void {
|
|
debugAssert(code.length > 0, "`code` should not be an empty string");
|
|
debugAssertCategoryMatches(state, code, last);
|
|
|
|
markMapEnd(state, start, end, node);
|
|
|
|
state.last = last;
|
|
state.output += code;
|
|
|
|
if (DEBUG) {
|
|
state.lastIsStale = false;
|
|
state.lastCharWritten = code[code.length - 1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a mapping for `node`'s start offset at the current output position.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
* Builds without source map support have no use for this. In those builds, minifier removes it.
|
|
*
|
|
* @param state - Printer state
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node the mapping points at. Read only by debug asserts.
|
|
*/
|
|
export function markMapStart(state: State, start: number, end: number, node: MappableNode): void {
|
|
if (!SOURCEMAPS) return;
|
|
|
|
debugAssertSpanMatches(node, start, end);
|
|
|
|
if (hasMappableSpan(start, end)) recordMapping(state, start);
|
|
}
|
|
|
|
/**
|
|
* Record a mapping for `node`'s end offset at the current output position.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
* Builds without source map support have no use for this. In those builds, minifier removes it.
|
|
*
|
|
* @param state - Printer state
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node whose end offset the mapping points at. Read only by debug asserts.
|
|
*/
|
|
export function markMapAfter(state: State, start: number, end: number, node: MappableNode): void {
|
|
if (!SOURCEMAPS) return;
|
|
|
|
debugAssertSpanMatches(node, start, end);
|
|
|
|
if (hasMappableSpan(start, end)) recordMapping(state, end);
|
|
}
|
|
|
|
/**
|
|
* Record a mapping a fixed number of characters after `node`'s start offset.
|
|
*
|
|
* The mapping is only recorded where the caller asked for source maps and `node` carries `start` / `end` offsets.
|
|
* Builds without source map support have no use for this. In those builds, minifier removes it.
|
|
*
|
|
* @param state - Printer state
|
|
* @param columnOffset - Number of UTF-16 units to add to `node`'s start offset
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node the mapping points into. Read only by debug asserts.
|
|
*/
|
|
export function markMapAtStartOffset(
|
|
state: State,
|
|
columnOffset: number,
|
|
start: number,
|
|
end: number,
|
|
node: MappableNode,
|
|
): void {
|
|
if (!SOURCEMAPS) return;
|
|
|
|
debugAssertSpanMatches(node, start, end);
|
|
|
|
if (hasMappableSpan(start, end)) recordMapping(state, start + columnOffset);
|
|
}
|
|
|
|
/**
|
|
* Record an unnamed mapping at `node`'s last source character.
|
|
*
|
|
* The end offset is exclusive, so this is one UTF-16 unit back from it, matching Rust's byte-span lookup.
|
|
* `generateSourceMap` normalizes a landing on a low surrogate back to its code point.
|
|
*
|
|
* @param state - Printer state
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node whose last source character the mapping points at. Read only by debug asserts.
|
|
*/
|
|
function markMapEnd(state: State, start: number, end: number, node: MappableNode): void {
|
|
if (!SOURCEMAPS) return;
|
|
|
|
debugAssertSpanMatches(node, start, end);
|
|
|
|
if (hasMappableSpan(start, end)) {
|
|
// `hasMappableSpan` ensured span is non-empty, so `end` is at least 1. `end - 1` cannot go negative.
|
|
recordMapping(state, end - 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a mapping at `node`'s start offset, carrying the name it had in the source.
|
|
*
|
|
* The name is only recorded for the mapping where it differs from the text which is printed.
|
|
*
|
|
* A private identifier's mapping stays on the `#`, where the printed token starts, and the `#` is part
|
|
* of the name recorded - the token is `#name`, so a name of `name` alone would describe a region of
|
|
* the output which includes the `#`.
|
|
*
|
|
* @param state - Printer state
|
|
* @param printedName - Name written to the output, which is the identifier's `name`
|
|
* @param isJSXIdentifier - `true` if the node is a `JSXIdentifier`
|
|
* @param hashLength - Length of the `#` the token is printed behind.
|
|
* 1 if the node is a `PrivateIdentifier`, 0 otherwise.
|
|
* @param start - `node`'s start offset
|
|
* @param end - `node`'s end offset
|
|
* @param node - Node the mapping points at. Read only by debug asserts.
|
|
*/
|
|
function markMapNamed(
|
|
state: State,
|
|
printedName: string,
|
|
isJSXIdentifier: boolean,
|
|
hashLength: 0 | 1,
|
|
start: number,
|
|
end: number,
|
|
node: NamedMappableNode,
|
|
): void {
|
|
if (!SOURCEMAPS) return;
|
|
|
|
debugAssertSpanMatches(node, start, end);
|
|
|
|
if (!hasMappableSpan(start, end)) return;
|
|
|
|
debugAssert(
|
|
state.mapPositions !== null && state.mapNames !== null && state.sourceText !== null,
|
|
"`mapPositions`, `mapNames` and `sourceText` should be defined when source maps are enabled",
|
|
);
|
|
debugAssert(
|
|
!(isJSXIdentifier && hashLength > 0),
|
|
"A node cannot be both a `JSXIdentifier` and a `PrivateIdentifier`",
|
|
);
|
|
|
|
const { sourceText } = state;
|
|
if (start > sourceText.length) return;
|
|
|
|
// `oxc_codegen` suppresses consecutive source positions as it records them. Do this before
|
|
// recovering a name or retaining the mapping, since member-level marks commonly duplicate keys.
|
|
// On the first mapping the read is index -1, which on a typed array is `undefined` - never equal.
|
|
let { mapPositions } = state;
|
|
const { mapPositionsLen } = state;
|
|
if (mapPositions[mapPositionsLen - 1] === start) return;
|
|
|
|
// A mapping carries a name only when the identifier printed differs from the one in the source.
|
|
// When possible, the mapping records the name from source, but if the source range is invalid,
|
|
// it falls back to the printed name.
|
|
//
|
|
// Almost every identifier is printed exactly as it was in source, so we do a quick check first,
|
|
// and only fall back to expensive scanning with Unicode property regexps in rare cases.
|
|
//
|
|
// A span can reach past the name it begins with, since a TypeScript annotation is absorbed into it,
|
|
// so a match has to be followed by a character which cannot continue an identifier.
|
|
//
|
|
// This check is one-sided.
|
|
// * When `matchesSource === true`, the source definitely has the same name that was printed,
|
|
// and the mapping needs no name recorded.
|
|
// * When `matchesSource === false`, nothing is settled yet.
|
|
// It could be a genuine rename, or could be a Unicode escape (`\u0061` printed as `a`),
|
|
// or a non-ASCII character after the name, which `isDefinitelyIdentifierBoundary` will not classify.
|
|
//
|
|
// A private identifier prints as `#` followed by its name, and its span covers the `#`, so the token is `#name`.
|
|
// That is what the source is compared against, and what gets recorded as the name.
|
|
const nameStart = start + hashLength;
|
|
const nameEnd = nameStart + printedName.length;
|
|
const matchesSource =
|
|
end <= sourceText.length
|
|
&& nameEnd <= end
|
|
&& (hashLength === 0 || sourceText.charCodeAt(start) === 35) /* # */
|
|
&& sourceText.startsWith(printedName, nameStart)
|
|
&& (nameEnd === end || isDefinitelyIdentifierBoundary(sourceText.charCodeAt(nameEnd)));
|
|
|
|
if (!matchesSource) {
|
|
// Read the name out of the source.
|
|
// We can use it for a definitive comparison, which the quick check above couldn't.
|
|
const originalName = isJSXIdentifier
|
|
? originalJSXNameFromSource(sourceText, start, end)
|
|
: originalNameFromSource(sourceText, start, end);
|
|
|
|
// A transformed or hand-authored AST can carry ranges unrelated to `sourceText`.
|
|
// Preserve the existing fallback in that case instead of recording an arbitrary source substring.
|
|
if (originalName === undefined || !isSameToken(originalName, printedName, hashLength)) {
|
|
state.mapNames.push(
|
|
mapPositionsLen >> 1,
|
|
originalName === undefined ? printedName : originalName,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mapPositionsLen === mapPositions.length) mapPositions = state.growMapPositions();
|
|
mapPositions[mapPositionsLen] = state.spilledOutputLength + state.output.length;
|
|
mapPositions[mapPositionsLen + 1] = start;
|
|
state.mapPositionsLen = mapPositionsLen + 2;
|
|
}
|
|
|
|
/**
|
|
* Whether the offsets given describe a span a mapping can be recorded for.
|
|
*
|
|
* A transformed or hand-authored AST can carry anything at all - including nodes with no offsets,
|
|
* which arrive here as `undefined` however the parameters are typed - so they are checked rather than trusted.
|
|
* Proving them here is what lets each recorder skip the lower half of its own bounds check -
|
|
* `start` is not negative, and `end` is greater than it.
|
|
*
|
|
* @param start - Start offset of the node the mapping is for
|
|
* @param end - End offset of that node
|
|
* @returns `true` if the offsets are a non-empty span of safe integers
|
|
*/
|
|
function hasMappableSpan(start: number, end: number): boolean {
|
|
return (
|
|
typeof start === "number"
|
|
&& typeof end === "number"
|
|
&& Number.isSafeInteger(start)
|
|
&& Number.isSafeInteger(end)
|
|
&& start >= 0
|
|
&& end > start
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Record source mapping at specified offset.
|
|
*
|
|
* @param state - Printer state
|
|
* @param sourceOffset - Source offset to record mapping for
|
|
*/
|
|
function recordMapping(state: State, sourceOffset: number): void {
|
|
debugAssert(
|
|
state.mapPositions !== null && state.sourceText !== null,
|
|
"`mapPositions` and `sourceText` should be defined when source maps are enabled",
|
|
);
|
|
|
|
if (sourceOffset > state.sourceText.length) return;
|
|
|
|
// On the first mapping the read is index -1, which on a typed array is `undefined` - never equal
|
|
let { mapPositions } = state;
|
|
const { mapPositionsLen } = state;
|
|
if (mapPositions[mapPositionsLen - 1] === sourceOffset) return;
|
|
|
|
if (mapPositionsLen === mapPositions.length) mapPositions = state.growMapPositions();
|
|
mapPositions[mapPositionsLen] = state.spilledOutputLength + state.output.length;
|
|
mapPositions[mapPositionsLen + 1] = sourceOffset;
|
|
state.mapPositionsLen = mapPositionsLen + 2;
|
|
}
|
|
|
|
/**
|
|
* Is `originalName` the same token as `printedName`, taking into account leading `#` if `hashLength === 1`.
|
|
*/
|
|
function isSameToken(originalName: string, printedName: string, hashLength: 0 | 1): boolean {
|
|
if (hashLength === 0) return originalName === printedName;
|
|
|
|
// Compare with 2 operations rather than `originalName === "#" + printedName`
|
|
// to avoid allocating a temporary string
|
|
return (
|
|
originalName.length === printedName.length + 1
|
|
&& originalName.charCodeAt(0) === 35 /* # */
|
|
&& originalName.endsWith(printedName)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Recover the original identifier spelling from validated source offsets.
|
|
*
|
|
* A private identifier's span covers its leading `#`. The scan steps over it to reach the name behind,
|
|
* and the `#` is part of what is returned - the token is `#name`.
|
|
*
|
|
* @param sourceText - Original source text
|
|
* @param start - Offset the identifier starts at, already validated
|
|
* @param end - Offset the scan stops at, already validated
|
|
* @returns The identifier as it appears in the source, or `undefined` if the offsets do not span one
|
|
*/
|
|
function originalNameFromSource(
|
|
sourceText: string,
|
|
start: number,
|
|
end: number,
|
|
): string | undefined {
|
|
if (end > sourceText.length) return undefined;
|
|
|
|
let index = start;
|
|
if (index < end && sourceText.charCodeAt(index) === 35) index++; // `#` in a private identifier
|
|
const identifierStart = index;
|
|
|
|
while (index < end) {
|
|
const matcher = index === identifierStart ? IDENT_START_REGEX : IDENT_CONTINUE_REGEX;
|
|
if (sourceText.charCodeAt(index) === 92) {
|
|
const length = unicodeEscapeLength(sourceText, index, end);
|
|
if (length === 0) break;
|
|
const codePoint = unicodeEscapeCodePoint(sourceText, index, length);
|
|
if (codePoint > 0x10ffff || !matcher.test(String.fromCodePoint(codePoint))) break;
|
|
index += length;
|
|
continue;
|
|
}
|
|
|
|
const codePoint = sourceText.codePointAt(index) as number;
|
|
const char = String.fromCodePoint(codePoint);
|
|
if (!matcher.test(char)) break;
|
|
index += char.length;
|
|
}
|
|
|
|
return index === identifierStart ? undefined : sourceText.slice(start, index);
|
|
}
|
|
|
|
/**
|
|
* Recover a JSX identifier's original spelling from validated source offsets.
|
|
*
|
|
* JSX identifiers admit `-`, which is not an ECMAScript identifier character, so the scan in
|
|
* `originalNameFromSource` would stop short of one. Their spans also do not absorb TypeScript
|
|
* annotations, so the ESTree end offset is already exact and the whole span is the name.
|
|
*
|
|
* @param sourceText - Original source text
|
|
* @param start - Start offset of the node, already validated
|
|
* @param end - End offset of the node, already validated
|
|
* @returns The identifier as it was spelled in the source, or `undefined` if the offsets do not span one
|
|
*/
|
|
function originalJSXNameFromSource(
|
|
sourceText: string,
|
|
start: number,
|
|
end: number,
|
|
): string | undefined {
|
|
if (end > sourceText.length) return undefined;
|
|
|
|
const originalName = sourceText.slice(start, end);
|
|
return JSX_IDENTIFIER_REGEX.test(originalName) ? originalName : undefined;
|
|
}
|
|
|
|
/**
|
|
* Return the UTF-16 length of a `\u` identifier escape within `end`, or `0` if invalid.
|
|
*
|
|
* Covers both the fixed 4-digit form and the braced `\u{...}` one.
|
|
*
|
|
* @param sourceText - Original source text
|
|
* @param index - Offset of the `\` starting the escape
|
|
* @param end - Offset the escape must finish within
|
|
* @returns Length of the escape in UTF-16 units, or `0` if it is not a valid one
|
|
*/
|
|
function unicodeEscapeLength(sourceText: string, index: number, end: number): number {
|
|
if (sourceText.charCodeAt(index + 1) !== 117) return 0; // `u`
|
|
|
|
const firstHex = index + 2;
|
|
if (sourceText.charCodeAt(firstHex) === 123) {
|
|
let cursor = firstHex + 1;
|
|
const firstDigit = cursor;
|
|
while (cursor < end && isHexDigit(sourceText.charCodeAt(cursor))) cursor++;
|
|
return cursor > firstDigit && sourceText.charCodeAt(cursor) === 125 ? cursor - index + 1 : 0;
|
|
}
|
|
|
|
const escapeEnd = firstHex + 4;
|
|
if (escapeEnd > end) return 0;
|
|
for (let cursor = firstHex; cursor < escapeEnd; cursor++) {
|
|
if (!isHexDigit(sourceText.charCodeAt(cursor))) return 0;
|
|
}
|
|
return escapeEnd - index;
|
|
}
|
|
|
|
/**
|
|
* Decode the code point in a syntactically valid identifier escape.
|
|
*
|
|
* @param sourceText - Original source text
|
|
* @param index - Offset of the `\` starting the escape
|
|
* @param length - Length of the escape, as returned by `unicodeEscapeLength`
|
|
* @returns The code point the escape denotes
|
|
*/
|
|
function unicodeEscapeCodePoint(sourceText: string, index: number, length: number): number {
|
|
const braced = sourceText.charCodeAt(index + 2) === 123;
|
|
const digitsStart = index + (braced ? 3 : 2);
|
|
const digitsEnd = index + length - (braced ? 1 : 0);
|
|
return Number.parseInt(sourceText.slice(digitsStart, digitsEnd), 16);
|
|
}
|
|
|
|
/**
|
|
* Whether `code` is an ASCII hexadecimal digit.
|
|
*
|
|
* @param code - Character code
|
|
* @returns `true` if `code` is `0`-`9`, `a`-`f` or `A`-`F`
|
|
*/
|
|
function isHexDigit(code: number): boolean {
|
|
return (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102);
|
|
}
|
|
|
|
/**
|
|
* Whether an ASCII character definitely cannot continue any supported identifier spelling.
|
|
*
|
|
* @param code - Character code
|
|
* @returns `true` only for an ASCII character which cannot continue an identifier. Every non-ASCII
|
|
* character gives `false`, since this runtime's tables cannot settle it
|
|
*/
|
|
function isDefinitelyIdentifierBoundary(code: number): boolean {
|
|
return (
|
|
code <= 0x7f
|
|
&& !((code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122))
|
|
&& code !== 36 // `$`
|
|
&& code !== 45 // `-` in JSX identifiers
|
|
&& code !== 92 // `\` starting a Unicode escape
|
|
&& code !== 95 // `_`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Assert that the offsets a mapping is recorded from are the node's own.
|
|
*
|
|
* Callers extract `start` and `end` themselves, where the node's type is known statically and the reads
|
|
* are monomorphic. This is what holds them to the node they claim to describe.
|
|
*
|
|
* Debug builds only. Removed by minifier in release builds.
|
|
*
|
|
* @param node - Node the mapping is for
|
|
* @param start - Start offset passed for it
|
|
* @param end - End offset passed for it
|
|
*/
|
|
function debugAssertSpanMatches(node: MappableNode, start: number, end: number): void {
|
|
debugAssert(
|
|
start === node.start && end === node.end,
|
|
() => `\`${node.type}\` spans ${node.start}-${node.end}, but ${start}-${end} was passed`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Assert that the name a mapping records is the text which was printed.
|
|
*
|
|
* The `Named` write functions record what they print, on the premise that an identifier's `name` and
|
|
* the text printed for it are one and the same string. This is what holds that premise up.
|
|
*
|
|
* Debug builds only. Removed by minifier in release builds.
|
|
*
|
|
* @param node - Node the mapping is for
|
|
* @param name - Name the mapping records, which is what was printed
|
|
*/
|
|
function debugAssertNameMatches(node: NamedMappableNode, name: string): void {
|
|
debugAssert(
|
|
node.name === name,
|
|
() => `\`${node.type}\` has name "${node.name}", but "${name}" was printed`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param state - Printer state
|
|
*/
|
|
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;
|
|
const IDENT_START_REGEX = /[\p{ID_Start}$_]/u;
|
|
const JSX_IDENTIFIER_REGEX = /^[\p{ID_Start}$_](?:[\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.
|
|
*
|
|
* @param state - Printer state
|
|
* @param code - Code being appended to output
|
|
* @param last - Category of the last character of `code`
|
|
* @throws - If `last` and `code` do not match
|
|
*/
|
|
function debugAssertCategoryMatches(state: State, code: string, last: Category): void {
|
|
if (!DEBUG) return;
|
|
|
|
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 if (ch === ")" || ch === "]") {
|
|
ok = last === CAT_CLOSE_BRACKET;
|
|
} 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)}`);
|
|
}
|