Files
moli/moli-module-syntax/src/lib.rs
2026-08-11 00:10:12 +08:00

712 lines
28 KiB
Rust

//! Syntax scanning helpers for legacy module analysis and child script binding discovery.
//!
//! The window/document ESM runtime no longer lowers modules into classic-script
//! shims. Some AST inventory helpers remain while worker/native ESM migration
//! and child script declared-name discovery are being separated.
mod ast_inventory;
mod dynamic_import;
mod exports;
mod imports;
mod types;
pub use types::{
DynamicImportRewriteKind, DynamicImportRewriteSite, ImportMetaResolveRewriteSite,
ModuleAstLowering, ModuleAstSourceFragment, ModuleAstSpan, ModuleAstStatement,
ModuleExportBinding, ModuleNamedBinding, ModulePredeclaredExportSurface,
ModuleSpecialFormRewriteSites, ParsedExportConst, ParsedExportDefaultDeclaration,
ParsedExportVariable, ParsedExportVariableBinding, ParsedExportedClass, ParsedExportedFunction,
ParsedModuleExportAll, ParsedModuleExportList, ParsedModuleStaticImport,
};
pub use ast_inventory::lower_module_source_with_ast_lowering;
pub use exports::{
parse_script_top_level_assignment_declared_names,
parse_script_top_level_lexical_declared_names, parse_script_var_and_function_declared_names,
};
#[cfg(test)]
mod tests {
use crate::ast_inventory::{
lower_module_source_with_ast, lower_module_source_with_ast_lowering,
};
use crate::dynamic_import::collect_module_special_form_rewrite_sites;
use crate::exports::collect_predeclared_export_surface;
use crate::{
DynamicImportRewriteKind, ModuleAstStatement,
parse_script_top_level_assignment_declared_names,
parse_script_top_level_lexical_declared_names,
parse_script_var_and_function_declared_names,
};
fn lower_one_module_statement(source: &str) -> ModuleAstStatement {
let mut statements =
lower_module_source_with_ast(source).expect("module source should lower");
assert_eq!(statements.len(), 1);
statements.remove(0)
}
fn lower_default_expression(source: &str) -> String {
let ModuleAstStatement::ExportDefaultExpr(expression) = lower_one_module_statement(source)
else {
panic!("source should lower as a default expression");
};
expression.source
}
fn lower_default_declaration(source: &str) -> crate::ParsedExportDefaultDeclaration {
let ModuleAstStatement::ExportDefaultDeclaration(declaration) =
lower_one_module_statement(source)
else {
panic!("source should lower as a default declaration");
};
declaration
}
#[test]
fn parses_side_effect_only_static_import() {
let ModuleAstStatement::StaticImport(import) =
lower_one_module_statement("import './mod.js';")
else {
panic!("source should lower as a static import");
};
assert_eq!(import.specifier, "./mod.js");
assert!(import.default_binding.is_none());
assert!(import.namespace_binding.is_none());
assert!(import.named_bindings.is_empty());
let ModuleAstStatement::StaticImport(compact_import) =
lower_one_module_statement("import{named as local}from './compact.js';")
else {
panic!("compact source should lower as a static import");
};
assert_eq!(compact_import.specifier, "./compact.js");
assert_eq!(compact_import.named_bindings.len(), 1);
assert_eq!(compact_import.named_bindings[0].imported_name, "named");
assert_eq!(compact_import.named_bindings[0].local_name, "local");
}
#[test]
fn parses_static_module_headers_with_empty_import_attributes() {
let ModuleAstStatement::StaticImport(import) =
lower_one_module_statement("import value, { named } from './mod.js' with {};")
else {
panic!("source should lower as a static import");
};
assert_eq!(import.specifier, "./mod.js");
assert_eq!(import.import_type, None);
assert_eq!(import.default_binding.as_deref(), Some("value"));
assert_eq!(import.named_bindings.len(), 1);
assert_eq!(import.named_bindings[0].imported_name, "named");
assert_eq!(import.named_bindings[0].local_name, "named");
let ModuleAstStatement::ExportList(export_list) =
lower_one_module_statement("export { named as renamed } from './mod.js' with {};")
else {
panic!("source should lower as an export list");
};
assert_eq!(export_list.specifier.as_deref(), Some("./mod.js"));
assert_eq!(export_list.import_type, None);
assert_eq!(export_list.bindings.len(), 1);
assert_eq!(export_list.bindings[0].local_name, "named");
assert_eq!(export_list.bindings[0].export_name, "renamed");
let ModuleAstStatement::ExportList(compact_export_list) =
lower_one_module_statement("export{named as renamed};")
else {
panic!("compact source should lower as an export list");
};
assert_eq!(compact_export_list.bindings.len(), 1);
assert_eq!(compact_export_list.bindings[0].local_name, "named");
assert_eq!(compact_export_list.bindings[0].export_name, "renamed");
let ModuleAstStatement::ExportAll(export_all) =
lower_one_module_statement("export * as ns from './mod.js' with {};")
else {
panic!("source should lower as an export-all");
};
assert_eq!(export_all.specifier, "./mod.js");
assert_eq!(export_all.import_type, None);
assert_eq!(export_all.namespace_export_name.as_deref(), Some("ns"));
}
#[test]
fn parses_static_module_headers_with_json_import_attributes() {
let ModuleAstStatement::StaticImport(import) =
lower_one_module_statement("import config from './config.json' with { type: 'json' };")
else {
panic!("source should lower as a static import");
};
assert_eq!(import.specifier, "./config.json");
assert_eq!(import.import_type.as_deref(), Some("json"));
assert_eq!(import.default_binding.as_deref(), Some("config"));
let ModuleAstStatement::StaticImport(legacy) = lower_one_module_statement(
"import config from './config.json' assert { \"type\": \"json\" };",
) else {
panic!("source should lower as a static import");
};
assert_eq!(legacy.specifier, "./config.json");
assert_eq!(legacy.import_type.as_deref(), Some("json"));
}
#[test]
fn parses_static_module_headers_with_text_import_attributes() {
let ModuleAstStatement::StaticImport(import) =
lower_one_module_statement("import content from './file.txt' with { type: 'text' };")
else {
panic!("source should lower as a static import");
};
assert_eq!(import.specifier, "./file.txt");
assert_eq!(import.import_type.as_deref(), Some("text"));
assert_eq!(import.default_binding.as_deref(), Some("content"));
}
#[test]
fn rejects_unknown_static_import_export_attributes() {
for source in [
"import config from './config.json' with { integrity: 'sha256-test' };",
"export { config } from './config.json' with { integrity: 'sha256-test' };",
"export * from './config.json' with { integrity: 'sha256-test' };",
] {
let error = lower_module_source_with_ast_lowering(source)
.expect_err("unsupported static import/export attributes should fail lowering");
assert!(
error.contains("unsupported import attribute"),
"unexpected error for {source}: {error}"
);
}
}
#[test]
fn exported_variable_local_source_preserves_statement_semicolon() {
let ModuleAstStatement::ExportConst {
local_source: const_source,
..
} = lower_one_module_statement("export const value = 1;")
else {
panic!("source should lower as exported const");
};
assert_eq!(const_source.source, "const value = 1;");
let ModuleAstStatement::ExportVariable {
local_source: variable_source,
..
} = lower_one_module_statement("export let value = 1;")
else {
panic!("source should lower as exported variable");
};
assert_eq!(variable_source.source, "let value = 1;");
}
#[test]
fn lowering_drops_hashbang_trivia_before_first_statement() {
let lowering = lower_module_source_with_ast_lowering(
"#!/usr/bin/env node\nconst value = import.meta.url;",
)
.expect("hashbang module source should lower");
assert_eq!(lowering.statements.len(), 1);
let ModuleAstStatement::Body(body) = &lowering.statements[0] else {
panic!("hashbang should not become a body statement");
};
assert_eq!(body.source, "const value = import.meta.url;");
assert_eq!(body.special_forms.import_meta_urls.len(), 1);
let lowering =
lower_module_source_with_ast_lowering("#!/usr/bin/env node\nimport './dep.js';")
.expect("hashbang before import should lower");
assert_eq!(lowering.statements.len(), 1);
assert!(matches!(
lowering.statements[0],
ModuleAstStatement::StaticImport(_)
));
}
#[test]
fn lowering_flags_top_level_await_from_oxc_ast() {
let lowering = lower_module_source_with_ast_lowering(
[
"const literal = 'await';",
"const nested = async () => await load();",
"function nestedFunction() { return import('./inside-function.js'); }",
"const templ = `prefix:${await load()}`;",
"for await (const value of values) { consume(value); }",
"export const value = await load();",
"export default await load();",
]
.join("\n")
.as_str(),
)
.expect("module source should lower");
let ModuleAstStatement::Body(literal) = &lowering.statements[0] else {
panic!("first statement should lower as body");
};
assert!(!literal.contains_top_level_await);
let ModuleAstStatement::Body(nested) = &lowering.statements[1] else {
panic!("second statement should lower as body");
};
assert!(!nested.contains_top_level_await);
let ModuleAstStatement::Body(nested_function) = &lowering.statements[2] else {
panic!("third statement should lower as body");
};
assert!(!nested_function.contains_top_level_await);
assert_eq!(nested_function.special_forms.dynamic_imports.len(), 1);
let ModuleAstStatement::Body(template) = &lowering.statements[3] else {
panic!("fourth statement should lower as body");
};
assert!(template.contains_top_level_await);
let ModuleAstStatement::Body(for_await) = &lowering.statements[4] else {
panic!("fifth statement should lower as body");
};
assert!(for_await.contains_top_level_await);
let ModuleAstStatement::ExportConst { local_source, .. } = &lowering.statements[5] else {
panic!("sixth statement should lower as exported const");
};
assert!(local_source.contains_top_level_await);
let ModuleAstStatement::ExportDefaultExpr(expression) = &lowering.statements[6] else {
panic!("seventh statement should lower as default expression");
};
assert!(expression.contains_top_level_await);
}
#[test]
fn collects_special_form_rewrite_sites_with_oxc_ast() {
let source = [
"const literal = 'import.meta.url import(\"./ignored.js\")';",
"const templ = `url:${import.meta.url}`;",
"const resolved = import.meta.resolve(`./nested/` + 'dep' + `.js`);",
"const promise = import('./lazy.js', { with: { type: 'json' } });",
"const css = import('./styles.css', { with: { type: 'css' } });",
"const legacy = import('./legacy.js', { assert: { \"type\": \"json\" } });",
"const escaped = import('./caf\\u{e9}.js');",
"const namespace = await /* keep */ import('./awaited.js');",
"const resolvedImport = await import(import.meta.resolve('./mapped.js'));",
]
.join("\n");
let sites = collect_module_special_form_rewrite_sites(&source)
.expect("special forms should collect from AST");
assert_eq!(sites.import_meta_urls.len(), 1);
assert_eq!(
&source
[sites.import_meta_urls[0].start as usize..sites.import_meta_urls[0].end as usize],
"import.meta.url"
);
assert_eq!(sites.import_metas.len(), 1);
assert_eq!(
&source[sites.import_metas[0].start as usize..sites.import_metas[0].end as usize],
"import.meta"
);
assert_eq!(sites.dynamic_imports.len(), 6);
assert_eq!(sites.dynamic_imports[0].specifier, "./lazy.js");
assert!(!sites.dynamic_imports[0].resolve_import_meta_first);
assert_eq!(
sites.dynamic_imports[0].import_type.as_deref(),
Some("json")
);
assert_eq!(
sites.dynamic_imports[0].kind,
DynamicImportRewriteKind::Promise
);
assert_eq!(sites.dynamic_imports[1].specifier, "./styles.css");
assert_eq!(sites.dynamic_imports[1].import_type.as_deref(), Some("css"));
assert_eq!(sites.dynamic_imports[2].specifier, "./legacy.js");
assert_eq!(
sites.dynamic_imports[2].import_type.as_deref(),
Some("json")
);
assert_eq!(sites.dynamic_imports[3].specifier, "./café.js");
assert_eq!(sites.dynamic_imports[4].specifier, "./awaited.js");
assert_eq!(
sites.dynamic_imports[4].kind,
DynamicImportRewriteKind::AwaitedNamespace
);
assert_eq!(
&source[sites.dynamic_imports[4].replace_start..sites.dynamic_imports[4].replace_end],
"await /* keep */ import('./awaited.js')"
);
assert_eq!(sites.dynamic_imports[5].specifier, "./mapped.js");
assert!(sites.dynamic_imports[5].resolve_import_meta_first);
assert_eq!(
&source[sites.dynamic_imports[5].replace_start..sites.dynamic_imports[5].replace_end],
"await import(import.meta.resolve('./mapped.js'))"
);
}
#[test]
fn collects_special_forms_from_expression_fragments() {
let expression = "({ url: import.meta.url, mod: import('./object.js') })";
let sites = collect_module_special_form_rewrite_sites(expression)
.expect("expression fragment should collect through AST wrapper");
assert_eq!(sites.import_meta_urls.len(), 1);
assert_eq!(
&expression
[sites.import_meta_urls[0].start as usize..sites.import_meta_urls[0].end as usize],
"import.meta.url"
);
assert_eq!(sites.dynamic_imports.len(), 1);
assert_eq!(sites.dynamic_imports[0].specifier, "./object.js");
}
#[test]
fn lowering_attaches_special_form_sites_to_source_fragments() {
let lowering = lower_module_source_with_ast_lowering(
[
"const url = import.meta.url;",
"export const dep = await import('./dep.js');",
"export default import.meta.resolve('./resolved.js');",
]
.join("\n")
.as_str(),
)
.expect("module source should lower");
let ModuleAstStatement::Body(body) = &lowering.statements[0] else {
panic!("first statement should lower as body");
};
assert_eq!(body.special_forms.import_meta_urls.len(), 1);
let url_span = body.special_forms.import_meta_urls[0];
assert_eq!(
&body.source[url_span.start as usize..url_span.end as usize],
"import.meta.url"
);
let ModuleAstStatement::ExportConst { local_source, .. } = &lowering.statements[1] else {
panic!("second statement should lower as exported const");
};
assert_eq!(local_source.special_forms.dynamic_imports.len(), 1);
assert_eq!(
local_source.special_forms.dynamic_imports[0].kind,
DynamicImportRewriteKind::AwaitedNamespace
);
assert_eq!(
&local_source.source[local_source.special_forms.dynamic_imports[0].replace_start
..local_source.special_forms.dynamic_imports[0].replace_end],
"await import('./dep.js')"
);
let ModuleAstStatement::ExportDefaultExpr(expression) = &lowering.statements[2] else {
panic!("third statement should lower as default expression");
};
assert_eq!(expression.special_forms.import_metas.len(), 1);
assert_eq!(
&expression.source[expression.special_forms.import_metas[0].start as usize
..expression.special_forms.import_metas[0].end as usize],
"import.meta"
);
}
#[test]
fn lowering_includes_predeclared_export_surface_from_same_parse() {
let lowering = lower_module_source_with_ast_lowering(
"export const value = 1; export * from './x.js';",
)
.expect("module source should lower");
assert_eq!(
lowering.predeclared_export_surface.explicit_export_names,
vec!["value"]
);
assert!(lowering.predeclared_export_surface.has_export_star);
}
#[test]
fn ignores_special_form_words_inside_trivia_chunks() {
let trivia = [
"#!/usr/bin/env node",
"// import.meta.url",
"/* import.meta.resolve('./ignored.js') */",
"// import('./ignored.js')",
]
.join("\n");
let sites = collect_module_special_form_rewrite_sites(&trivia)
.expect("trivia-only chunks should not be parsed as executable code");
assert!(sites.import_meta_urls.is_empty());
assert!(sites.import_meta_resolves.is_empty());
assert!(sites.dynamic_imports.is_empty());
}
#[test]
fn rejects_invalid_non_trivia_special_form_fragments() {
let error = collect_module_special_form_rewrite_sites("import(")
.expect_err("invalid executable fragments should stay unsupported");
assert!(error.contains("unsupported module special-form syntax"));
}
#[test]
fn rejects_non_static_special_form_specifiers() {
let dynamic_error = collect_module_special_form_rewrite_sites("const p = import(name);")
.expect_err("non-static dynamic import should stay unsupported");
assert!(dynamic_error.contains("unsupported dynamic import syntax"));
let css_dynamic = collect_module_special_form_rewrite_sites(
"const p = import(name, { with: { type: 'css' } });",
)
.expect("CSS dynamic import should stay in source for V8's host hook");
assert!(css_dynamic.dynamic_imports.is_empty());
let resolve_sites =
collect_module_special_form_rewrite_sites("const p = import.meta.resolve(name);")
.expect("dynamic import.meta.resolve should be handled by runtime import.meta");
assert_eq!(resolve_sites.import_metas.len(), 1);
let extra_arg_sites = collect_module_special_form_rewrite_sites(
"const p = import.meta.resolve('./dep.js', './base.js');",
)
.expect("multi-argument import.meta.resolve should stay a runtime call");
assert_eq!(extra_arg_sites.import_metas.len(), 1);
}
#[test]
fn rejects_non_static_or_ambiguous_dynamic_import_options() {
for source in [
"const p = import('./dep.js', { ...opts });",
"const p = import('./dep.js', { ['with']: { type: 'json' } });",
"const p = import('./dep.js', { unrelated });",
"const p = import('./dep.js', { with: { type: 'json' }, with: { type: 'json' } });",
"const p = import('./dep.js', { with: { type: 'json' }, assert: { type: 'json' } });",
"const p = import('./dep.js', { with: { type: 'json', type: 'css' } });",
"const p = import('./dep.js', { with: { type: getType() } });",
"const p = import('./dep.js', { with: { other: 'json' } });",
"const p = import('./dep.js', { unrelated: 'value' });",
] {
let error = collect_module_special_form_rewrite_sites(source)
.expect_err("non-static import options should stay unsupported");
assert!(
error.contains("unsupported dynamic import syntax"),
"unexpected error for {source}: {error}"
);
}
let sites = collect_module_special_form_rewrite_sites(
"const p = import('./dep.js', ({ with: { type: 'json' } }));",
)
.expect("static dynamic import options should remain supported");
assert_eq!(sites.dynamic_imports.len(), 1);
assert_eq!(
sites.dynamic_imports[0].import_type.as_deref(),
Some("json")
);
let sites = collect_module_special_form_rewrite_sites(
"const p = import('./dep.js', { with: {} });",
)
.expect("empty static dynamic import attributes should remain supported");
assert_eq!(sites.dynamic_imports.len(), 1);
assert_eq!(sites.dynamic_imports[0].import_type, None);
}
#[test]
fn decodes_export_all_namespace_and_string_literals() {
let ModuleAstStatement::ExportAll(namespace) =
lower_one_module_statement("export * as \"n\\u0073:name\" from './mod.js';")
else {
panic!("source should lower as an export-all");
};
assert_eq!(namespace.specifier, "./mod.js");
assert_eq!(namespace.namespace_export_name.as_deref(), Some("ns:name"));
let ModuleAstStatement::ExportAll(rocket_namespace) =
lower_one_module_statement("export * as \"\\uD83D\\uDE80\" from './emoji.js';")
else {
panic!("source should lower as an export-all");
};
assert_eq!(
rocket_namespace.namespace_export_name.as_deref(),
Some("\u{1F680}")
);
}
#[test]
fn predeclared_export_surface_uses_oxc_ast_for_export_names() {
let surface = collect_predeclared_export_surface(
r#"
export const {alpha, beta: renamed, nested: [gamma]} = source;
export function ready() {}
export default class {}
export { ready as "ready-alias" };
export * as ns from './namespace.js';
export * from './all.js';
"#,
)
.expect("module source should parse");
assert_eq!(
surface.explicit_export_names,
vec![
"alpha".to_owned(),
"renamed".to_owned(),
"gamma".to_owned(),
"ready".to_owned(),
"default".to_owned(),
"ready-alias".to_owned(),
"ns".to_owned(),
]
);
assert!(surface.has_export_star);
}
#[test]
fn parses_default_exports_with_oxc_ast_shape() {
let expression = lower_default_expression("export default foo ? bar : baz;");
assert_eq!(expression, "foo ? bar : baz");
let anonymous_function = lower_default_declaration("export default function () {}");
assert_eq!(
anonymous_function.declaration_source.source,
"function () {}"
);
assert!(anonymous_function.is_anonymous);
let partial_body = lower_default_declaration(
"export default function makeValue() {\n return { ok: true };\n}",
);
assert_eq!(
partial_body.declaration_source.source,
"function makeValue() {\n return { ok: true };\n}"
);
assert!(!partial_body.is_anonymous);
assert_eq!(partial_body.local_name.as_deref(), Some("makeValue"));
let named_class = lower_default_declaration("export default class ValueBox {}");
assert_eq!(named_class.declaration_source.source, "class ValueBox {}");
assert!(!named_class.is_anonymous);
assert_eq!(named_class.local_name.as_deref(), Some("ValueBox"));
assert!(matches!(
lower_one_module_statement("export default function named() {}"),
ModuleAstStatement::ExportDefaultDeclaration(_)
));
}
#[test]
fn parses_script_var_and_function_declared_names_with_oxc() {
let names = parse_script_var_and_function_declared_names(
r#"
var { alpha, beta: gamma } = data;
for (var delta in data) {}
if (ready) { function boot() {} }
function outer() { var hidden = 1; function nested() {} }
"#,
)
.expect("script should parse");
assert_eq!(
names,
vec![
"alpha".to_owned(),
"boot".to_owned(),
"delta".to_owned(),
"gamma".to_owned(),
"outer".to_owned(),
]
);
}
#[test]
fn script_declared_names_reject_unparseable_source() {
let error = parse_script_var_and_function_declared_names(
"var O=function(){},exports={},a=1,function(){return 1},window=2;",
)
.expect_err("unparseable source should not produce guessed declarations");
assert_eq!(error, "unsupported script syntax");
}
#[test]
fn parses_top_level_assignment_declared_names_with_oxc() {
let names = parse_script_top_level_assignment_declared_names(
r#"
onload = function() {
location = "next.html";
window.nested = 1;
};
window.exported = 1;
bare = 2;
({ destructured } = value);
"#,
)
.expect("script should parse");
assert_eq!(
names,
vec![
"bare".to_owned(),
"exported".to_owned(),
"onload".to_owned(),
]
);
}
#[test]
fn parses_top_level_lexical_declared_names_with_oxc() {
let names = parse_script_top_level_lexical_declared_names(
r#"
const wrapThreshold = 1;
let { alpha, nested: [beta] } = value;
if (ready) { const hidden = 2; }
var ignored = 3;
"#,
)
.expect("script should parse");
assert_eq!(
names,
vec![
"alpha".to_owned(),
"beta".to_owned(),
"wrapThreshold".to_owned(),
]
);
}
#[test]
fn parses_default_declaration_local_names() {
let function = lower_default_declaration("export default function localName() {}");
assert_eq!(function.local_name.as_deref(), Some("localName"));
assert!(!function.is_anonymous);
let generator = lower_default_declaration("export default async function* localGen() {}");
assert_eq!(generator.local_name.as_deref(), Some("localGen"));
assert!(!generator.is_anonymous);
let class = lower_default_declaration("export default class LocalClass {}");
assert_eq!(class.local_name.as_deref(), Some("LocalClass"));
assert!(!class.is_anonymous);
let anonymous = lower_default_declaration("export default function () {}");
assert!(anonymous.local_name.is_none());
assert!(anonymous.is_anonymous);
let anonymous_class = lower_default_declaration("export default class extends Base {}");
assert!(anonymous_class.local_name.is_none());
assert!(anonymous_class.is_anonymous);
let newline_extends_class =
lower_default_declaration("export default class extends\nBase {}");
assert!(newline_extends_class.local_name.is_none());
assert!(newline_extends_class.is_anonymous);
let extends_prefix_name = lower_default_declaration("export default class extendsFoo {}");
assert_eq!(
extends_prefix_name.local_name.as_deref(),
Some("extendsFoo")
);
assert!(!extends_prefix_name.is_anonymous);
}
}