perf(estree/tokens): introduce ESTreeTokenConfig trait (#19842)

Introduce `ESTreeTokenConfig` trait and allow option for the "flavor" of tokens to be set at compile time.

When producing Espree-style tokens, this allows cutting out all the special-case logic related to `JSXIdentifier` tokens and removing 2 branches from `emit_identifier_at`.

Small effect on perf - only +0.3% on ESTree tokens benchmark.
This commit is contained in:
overlookmotel committed 2026-02-28 16:13:09 +00:00
1 parent 5036bb69e8
commit 4b0611a5dd
6 files changed
+238 -91

No files matched your search

+2 -2
View File
@@ -8,7 +8,7 @@ use napi_derive::napi;
use oxc_allocator::Allocator;
use oxc_ast_visit::utf8_to_utf16::Utf8ToUtf16;
use oxc_estree_tokens::{ESTreeTokenOptions, to_estree_tokens_json};
use oxc_estree_tokens::{ESTreeTokenOptionsJS, to_estree_tokens_json};
use oxc_linter::RawTransferMetadata2 as RawTransferMetadata;
use oxc_napi::get_source_type;
use oxc_parser::{ParseOptions, Parser, ParserReturn, config::RuntimeParserConfig};
@@ -219,7 +219,7 @@ unsafe fn parse_raw_impl(
program,
original_source_text,
&span_converter,
ESTreeTokenOptions::test262(),
ESTreeTokenOptionsJS,
);
span_converter.convert_program(program);
+91 -82
View File
@@ -16,50 +16,10 @@ use oxc_estree::{
use oxc_parser::{Kind, Token};
use oxc_span::{GetSpan, Span};
/// Options for serializing tokens.
///
/// Espree (`test262`) and TS-ESLint (`typescript`) differ in several ways:
///
/// * `yield`, `let`, `static` used as identifiers (`obj = { yield: 1, let: 2, static: 3 };`)
/// * Espree emits these as `Keyword` tokens.
/// * TS-ESLint as `Identifier` tokens.
/// * Escaped identifiers (e.g. `\u0061`)
/// * Espree decodes escapes in the token `value`.
/// * TS-ESLint preserves the raw source text.
/// * JSX namespaced names (`<ns:tag>`)
/// * Espree emits `JSXIdentifier` tokens for both parts,
/// * TS-ESLint leaves them as their default token type (`Identifier`).
/// * Member expressions in JSX expressions (`<C x={a.b}>`)
/// * Espree emits them as `Identifier` tokens.
/// * TS-ESLint emits `JSXIdentifier` tokens for non-computed member expression identifiers
/// inside JSX expression containers.
#[derive(Debug, Clone, Copy)]
pub struct ESTreeTokenOptions {
pub exclude_legacy_keyword_identifiers: bool,
pub decode_identifier_escapes: bool,
pub jsx_namespace_jsx_identifiers: bool,
pub member_expr_in_jsx_expression_jsx_identifiers: bool,
}
impl ESTreeTokenOptions {
pub const fn test262() -> Self {
Self {
exclude_legacy_keyword_identifiers: true,
decode_identifier_escapes: true,
jsx_namespace_jsx_identifiers: true,
member_expr_in_jsx_expression_jsx_identifiers: false,
}
}
pub const fn typescript() -> Self {
Self {
exclude_legacy_keyword_identifiers: false,
decode_identifier_escapes: false,
jsx_namespace_jsx_identifiers: false,
member_expr_in_jsx_expression_jsx_identifiers: true,
}
}
}
mod options;
pub use options::{
ESTreeTokenConfig, ESTreeTokenOptions, ESTreeTokenOptionsJS, ESTreeTokenOptionsTS,
};
/// Serialize tokens to JSON.
///
@@ -68,12 +28,12 @@ impl ESTreeTokenOptions {
///
/// `source_text` must be the original source text, prior to BOM removal.
/// i.e. if the file has a BOM, it must be present at the start of `source_text`.
pub fn to_estree_tokens_json(
pub fn to_estree_tokens_json<O: ESTreeTokenConfig>(
tokens: &[Token],
program: &Program<'_>,
source_text: &str,
span_converter: &Utf8ToUtf16,
options: ESTreeTokenOptions,
options: O,
) -> String {
// Estimated size of a single token serialized to JSON, in bytes.
// TODO: Estimate this better based on real-world usage.
@@ -92,12 +52,12 @@ pub fn to_estree_tokens_json(
///
/// `source_text` must be the original source text, prior to BOM removal.
/// i.e. if the file has a BOM, it must be present at the start of `source_text`.
pub fn to_estree_tokens_pretty_json(
pub fn to_estree_tokens_pretty_json<O: ESTreeTokenConfig>(
tokens: &[Token],
program: &Program<'_>,
source_text: &str,
span_converter: &Utf8ToUtf16,
options: ESTreeTokenOptions,
options: O,
) -> String {
// Estimated size of a single token serialized to JSON, in bytes.
// TODO: Estimate this better based on real-world usage.
@@ -333,13 +293,13 @@ use u32_string::U32String;
/// When a visitor method encounters an AST node that requires a token type override
/// (e.g. a keyword used as an identifier), it serializes all preceding tokens with their default types,
/// then serializes the overridden token with its corrected type.
fn serialize_tokens(
fn serialize_tokens<O: ESTreeTokenConfig>(
serializer: impl Serializer,
tokens: &[Token],
program: &Program<'_>,
source_text: &str,
span_converter: &Utf8ToUtf16,
options: ESTreeTokenOptions,
options: O,
) {
let mut context = ESTreeTokenContext {
seq: serializer.serialize_sequence(),
@@ -347,23 +307,72 @@ fn serialize_tokens(
source_text,
span_converter: span_converter.converter(),
options,
jsx_state: JSXState::default(),
jsx_state: O::JSXState::default(),
};
context.visit_program(program);
context.finish();
}
/// JSX state.
/// Trait for JSX state.
///
/// Used in TS style for tracking when to emit JSX identifiers.
///
/// Implemented by [`JSXStateJS`] and [`JSXStateTS`].
/// In JS style, there is no state to track, so [`JSXStateJS`] is a ZST which implements all methods as no-ops.
/// In TS style, [`JSXStateTS`] implements all methods, because state is required.
pub trait JSXState: Default {
/// Call when entering a JSX expression.
fn enter_jsx_expression(&mut self);
/// Call when exiting a JSX expression.
fn exit_jsx_expression(&mut self);
/// Call when entering a `MemberExpression`.
fn enter_member_expression(&mut self, member_expr: &MemberExpression<'_>);
/// Call when exiting a `MemberExpression`.
fn exit_member_expression(&mut self, member_expr: &MemberExpression<'_>);
/// Returns `true` if current `Identifier` token should be emitted as a `JSXIdentifier` token.
fn should_emit_jsx_identifier(&self) -> bool;
}
/// No-op JSX state for JS-style tokens.
#[derive(Default)]
pub struct JSXStateJS;
impl JSXState for JSXStateJS {
#[inline(always)]
fn enter_jsx_expression(&mut self) {}
#[inline(always)]
fn exit_jsx_expression(&mut self) {}
#[inline(always)]
fn enter_member_expression(&mut self, _member_expr: &MemberExpression<'_>) {}
#[inline(always)]
fn exit_member_expression(&mut self, _member_expr: &MemberExpression<'_>) {}
#[expect(clippy::inline_always)]
#[inline(always)]
fn should_emit_jsx_identifier(&self) -> bool {
false
}
}
/// JSX state for TS-style tokens.
///
/// Used in TS style for tracking when to emit JSX identifiers.
#[derive(Default)]
#[expect(clippy::struct_field_names)]
struct JSXState {
pub struct JSXStateTS {
jsx_expression_depth: u32,
member_expression_depth: u32,
computed_member_depth: u32,
}
impl JSXState {
impl JSXState for JSXStateTS {
#[inline]
fn enter_jsx_expression(&mut self) {
self.jsx_expression_depth += 1;
@@ -409,7 +418,7 @@ impl JSXState {
/// After the AST walk, any remaining tokens are serialized with default types.
///
/// This works because AST visitation occurs in source order, so same order as tokens in the iterator.
struct ESTreeTokenContext<'b, S: SequenceSerializer> {
struct ESTreeTokenContext<'b, O: ESTreeTokenConfig, S: SequenceSerializer> {
/// JSON sequence serializer.
/// Tokens are serialized into this serializer.
seq: S,
@@ -421,23 +430,23 @@ struct ESTreeTokenContext<'b, S: SequenceSerializer> {
/// `None` if source is ASCII-only.
span_converter: Option<Utf8ToUtf16Converter<'b>>,
/// Options
options: ESTreeTokenOptions,
// JSX state. Used in TS-ESLint emulation mode.
jsx_state: JSXState,
options: O,
// JSX state. Used when outputting tokens in TS style.
jsx_state: O::JSXState,
}
impl<'b, S: SequenceSerializer> ESTreeTokenContext<'b, S> {
/// Emit the token at `start` as `Identifier`, unless it's a legacy keyword and
/// `exclude_legacy_keyword_identifiers` is set (in which case it gets `Keyword` type).
impl<'b, O: ESTreeTokenConfig, S: SequenceSerializer> ESTreeTokenContext<'b, O, S> {
/// Emit the token at `start` as `Identifier`, unless it's a legacy keyword and serializing in JS style
/// (in which case it gets `Keyword` type).
///
/// `name` is the decoded identifier name from the AST node.
/// When the token has no escapes, `name` points into the source text, same as slicing it.
/// When the token has escapes and `decode_identifier_escapes` is enabled, `name` provides
/// the decoded value. Only when escapes are present but decoding is disabled do we need to
/// fall back to slicing the raw source text (preserving the escape sequences in the output).
/// When the token has escapes and using JS style, `name` provides the decoded value.
/// Only when escapes are present but decoding is disabled (TS style) do we need to fall back to
/// slicing the raw source text (preserving the escape sequences in the output).
fn emit_identifier_at(&mut self, start: u32, name: &str) {
let token = self.advance_to(start);
let token_type = if self.options.exclude_legacy_keyword_identifiers
let token_type = if self.options.is_js()
&& matches!(token.kind(), Kind::Yield | Kind::Let | Kind::Static)
{
TokenType::new("Keyword")
@@ -452,13 +461,13 @@ impl<'b, S: SequenceSerializer> ESTreeTokenContext<'b, S> {
// Only fall back to raw source text when the token contains escapes *and* decoding is disabled,
// since escape sequences contain `\` which needs JSON escaping.
// Escaped identifiers are extremely rare, so handle them in `#[cold]` branch.
if self.options.decode_identifier_escapes || !token.escaped() {
if self.options.is_js() || !token.escaped() {
self.serialize_safe_token(token, token_type, name);
} else {
#[cold]
#[inline(never)]
fn emit<S: SequenceSerializer>(
ctx: &mut ESTreeTokenContext<'_, S>,
fn emit<O: ESTreeTokenConfig, S: SequenceSerializer>(
ctx: &mut ESTreeTokenContext<'_, O, S>,
token: &Token,
token_type: TokenType,
) {
@@ -570,7 +579,7 @@ impl<'b, S: SequenceSerializer> ESTreeTokenContext<'b, S> {
}
}
impl<'a, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, S> {
impl<'a, O: ESTreeTokenConfig, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, O, S> {
fn visit_ts_type_name(&mut self, type_name: &TSTypeName<'a>) {
// `this` is emitted as `Identifier` token instead of `Keyword`
match type_name {
@@ -625,9 +634,7 @@ impl<'a, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, S> {
}
fn visit_identifier_name(&mut self, identifier: &IdentifierName<'a>) {
if self.options.member_expr_in_jsx_expression_jsx_identifiers
&& self.jsx_state.should_emit_jsx_identifier()
{
if self.options.is_ts() && self.jsx_state.should_emit_jsx_identifier() {
self.emit_jsx_identifier_at(identifier.span.start, &identifier.name);
} else {
self.emit_identifier_at(identifier.span.start, &identifier.name);
@@ -635,9 +642,7 @@ impl<'a, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, S> {
}
fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) {
if self.options.member_expr_in_jsx_expression_jsx_identifiers
&& self.jsx_state.should_emit_jsx_identifier()
{
if self.options.is_ts() && self.jsx_state.should_emit_jsx_identifier() {
self.emit_jsx_identifier_at(identifier.span.start, &identifier.name);
} else {
self.emit_identifier_at(identifier.span.start, &identifier.name);
@@ -657,17 +662,20 @@ impl<'a, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, S> {
// `identifier.name` has `#` stripped and escapes decoded by the parser, and is JSON-safe.
// Use it in most cases — if token is not marked as escaped, it's JSON-safe, so can skip JSON encoding.
// When `self.options.decode_identifier_escapes` is `true`, token `value` should *always* be
// the unescaped version, so can also use `name` from AST node and skip JSON encoding.
// When `self.is_js()` is `true`, token `value` should *always* be the unescaped version,
// so can also use `name` from AST node and skip JSON encoding.
// Only fall back to raw source text when the token contains escapes *and* decoding is disabled,
// since escape sequences contain `\` which needs JSON escaping.
// Escaped identifiers are extremely rare, so handle them in `#[cold]` branch.
if self.options.decode_identifier_escapes || !token.escaped() {
if self.options.is_js() || !token.escaped() {
self.serialize_safe_token(token, TokenType::new("PrivateIdentifier"), &identifier.name);
} else {
#[cold]
#[inline(never)]
fn emit<S: SequenceSerializer>(ctx: &mut ESTreeTokenContext<'_, S>, token: &Token) {
fn emit<O: ESTreeTokenConfig, S: SequenceSerializer>(
ctx: &mut ESTreeTokenContext<'_, O, S>,
token: &Token,
) {
// Strip leading `#`
let value = &ctx.source_text[token.start() as usize + 1..token.end() as usize];
ctx.serialize_unsafe_token(token, TokenType::new("PrivateIdentifier"), value);
@@ -763,11 +771,12 @@ impl<'a, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, S> {
}
fn visit_jsx_namespaced_name(&mut self, name: &JSXNamespacedName<'a>) {
if self.options.jsx_namespace_jsx_identifiers {
if self.options.is_js() {
self.emit_jsx_identifier_at(name.namespace.span.start, &name.namespace.name);
self.emit_jsx_identifier_at(name.name.span.start, &name.name.name);
} else {
// In TS mode, these tokens retain their default type (`Identifier`)
}
// When `!jsx_namespace_jsx_identifiers`, these tokens retain their default type
}
fn visit_jsx_expression_container(&mut self, container: &JSXExpressionContainer<'a>) {
@@ -838,7 +847,7 @@ impl<'a, S: SequenceSerializer> Visit<'a> for ESTreeTokenContext<'_, S> {
}
}
impl<S: SequenceSerializer> ESTreeTokenContext<'_, S> {
impl<O: ESTreeTokenConfig, S: SequenceSerializer> ESTreeTokenContext<'_, O, S> {
/// Emit template quasis interleaved with their interpolated parts (expressions or TS types).
///
/// `TemplateElement.span` excludes delimiters (parser adjusts `start + 1`),
+138
View File
@@ -0,0 +1,138 @@
// `#[inline(always)]` used on all methods as they're tiny, and to ensure compiler removes dead code
// resulting from static values
#![expect(clippy::inline_always)]
use crate::{JSXState, JSXStateJS, JSXStateTS};
/// Trait for options for serializing tokens.
///
/// # Token styles
///
/// Espree (JS) and TS-ESLint (TS) differ in several ways in the tokens they produce.
///
/// 1. `yield`, `let`, `static` used as identifiers (`obj = { yield: 1, let: 2, static: 3 };`)
/// * Espree emits these as `Keyword` tokens.
/// * TS-ESLint as `Identifier` tokens.
///
/// 2. Escaped identifiers (e.g. `\u0061`)
/// * Espree decodes escapes in the token `value`.
/// * TS-ESLint preserves the raw source text.
///
/// 3. JSX namespaced names (`<ns:tag>`)
/// * Espree emits `JSXIdentifier` tokens for both parts,
/// * TS-ESLint leaves them as their default token type (`Identifier`).
///
/// 4. Member expressions in JSX expressions (`<C x={a.b}>`)
/// * Espree emits them as `Identifier` tokens.
/// * TS-ESLint emits `JSXIdentifier` tokens for non-computed member expression identifiers
/// inside JSX expression containers.
///
/// # Options
///
/// This trait is implemented by 3 structs which can be passed as options to [`to_estree_tokens_json`] and
/// [`to_estree_tokens_pretty_json`]:
///
/// * [`ESTreeTokenOptions`]
/// * [`ESTreeTokenOptionsJS`]
/// * [`ESTreeTokenOptionsTS`]
///
/// The difference between them is:
/// * [`ESTreeTokenOptions`] supports both JS and TS styles, and branches at runtime to handle the differences.
/// * [`ESTreeTokenOptionsJS`] and [`ESTreeTokenOptionsTS`] only support a single style, and branches for the other
/// style are removed as dead code at compile time.
///
/// If your application only uses one style of tokens, use [`ESTreeTokenOptionsJS`] or [`ESTreeTokenOptionsTS`].
/// That will produce the smallest binary size and fastest runtime performance.
///
/// If your application uses either JS and TS style tokens depending on a condition only known at runtime,
/// [`ESTreeTokenOptions`] is likely preferable. It is marginally slower, but avoids compiling the code for serializing
/// tokens twice in the binary.
///
/// [`to_estree_tokens_json`]: crate::to_estree_tokens_json
/// [`to_estree_tokens_pretty_json`]: crate::to_estree_tokens_pretty_json
pub trait ESTreeTokenConfig {
/// JSX state type.
type JSXState: JSXState;
/// Returns `true` if serializing in TS style.
fn is_ts(&self) -> bool;
/// Returns `true` if serializing in JS style.
#[inline(always)]
fn is_js(&self) -> bool {
!self.is_ts()
}
}
/// Options for serializing tokens in JS style.
///
/// Prefer this over [`ESTreeTokenOptions`] when your application only uses JS style tokens,
/// as the code paths for TS style can be removed.
///
/// If your application uses both JS and TS style tokens, [`ESTreeTokenOptions`] is probably preferable
/// as it can be used to serialize both styles without binary including all this code twice.
///
/// See [`ESTreeTokenConfig`] for more details.
#[derive(Clone, Copy, Default, Debug)]
pub struct ESTreeTokenOptionsJS;
impl ESTreeTokenConfig for ESTreeTokenOptionsJS {
/// No-op JSX state, because no state is required for JS-style tokens.
type JSXState = JSXStateJS;
#[inline(always)]
fn is_ts(&self) -> bool {
false
}
}
/// Options for serializing tokens in TS style.
///
/// Prefer this over [`ESTreeTokenOptions`] when your application only uses TS style tokens,
/// as the code paths for JS style can be removed.
///
/// If your application uses both JS and TS style tokens, [`ESTreeTokenOptions`] is probably preferable
/// as it can be used to serialize both styles without binary including all this code twice.
///
/// See [`ESTreeTokenConfig`] for more details.
#[derive(Clone, Copy, Default, Debug)]
pub struct ESTreeTokenOptionsTS;
impl ESTreeTokenConfig for ESTreeTokenOptionsTS {
/// Working JSX state, because state is required for TS-style tokens.
type JSXState = JSXStateTS;
#[inline(always)]
fn is_ts(&self) -> bool {
true
}
}
/// Options for serializing tokens in either JS or TS style.
///
/// If your application uses both JS and TS style tokens, [`ESTreeTokenOptions`] is probably preferable
/// over [`ESTreeTokenOptionsJS`] and [`ESTreeTokenOptionsTS`], as it can be used to serialize both styles
/// without binary including all this code twice.
///
/// See [`ESTreeTokenConfig`] for more details.
#[derive(Clone, Copy, Debug)]
pub struct ESTreeTokenOptions {
is_ts: bool,
}
impl ESTreeTokenOptions {
#[inline(always)]
pub fn new(is_ts: bool) -> Self {
Self { is_ts }
}
}
impl ESTreeTokenConfig for ESTreeTokenOptions {
/// Working JSX state, because state is required for TS-style tokens.
type JSXState = JSXStateTS;
#[inline(always)]
fn is_ts(&self) -> bool {
self.is_ts
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ use oxc_ast_macros::ast;
use oxc_ast_visit::utf8_to_utf16::Utf8ToUtf16;
use oxc_data_structures::box_macros::boxed_array;
use oxc_diagnostics::OxcDiagnostic;
use oxc_estree_tokens::{ESTreeTokenOptions, to_estree_tokens_json};
use oxc_estree_tokens::{ESTreeTokenOptionsJS, to_estree_tokens_json};
use oxc_semantic::AstNode;
use oxc_span::Span;
@@ -576,7 +576,7 @@ impl Linter {
program,
original_source_text,
&span_converter,
ESTreeTokenOptions::test262(),
ESTreeTokenOptionsJS,
);
let tokens_json = allocator.alloc_str(&tokens_json);
let tokens_offset = tokens_json.as_ptr() as u32;
+2 -2
View File
@@ -1,7 +1,7 @@
use oxc_allocator::Allocator;
use oxc_ast_visit::utf8_to_utf16::Utf8ToUtf16;
use oxc_benchmark::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use oxc_estree_tokens::{ESTreeTokenOptions, to_estree_tokens_json};
use oxc_estree_tokens::{ESTreeTokenOptionsJS, to_estree_tokens_json};
use oxc_parser::{ParseOptions, Parser, ParserReturn, config::RuntimeParserConfig};
use oxc_tasks_common::TestFiles;
@@ -145,7 +145,7 @@ fn bench_estree_tokens(criterion: &mut Criterion) {
&program,
program.source_text,
&span_converter,
ESTreeTokenOptions::test262(),
ESTreeTokenOptionsJS,
);
let tokens_json = black_box(tokens_json);
// Allocate tokens JSON into arena, same as linter and NAPI parser package do
+3 -3
View File
@@ -854,7 +854,7 @@ pub fn run_estree_test262_tokens(files: &[Test262File]) -> Vec<CoverageResult> {
&program,
source_text,
&span_converter,
ESTreeTokenOptions::test262(),
ESTreeTokenOptions::new(false),
);
span_converter.convert_program_with_ascending_order_checks(&mut program);
@@ -904,7 +904,7 @@ pub fn run_estree_acorn_jsx_tokens(files: &[AcornJsxFile]) -> Vec<CoverageResult
&program,
source_text,
&span_converter,
ESTreeTokenOptions::test262(),
ESTreeTokenOptions::new(false),
);
span_converter.convert_program_with_ascending_order_checks(&mut program);
@@ -1080,7 +1080,7 @@ pub fn run_estree_typescript_tokens(files: &[TypeScriptFile]) -> Vec<CoverageRes
&program,
source_text,
&span_converter,
ESTreeTokenOptions::typescript(),
ESTreeTokenOptions::new(true),
);
span_converter.convert_program_with_ascending_order_checks(&mut program);