fix(parser): reject module syntax in script (#27220)

Fixes #25187, closes #25204.

Previously, this check was performed by semantic phase, but since it can be detected in advance, it has been moved to the parser as per our previous policy.

[Babel](https://ast-explorer.dev/#eNotjDEOwjAMRa8See4JunIFNsLgRgaC0thKXASKcnfcNtt/78lukGCGN36whhJFYQIxseBCyXawTV/hoq51YzZuPjvnofJWAl1/Qh7mnY97D9OZJW3PmOvebvfhEgfUyKd9YKo0AqpieF14XSnbixF97tD/NgY0iA==) also reports this in the parser.

Conformance test harness also updated:

- codegen: `if() { import }` is now invalid input
  - => use `declare module {}` for indent instead
- transformer: Revert #26495 change
  - => defaults to `unambiguous` (Correct me if I'm wrong 🙏🏻)
This commit is contained in:
leaysgur committed 2026-09-30 07:49:35 +00:00
1 parent 66545bfa3c
commit b2793fa2d8
17 files changed
+106 -91

No files matched your search

@@ -185,17 +185,17 @@ fn indented_statement_mappings_start_after_generated_indent() {
// gen col 0 (whitespace) instead of the start of the keyword.
#[test]
fn top_level_decl_mappings_start_after_generated_indent() {
// Wrap the imports/exports in `if (true) { ... }` so the body is
// Wrap the imports/exports in an ambient module so the body is
// indented, exposing the order of `add_source_mapping` vs `print_indent`.
let tokens = sourcemap_tokens(
r#"if (true) {
r#"declare module "m" {
"use strict";
import { x } from "x";
export { x } from "x";
export * from "x";
export default 1;
}"#,
SourceType::mjs(),
SourceType::ts(),
);
// Directive `"use strict"` source col 0 of line 1 → gen col 1 (after tab),
+4 -1
View File
@@ -174,6 +174,9 @@ impl Context {
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum StatementContext {
/// Direct child of `Program`.
/// Unlike `Context::TopLevel`, this excludes blocks and other nested statements.
Program,
StatementList,
If,
Label,
@@ -185,6 +188,6 @@ pub enum StatementContext {
impl StatementContext {
pub(crate) fn is_single_statement(self) -> bool {
self != Self::StatementList
!matches!(self, Self::Program | Self::StatementList)
}
}
+11
View File
@@ -885,6 +885,17 @@ parser_diagnostics! {
OxcDiagnostic::error("A rest parameter cannot have an initializer.").with_label(span)
};
top_level(statement: &'static str, span: Span) => {
OxcDiagnostic::error(format!(
"'{statement}' declaration can only be used at the top level of a module"
))
.with_label(span)
};
module_code(statement: &'static str, span: Span) => {
OxcDiagnostic::error(format!("Cannot use {statement} outside a module")).with_label(span)
};
import_requires_a_specifier(span: Span) => {
OxcDiagnostic::error("import() requires a specifier.").with_label(span)
};
+19 -1
View File
@@ -5,7 +5,7 @@ use rustc_hash::FxHashMap;
use super::FunctionKind;
use crate::{
ParserConfig as Config, ParserImpl, diagnostics,
ParserConfig as Config, ParserImpl, StatementContext, diagnostics,
lexer::Kind,
modifiers::{Modifier, ModifierKind, Modifiers},
};
@@ -452,12 +452,30 @@ impl<'a, C: Config> ParserImpl<'a, C> {
TSNamespaceExportDeclaration::boxed(self.end_span(start), id, self)
}
/// Check the parse goal and placement of the `import` / `export` declaration at the current token.
pub(crate) fn check_module_declaration(&mut self, stmt_ctx: StatementContext) {
// TypeScript cannot tell script from module by syntax,
// and permits module declarations in namespaces and ambient modules.
if self.is_ts {
return;
}
let statement = if self.at(Kind::Import) { "import statement" } else { "export statement" };
let span = self.cur_token().span();
if self.source_type.is_script() || self.source_type.is_commonjs() {
self.error(diagnostics::module_code(statement, span));
} else if stmt_ctx != StatementContext::Program {
self.error(diagnostics::top_level(statement, span));
}
}
/// [Exports](https://tc39.es/ecma262/#sec-exports)
pub(crate) fn parse_export_declaration(
&mut self,
start: u32,
mut decorators: ArenaVec<'a, Decorator<'a>>,
stmt_ctx: StatementContext,
) -> Statement<'a> {
self.check_module_declaration(stmt_ctx);
self.bump_any(); // bump `export`
// `export` is unambiguously module syntax (ECMA-262 §16.2.3): commit to the
// Module goal so the declaration parses under `Await` on the first pass and
+9 -5
View File
@@ -38,7 +38,8 @@ impl<'a, C: Config> ParserImpl<'a, C> {
let mut statements = ArenaVec::new_in(self);
let is_top_level = self.ctx.has_top_level();
let stmt_ctx = StatementContext::StatementList;
let stmt_ctx =
if is_top_level { StatementContext::Program } else { StatementContext::StatementList };
let is_ambient_block = (is_top_level || in_ts_namespace_body) && self.ctx.has_ambient();
let mut reported_ambient_statement = false;
@@ -155,7 +156,9 @@ impl<'a, C: Config> ParserImpl<'a, C> {
&Modifiers::empty(),
ArenaVec::new_in(self),
),
Kind::Export => self.parse_export_declaration(self.cur_start(), ArenaVec::new_in(self)),
Kind::Export => {
self.parse_export_declaration(self.cur_start(), ArenaVec::new_in(self), stmt_ctx)
}
// [+Return] ReturnStatement[?Yield, ?Await]
Kind::Return => self.parse_return_statement(),
Kind::Var => {
@@ -170,7 +173,7 @@ impl<'a, C: Config> ParserImpl<'a, C> {
Kind::At => self.parse_decorated_statement(stmt_ctx),
Kind::Let if !self.cur_token().escaped() => self.parse_let(stmt_ctx),
Kind::Async => self.parse_async_statement(self.cur_start(), stmt_ctx),
Kind::Import => self.parse_import_statement(),
Kind::Import => self.parse_import_statement(stmt_ctx),
Kind::Const => self.parse_const_statement(stmt_ctx),
Kind::Using if self.is_using_declaration() => self.parse_using_statement(stmt_ctx),
Kind::Await if self.is_using_statement() => self.parse_using_statement(stmt_ctx),
@@ -889,12 +892,13 @@ impl<'a, C: Config> ParserImpl<'a, C> {
}
/// Parse import statement or import expression.
fn parse_import_statement(&mut self) -> Statement<'a> {
fn parse_import_statement(&mut self, stmt_ctx: StatementContext) -> Statement<'a> {
let start = self.cur_start();
if matches!(self.lexer.peek_token().kind(), Kind::Dot | Kind::LParen) {
// Parse the whole expression `import.meta.url`.
self.parse_expression_or_labeled_statement()
} else {
self.check_module_declaration(stmt_ctx);
self.bump_any(); // bump `import`
self.parse_import_declaration(start, self.ctx.has_top_level())
}
@@ -920,7 +924,7 @@ impl<'a, C: Config> ParserImpl<'a, C> {
let kind = self.cur_kind();
if kind == Kind::Export {
// Export span.start starts after decorators.
return self.parse_export_declaration(self.cur_start(), decorators);
return self.parse_export_declaration(self.cur_start(), decorators, stmt_ctx);
}
let modifiers = self.parse_modifiers(false, false);
if self.at(Kind::Class) {
+1 -1
View File
@@ -886,7 +886,7 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
// Parse the statement with await context enabled (TopLevel context is already set)
let stmt = self.context_add(Context::Await, |p| {
p.parse_statement_list_item(StatementContext::StatementList)
p.parse_statement_list_item(StatementContext::Program)
});
// Replace the statement if the index is valid
+2 -40
View File
@@ -2,9 +2,9 @@ use memchr::memchr_iter;
use rustc_hash::{FxBuildHasher, FxHashMap};
use oxc_allocator::GetAddress;
use oxc_ast::{AstKind, ModuleDeclarationKind, ast::*};
use oxc_ast::{AstKind, ast::*};
use oxc_ecmascript::{BoundNames, IsSimpleParameterList, PropName};
use oxc_span::{GetSpan, ModuleKind, Span, best_match};
use oxc_span::{Span, best_match};
use oxc_str::Ident;
use oxc_syntax::{
class::{ClassId, ElementKind},
@@ -522,44 +522,6 @@ pub fn check_directive(directive: &Directive, ctx: &SemanticBuilder<'_>) {
}
}
pub fn check_module_declaration(decl: &ModuleDeclarationKind, ctx: &SemanticBuilder<'_>) {
// It is ambiguous between script and module for `TypeScript`, skipping this check for now.
// Basically we need to "upgrade" from script to module if we see any module syntax inside the
// semantic builder
if ctx.source_type.is_typescript() {
return;
}
let text = match decl {
ModuleDeclarationKind::Import(_) => "import statement",
ModuleDeclarationKind::ExportAll(_)
| ModuleDeclarationKind::Export(_)
| ModuleDeclarationKind::ExportDefault(_)
| ModuleDeclarationKind::ExportNamed(_)
| ModuleDeclarationKind::ExportFrom(_)
| ModuleDeclarationKind::TSExportAssignment(_)
| ModuleDeclarationKind::TSNamespaceExport(_) => "export statement",
};
let start = decl.span().start;
let span = Span::sized(start, 6);
match ctx.source_type.module_kind() {
ModuleKind::Unambiguous => {
#[cfg(debug_assertions)]
panic!("Technically unreachable, omit to avoid panic.");
}
// CommonJS uses require/module.exports, not import/export statements
ModuleKind::Script | ModuleKind::CommonJS => {
ctx.error(diagnostics::module_code(text, span));
}
ModuleKind::Module => {
if matches!(ctx.ancestry().parent_kind(), AstKind::Program(_)) {
return;
}
ctx.error(diagnostics::top_level(text, span));
}
}
}
/// Check that `using` declarations are not at the top level in script mode.
/// `using` is allowed:
/// - At the top level of ES modules
+1 -5
View File
@@ -1,4 +1,4 @@
use oxc_ast::{AstKind, ast::*, match_module_declaration};
use oxc_ast::{AstKind, ast::*};
use crate::builder::SemanticBuilder;
@@ -37,10 +37,6 @@ pub fn check<'a>(kind: AstKind<'a>, ctx: &SemanticBuilder<'a>) {
AstKind::StringLiteral(lit) => js::check_string_literal(lit, ctx),
AstKind::Directive(dir) => js::check_directive(dir, ctx),
match_module_declaration!(AstKind) => {
let mod_decl_kind = kind.as_module_declaration_kind().unwrap();
js::check_module_declaration(&mod_decl_kind, ctx);
}
AstKind::WithStatement(stmt) => {
js::check_function_declaration(&stmt.body, false, ctx);
-13
View File
@@ -174,19 +174,6 @@ pub fn illegal_use_strict(span: Span) -> OxcDiagnostic {
)
}
#[cold]
pub fn top_level(x0: &str, span1: Span) -> OxcDiagnostic {
OxcDiagnostic::error(format!(
"'{x0}' declaration can only be used at the top level of a module"
))
.with_label(span1)
}
#[cold]
pub fn module_code(x0: &str, span1: Span) -> OxcDiagnostic {
OxcDiagnostic::error(format!("Cannot use {x0} outside a module")).with_label(span1)
}
#[cold]
pub fn using_declaration_not_allowed_in_script(span: Span) -> OxcDiagnostic {
OxcDiagnostic::error("'using' declarations are not allowed at the top level of a script")
+6
View File
@@ -47,6 +47,12 @@ describe("parse", () => {
expect(ret.errors.length).toBe(1);
});
it("rejects module syntax in script without semantic checks", () => {
const ret = parseSync("test.js", 'import "foo";\nexport {};', { sourceType: "script" });
// Reported at `import` and `export` keywords
expect(ret.errors.map((error) => error.labels[0].start)).toEqual([0, 14]);
});
describe("sets lang and sourceType", () => {
const langs: ParserOptions["lang"][] = ["js", "ts", "jsx", "tsx"];
@@ -0,0 +1,3 @@
{
@dec export class C {}
}
+2 -2
View File
@@ -1,3 +1,3 @@
lexer_misc Summary:
AST Parsed : 347/347 (100.00%)
Positive Passed: 347/347 (100.00%)
AST Parsed : 348/348 (100.00%)
Positive Passed: 348/348 (100.00%)
+6 -6
View File
@@ -5474,18 +5474,18 @@ Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/typesc
· ──────────
╰────
× Keywords cannot contain escape characters
╭─[babel/packages/babel-parser/test/fixtures/es2017/async-functions/invalid-escape-export-async-function/input.js:1:8]
1 │ export \u0061sync function y() { await x }
· ──────────
╰────
× Cannot use export statement outside a module
╭─[babel/packages/babel-parser/test/fixtures/es2017/async-functions/invalid-escape-export-async-function/input.js:1:1]
1 │ export \u0061sync function y() { await x }
· ──────
╰────
× Keywords cannot contain escape characters
╭─[babel/packages/babel-parser/test/fixtures/es2017/async-functions/invalid-escape-export-async-function/input.js:1:8]
1 │ export \u0061sync function y() { await x }
· ──────────
╰────
× Keywords cannot contain escape characters
╭─[babel/packages/babel-parser/test/fixtures/es2017/async-functions/invalid-escape-export-dflt-async-function/input.js:1:16]
1 │ export default \u0061sync function y() { await x }
+9 -1
View File
@@ -1,7 +1,7 @@
parser_misc Summary:
AST Parsed : 111/111 (100.00%)
Positive Passed: 111/111 (100.00%)
Negative Passed: 238/238 (100.00%)
Negative Passed: 239/239 (100.00%)
× TS(1275): 'accessor' modifier can only appear on a property declaration.
╭─[misc/fail/accessor-modifier-abstract-method.ts:1:29]
@@ -470,6 +470,14 @@ Negative Passed: 238/238 (100.00%)
3 │ }
╰────
× 'export statement' declaration can only be used at the top level of a module
╭─[misc/fail/decorated-export-in-block.mjs:2:8]
1 │ {
2 │ @dec export class C {}
· ──────
3 │ }
╰────
× Encountered diff marker
╭─[misc/fail/diff-markers.js:10:1]
9 │ function test() {
+29 -8
View File
@@ -22379,6 +22379,13 @@ Negative Passed: 4653/4653 (100.00%)
· ─────────
╰────
× Cannot use import statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-default-and-defer-namespace.js:34:1]
33 │
34 │ import defer x, * as ns from "./dep_FIXTURE.js";
· ──────
╰────
× TS(18058): Default imports are not allowed in a deferred import.
╭─[test262/test/language/import/import-defer/syntax/invalid-default-and-defer-namespace.js:34:8]
33 │
@@ -22387,9 +22394,9 @@ Negative Passed: 4653/4653 (100.00%)
╰────
× Cannot use import statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-default-and-defer-namespace.js:34:1]
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-as-with-no-asterisk.js:34:1]
33 │
34 │ import defer x, * as ns from "./dep_FIXTURE.js";
34 │ import defer as ns from "./dep_FIXTURE.js";
· ──────
╰────
@@ -22401,6 +22408,13 @@ Negative Passed: 4653/4653 (100.00%)
· ╰── `from` expected
╰────
× Cannot use import statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-default-and-namespace.js:34:1]
33 │
34 │ import x, defer * as ns from "./dep_FIXTURE.js";
· ──────
╰────
× Unexpected token
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-default-and-namespace.js:34:11]
33 │
@@ -22408,6 +22422,13 @@ Negative Passed: 4653/4653 (100.00%)
· ─────
╰────
× Cannot use import statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-default.js:34:1]
33 │
34 │ import defer x from "./dep_FIXTURE.js";
· ──────
╰────
× TS(18058): Default imports are not allowed in a deferred import.
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-default.js:34:8]
33 │
@@ -22416,9 +22437,9 @@ Negative Passed: 4653/4653 (100.00%)
╰────
× Cannot use import statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-default.js:34:1]
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-named.js:34:1]
33 │
34 │ import defer x from "./dep_FIXTURE.js";
34 │ import defer { default as x } from "./dep_FIXTURE.js";
· ──────
╰────
@@ -22429,10 +22450,10 @@ Negative Passed: 4653/4653 (100.00%)
· ─────
╰────
× Cannot use import statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-defer-named.js:34:1]
33 │
34 │ import defer { default as x } from "./dep_FIXTURE.js";
× Cannot use export statement outside a module
╭─[test262/test/language/import/import-defer/syntax/invalid-export-defer-namespace.js:33:1]
32 │
33 │ export defer * as ns from "./dep_FIXTURE.js";
· ──────
╰────
@@ -8410,7 +8410,7 @@ Expect to Parse: tasks/coverage/typescript/tsc/testdata/tests/cases/conformance/
3 │ }
╰────
× Cannot use import statement outside a module
× 'import statement' declaration can only be used at the top level of a module
╭─[typescript/tsc/testdata/tests/cases/compiler/importDeclarationInModuleDeclaration2.ts:2:5]
1 │ function container() {
2 │ import "fs";
@@ -90,10 +90,6 @@ impl TestCase {
// Some babel test cases have a js extension, but contain typescript code.
// Therefore, if the typescript plugin exists, enable typescript.
let mut source_type = SourceType::from_path(path).unwrap().with_jsx(options.is_jsx());
// Preserve explicit module extensions; other fixtures default to scripts.
if !source_type.is_module() {
source_type = source_type.with_script(true);
}
source_type = match options.source_type.as_deref() {
Some("unambiguous") => source_type.with_unambiguous(true),
Some("script") => source_type.with_script(true),