From 77b6f7e92eafedac7c7fb45a23cc76d49652eeab Mon Sep 17 00:00:00 2001 From: overlookmotel <557937+overlookmotel@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:47:42 +0000 Subject: [PATCH] fix(ast/estree): fix start span of `Program` in TS-ESTree AST where first statement is `@dec export class C {}` (#10448) #10438 aligned the spans of `@dec export class C {}` and `@dec export default class {}` with TS-ESLint. ```ts @dec export class C {} ^^^^^^^^^^^^^^^^^ ExportNamedDeclaration ^^^^^^^^^^ Class ^^^^ Decorator @dec export default class {} ^^^^^^^^^^^^^^^^^^^^^^^ ExportDefaultDeclaration ^^^^^^^^ Class ^^^^ Decorator ``` However, this causes a problem where one of these is the first statement in the file. In TS-ESTree, `Program` start is the start of the first token (excluding whitespace and comments). So we need to set `Program` start to the start of the decorator in these cases. --- crates/oxc_ast/src/serialize.rs | 84 +++++++++++++++---- napi/parser/deserialize-js.js | 7 +- napi/parser/deserialize-ts.js | 24 +++++- .../coverage/snapshots/estree_typescript.snap | 3 +- 4 files changed, 96 insertions(+), 22 deletions(-) diff --git a/crates/oxc_ast/src/serialize.rs b/crates/oxc_ast/src/serialize.rs index af9990d4159f0..ff07679ece77d 100644 --- a/crates/oxc_ast/src/serialize.rs +++ b/crates/oxc_ast/src/serialize.rs @@ -1,3 +1,5 @@ +use std::cmp; + use cow_utils::CowUtils; use crate::ast::*; @@ -80,18 +82,47 @@ impl Program<'_> { /// This is required because unlike Acorn, TS-ESLint excludes whitespace and comments /// from the `Program` start span. /// See for more info. +/// +/// Special case where first statement is an `ExportNamedDeclaration` or `ExportDefaultDeclaration` +/// exporting a class with decorators, where one of the decorators is before `export`. +/// In these cases, the span of the statement starts after the span of the decorators. +/// e.g. `@dec export class C {}` - `ExportNamedDeclaration` span start is 5, `Decorator` span start is 0. +/// `Program` span start is 0 (not 5). #[ast_meta] #[estree(raw_deser = " const body = DESER[Vec](POS_OFFSET.directives); body.push(...DESER[Vec](POS_OFFSET.body)); - let start = DESER[u32](POS_OFFSET.span.start); + + /* IF_JS */ + const start = DESER[u32](POS_OFFSET.span.start); + /* END_IF_JS */ + + const end = DESER[u32](POS_OFFSET.span.end); + /* IF_TS */ - if (body.length > 0) start = body[0].start; + let start; + if (body.length > 0) { + const first = body[0]; + start = first.start; + if (first.type === 'ExportNamedDeclaration' || first.type === 'ExportDefaultDeclaration') { + const {declaration} = first; + if ( + declaration !== null && declaration.type === 'ClassDeclaration' + && declaration.decorators.length > 0 + ) { + const decoratorStart = declaration.decorators[0].start; + if (decoratorStart < start) start = decoratorStart; + } + } + } else { + start = end; + } /* END_IF_TS */ + const program = { type: 'Program', start, - end: DESER[u32](POS_OFFSET.span.end), + end, body, sourceType: DESER[ModuleKind](POS_OFFSET.source_type.module_kind), hashbang: DESER[Option](POS_OFFSET.hashbang), @@ -103,18 +134,8 @@ pub struct ProgramConverter<'a, 'b>(pub &'b Program<'a>); impl ESTree for ProgramConverter<'_, '_> { fn serialize(&self, serializer: S) { let program = self.0; - let span_start = if S::INCLUDE_TS_FIELDS { - if let Some(first_directive) = program.directives.first() { - first_directive.span.start - } else if let Some(first_stmt) = program.body.first() { - first_stmt.span().start - } else { - // If program contains no statements or directives, span start = span end - program.span.end - } - } else { - program.span.start - }; + let span_start = + if S::INCLUDE_TS_FIELDS { get_ts_start_span(program) } else { program.span.start }; let mut state = serializer.serialize_struct(); state.serialize_field("type", &JsonSafeString("Program")); @@ -130,6 +151,39 @@ impl ESTree for ProgramConverter<'_, '_> { } } +fn get_ts_start_span(program: &Program<'_>) -> u32 { + if let Some(first_directive) = program.directives.first() { + return first_directive.span.start; + } + + let Some(first_stmt) = program.body.first() else { + // Program contains no statements or directives. Span start = span end. + return program.span.end; + }; + + match first_stmt { + Statement::ExportNamedDeclaration(decl) => { + let start = decl.span.start; + if let Some(Declaration::ClassDeclaration(class)) = &decl.declaration { + if let Some(decorator) = class.decorators.first() { + return cmp::min(start, decorator.span.start); + } + } + start + } + Statement::ExportDefaultDeclaration(decl) => { + let start = decl.span.start; + if let ExportDefaultDeclarationKind::ClassDeclaration(class) = &decl.declaration { + if let Some(decorator) = class.decorators.first() { + return cmp::min(start, decorator.span.start); + } + } + start + } + _ => first_stmt.span().start, + } +} + // -------------------- // Basic types // -------------------- diff --git a/napi/parser/deserialize-js.js b/napi/parser/deserialize-js.js index 644f9abde9398..52aaabf15a530 100644 --- a/napi/parser/deserialize-js.js +++ b/napi/parser/deserialize-js.js @@ -37,11 +37,14 @@ function deserialize(buffer, sourceTextInput, sourceLenInput) { function deserializeProgram(pos) { const body = deserializeVecDirective(pos + 88); body.push(...deserializeVecStatement(pos + 120)); - let start = deserializeU32(pos); + + const start = deserializeU32(pos); + const end = deserializeU32(pos + 4); + const program = { type: 'Program', start, - end: deserializeU32(pos + 4), + end, body, sourceType: deserializeModuleKind(pos + 9), hashbang: deserializeOptionHashbang(pos + 64), diff --git a/napi/parser/deserialize-ts.js b/napi/parser/deserialize-ts.js index 3e19a12520832..cefaeb100d8c6 100644 --- a/napi/parser/deserialize-ts.js +++ b/napi/parser/deserialize-ts.js @@ -37,12 +37,30 @@ function deserialize(buffer, sourceTextInput, sourceLenInput) { function deserializeProgram(pos) { const body = deserializeVecDirective(pos + 88); body.push(...deserializeVecStatement(pos + 120)); - let start = deserializeU32(pos); - if (body.length > 0) start = body[0].start; + + const end = deserializeU32(pos + 4); + + let start; + if (body.length > 0) { + const first = body[0]; + start = first.start; + if (first.type === 'ExportNamedDeclaration' || first.type === 'ExportDefaultDeclaration') { + const { declaration } = first; + if ( + declaration !== null && declaration.type === 'ClassDeclaration' && + declaration.decorators.length > 0 + ) { + const decoratorStart = declaration.decorators[0].start; + if (decoratorStart < start) start = decoratorStart; + } + } + } else { + start = end; + } const program = { type: 'Program', start, - end: deserializeU32(pos + 4), + end, body, sourceType: deserializeModuleKind(pos + 9), hashbang: deserializeOptionHashbang(pos + 64), diff --git a/tasks/coverage/snapshots/estree_typescript.snap b/tasks/coverage/snapshots/estree_typescript.snap index b508dc48a5f1c..7707006a9ba1d 100644 --- a/tasks/coverage/snapshots/estree_typescript.snap +++ b/tasks/coverage/snapshots/estree_typescript.snap @@ -2,7 +2,7 @@ commit: 15392346 estree_typescript Summary: AST Parsed : 10619/10725 (99.01%) -Positive Passed: 9012/10725 (84.03%) +Positive Passed: 9013/10725 (84.04%) Expect to Parse: tasks/coverage/typescript/tests/cases/compiler/ClassDeclarationWithInvalidConstOnPropertyDeclaration.ts A class member cannot have the 'const' keyword. Mismatch: tasks/coverage/typescript/tests/cases/compiler/accessOverriddenBaseClassMember1.ts @@ -1643,7 +1643,6 @@ Expect to Parse: tasks/coverage/typescript/tests/cases/conformance/es7/trailingC Unexpected trailing comma after rest element Expect to Parse: tasks/coverage/typescript/tests/cases/conformance/es7/trailingCommasInFunctionParametersAndArguments.ts A rest parameter must be last in a parameter list -Mismatch: tasks/coverage/typescript/tests/cases/conformance/esDecorators/classDeclaration/esDecorators-classDeclaration-commentPreservation.ts Mismatch: tasks/coverage/typescript/tests/cases/conformance/esDecorators/classDeclaration/esDecorators-classDeclaration-parameterProperties.ts Mismatch: tasks/coverage/typescript/tests/cases/conformance/esDecorators/classExpression/esDecorators-classExpression-missingEmitHelpers-classDecorator.17.ts Mismatch: tasks/coverage/typescript/tests/cases/conformance/esDecorators/classExpression/esDecorators-classExpression-missingEmitHelpers-classDecorator.5.ts