feat(napi/parser): return esm info (#7602)

The parser now returns import / export statement information, which can be used for parser plugins.
This commit is contained in:
Boshen 2024-12-03 11:21:54 +00:00
parent 521df425de
commit 7c62a33a06
9 changed files with 1990 additions and 25 deletions

View file

@ -10,3 +10,31 @@ rule = "run -p rulegen"
# Build oxlint in release mode
oxlint = "build --release --bin oxlint --features allocator"
# Fix napi breaking in test environment <https://github.com/napi-rs/napi-rs/issues/1005#issuecomment-1011034770>
# To be able to run unit tests on macOS, support compilation to 'x86_64-apple-darwin'.
[target.'cfg(target_vendor = "apple")']
rustflags = ["-C", "link-args=-Wl,-undefined,dynamic_lookup,-no_fixup_chains"]
# To be able to run unit tests on Linux, support compilation to 'x86_64-unknown-linux-gnu'.
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-args=-Wl,--warn-unresolved-symbols"]
# To be able to run unit tests on Windows, support compilation to 'x86_64-pc-windows-msvc'.
# Use Hybrid CRT to reduce the size of the binary (Coming by default with Windows 10 and later versions).
[target.'cfg(target_os = "windows")']
rustflags = [
"-C",
"link-args=/FORCE",
"-C",
"link-args=/NODEFAULTLIB:libucrt.lib",
"-C",
"link-args=/DEFAULTLIB:ucrt.lib",
]
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]

View file

@ -1,8 +1,11 @@
use napi_derive::napi;
use rustc_hash::FxHashMap;
use oxc_span::Span;
use oxc_syntax::module_record::{self, ModuleRecord};
/// Babel Parser Options
///
/// <https://github.com/babel/babel/blob/v7.26.2/packages/babel-parser/typings/babel-parser.d.ts>
#[napi(object)]
#[derive(Default)]
pub struct ParserOptions {
@ -23,6 +26,7 @@ pub struct ParserOptions {
pub struct ParseResult {
#[napi(ts_type = "import(\"@oxc-project/types\").Program")]
pub program: String,
pub module: EcmaScriptModule,
pub comments: Vec<Comment>,
pub errors: Vec<String>,
}
@ -35,3 +39,324 @@ pub struct Comment {
pub start: u32,
pub end: u32,
}
#[napi(object)]
pub struct EcmaScriptModule {
/// Import Statements.
pub static_imports: Vec<StaticImport>,
/// Export Statements.
pub static_exports: Vec<StaticExport>,
}
#[napi(object)]
pub struct ValueSpan {
pub value: String,
pub start: u32,
pub end: u32,
}
#[napi(object)]
pub struct StaticImport {
/// Start of import statement.
pub start: u32,
/// End of import statement.
pub end: u32,
/// Import source.
///
/// ```js
/// import { foo } from "mod";
/// // ^^^
/// ```
pub module_request: ValueSpan,
/// Import specifiers.
///
/// Empty for `import "mod"`.
pub entries: Vec<ImportEntry>,
}
#[napi(object)]
pub struct ImportEntry {
/// The name under which the desired binding is exported by the module.
///
/// ```js
/// import { foo } from "mod";
/// // ^^^
/// import { foo as bar } from "mod";
/// // ^^^
/// ```
pub import_name: ImportName,
/// The name that is used to locally access the imported value from within the importing module.
/// ```js
/// import { foo } from "mod";
/// // ^^^
/// import { foo as bar } from "mod";
/// // ^^^
/// ```
pub local_name: ValueSpan,
/// Whether this binding is for a TypeScript type-only import.
///
/// `true` for the following imports:
/// ```ts
/// import type { foo } from "mod";
/// import { type foo } from "mod";
/// ```
pub is_type: bool,
}
#[napi(string_enum)]
pub enum ImportNameKind {
/// `import { x } from "mod"`
Name,
/// `import * as ns from "mod"`
NamespaceObject,
/// `import defaultExport from "mod"`
Default,
}
#[napi(object)]
pub struct ImportName {
pub kind: ImportNameKind,
pub name: Option<String>,
pub start: Option<u32>,
pub end: Option<u32>,
}
#[napi(object)]
pub struct StaticExport {
pub start: u32,
pub end: u32,
pub entries: Vec<ExportEntry>,
}
#[napi(object)]
pub struct ExportEntry {
pub start: u32,
pub end: u32,
pub module_request: Option<ValueSpan>,
/// The name under which the desired binding is exported by the module`.
pub import_name: ExportImportName,
/// The name used to export this binding by this module.
pub export_name: ExportExportName,
/// The name that is used to locally access the exported value from within the importing module.
pub local_name: ExportLocalName,
}
#[napi(string_enum)]
pub enum ExportImportNameKind {
/// `export { name }
Name,
/// `export * as ns from "mod"`
All,
/// `export * from "mod"`
AllButDefault,
/// Does not have a specifier.
None,
}
#[napi(object)]
pub struct ExportImportName {
pub kind: ExportImportNameKind,
pub name: Option<String>,
pub start: Option<u32>,
pub end: Option<u32>,
}
#[napi(string_enum)]
pub enum ExportExportNameKind {
/// `export { name }
Name,
/// `export default expression`
Default,
/// `export * from "mod"
None,
}
#[napi(object)]
pub struct ExportExportName {
pub kind: ExportExportNameKind,
pub name: Option<String>,
pub start: Option<u32>,
pub end: Option<u32>,
}
#[napi(object)]
pub struct ExportLocalName {
pub kind: ExportLocalNameKind,
pub name: Option<String>,
pub start: Option<u32>,
pub end: Option<u32>,
}
#[napi(string_enum)]
pub enum ExportLocalNameKind {
/// `export { name }
Name,
/// `export default expression`
Default,
/// If the exported value is not locally accessible from within the module.
/// `export default function () {}`
None,
}
impl From<&ModuleRecord<'_>> for EcmaScriptModule {
fn from(record: &ModuleRecord<'_>) -> Self {
let mut static_imports = record
.requested_modules
.iter()
.flat_map(|(name, requested_modules)| {
requested_modules.iter().filter(|m| m.is_import).map(|m| {
let entries = record
.import_entries
.iter()
.filter(|e| e.statement_span == m.statement_span)
.map(ImportEntry::from)
.collect::<Vec<_>>();
{
StaticImport {
start: m.statement_span.start,
end: m.statement_span.end,
module_request: ValueSpan {
value: name.to_string(),
start: m.span.start,
end: m.span.end,
},
entries,
}
}
})
})
.collect::<Vec<_>>();
static_imports.sort_unstable_by_key(|e| e.start);
let mut static_exports = record
.local_export_entries
.iter()
.chain(record.indirect_export_entries.iter())
.chain(record.star_export_entries.iter())
.map(|e| (e.statement_span, ExportEntry::from(e)))
.collect::<Vec<_>>()
.into_iter()
.fold(FxHashMap::<Span, Vec<ExportEntry>>::default(), |mut acc, (span, e)| {
acc.entry(span).or_default().push(e);
acc
})
.into_iter()
.map(|(span, entries)| StaticExport { start: span.start, end: span.end, entries })
.collect::<Vec<_>>();
static_exports.sort_unstable_by_key(|e| e.start);
Self { static_imports, static_exports }
}
}
impl From<&module_record::ImportEntry<'_>> for ImportEntry {
fn from(e: &module_record::ImportEntry<'_>) -> Self {
Self {
import_name: ImportName::from(&e.import_name),
local_name: ValueSpan::from(&e.local_name),
is_type: e.is_type,
}
}
}
impl From<&module_record::ImportImportName<'_>> for ImportName {
fn from(e: &module_record::ImportImportName<'_>) -> Self {
let (kind, name, start, end) = match e {
module_record::ImportImportName::Name(name_span) => (
ImportNameKind::Name,
Some(name_span.name.to_string()),
Some(name_span.span.start),
Some(name_span.span.end),
),
module_record::ImportImportName::NamespaceObject => {
(ImportNameKind::NamespaceObject, None, None, None)
}
module_record::ImportImportName::Default(span) => {
(ImportNameKind::Default, None, Some(span.start), Some(span.end))
}
};
Self { kind, name, start, end }
}
}
impl From<&module_record::NameSpan<'_>> for ValueSpan {
fn from(name_span: &module_record::NameSpan) -> Self {
Self {
value: name_span.name.to_string(),
start: name_span.span.start,
end: name_span.span.end,
}
}
}
impl From<&module_record::ExportEntry<'_>> for ExportEntry {
fn from(e: &module_record::ExportEntry) -> Self {
Self {
start: e.span.start,
end: e.span.end,
module_request: e.module_request.as_ref().map(ValueSpan::from),
import_name: ExportImportName::from(&e.import_name),
export_name: ExportExportName::from(&e.export_name),
local_name: ExportLocalName::from(&e.local_name),
}
}
}
impl From<&module_record::ExportImportName<'_>> for ExportImportName {
fn from(e: &module_record::ExportImportName<'_>) -> Self {
let (kind, name, start, end) = match e {
module_record::ExportImportName::Name(name_span) => (
ExportImportNameKind::Name,
Some(name_span.name.to_string()),
Some(name_span.span.start),
Some(name_span.span.end),
),
module_record::ExportImportName::All => (ExportImportNameKind::All, None, None, None),
module_record::ExportImportName::AllButDefault => {
(ExportImportNameKind::AllButDefault, None, None, None)
}
module_record::ExportImportName::Null => (ExportImportNameKind::None, None, None, None),
};
Self { kind, name, start, end }
}
}
impl From<&module_record::ExportExportName<'_>> for ExportExportName {
fn from(e: &module_record::ExportExportName<'_>) -> Self {
let (kind, name, start, end) = match e {
module_record::ExportExportName::Name(name_span) => (
ExportExportNameKind::Name,
Some(name_span.name.to_string()),
Some(name_span.span.start),
Some(name_span.span.end),
),
module_record::ExportExportName::Default(span) => {
(ExportExportNameKind::Default, None, Some(span.start), Some(span.end))
}
module_record::ExportExportName::Null => (ExportExportNameKind::None, None, None, None),
};
Self { kind, name, start, end }
}
}
impl From<&module_record::ExportLocalName<'_>> for ExportLocalName {
fn from(e: &module_record::ExportLocalName<'_>) -> Self {
let (kind, name, start, end) = match e {
module_record::ExportLocalName::Name(name_span) => (
ExportLocalNameKind::Name,
Some(name_span.name.to_string()),
Some(name_span.span.start),
Some(name_span.span.end),
),
module_record::ExportLocalName::Default(name_span) => (
ExportLocalNameKind::Default,
Some(name_span.name.to_string()),
Some(name_span.span.start),
Some(name_span.span.end),
),
module_record::ExportLocalName::Null => (ExportLocalNameKind::None, None, None, None),
};
Self { kind, name, start, end }
}
}

View file

@ -166,11 +166,11 @@ pub struct ImportEntry<'a> {
/// `ImportName` For `ImportEntry`
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportImportName<'a> {
/// Name
/// `import { x } from "mod"`
Name(NameSpan<'a>),
/// Namespace Object
/// `import * as ns from "mod"`
NamespaceObject,
/// Default
/// `import defaultExport from "mod"`
Default(Span),
}

View file

@ -361,6 +361,10 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
module.exports.ExportExportNameKind = nativeBinding.ExportExportNameKind
module.exports.ExportImportNameKind = nativeBinding.ExportImportNameKind
module.exports.ExportLocalNameKind = nativeBinding.ExportLocalNameKind
module.exports.ImportNameKind = nativeBinding.ImportNameKind
module.exports.parseAsync = nativeBinding.parseAsync
module.exports.parseSync = nativeBinding.parseSync
module.exports.parseWithoutReturn = nativeBinding.parseWithoutReturn

180
napi/parser/index.d.ts vendored
View file

@ -9,24 +9,143 @@ export interface Comment {
end: number
}
export interface EcmaScriptModule {
/** Import Statements. */
staticImports: Array<StaticImport>
/** Export Statements. */
staticExports: Array<StaticExport>
}
export interface ExportEntry {
start: number
end: number
moduleRequest?: ValueSpan
/** The name under which the desired binding is exported by the module`. */
importName: ExportImportName
/** The name used to export this binding by this module. */
exportName: ExportExportName
/** The name that is used to locally access the exported value from within the importing module. */
localName: ExportLocalName
}
export interface ExportExportName {
kind: ExportExportNameKind
name?: string
start?: number
end?: number
}
export declare const enum ExportExportNameKind {
/** `export { name } */
Name = 'Name',
/** `export default expression` */
Default = 'Default',
/** `export * from "mod" */
None = 'None'
}
export interface ExportImportName {
kind: ExportImportNameKind
name?: string
start?: number
end?: number
}
export declare const enum ExportImportNameKind {
/** `export { name } */
Name = 'Name',
/** `export * as ns from "mod"` */
All = 'All',
/** `export * from "mod"` */
AllButDefault = 'AllButDefault',
/** Does not have a specifier. */
None = 'None'
}
export interface ExportLocalName {
kind: ExportLocalNameKind
name?: string
start?: number
end?: number
}
export declare const enum ExportLocalNameKind {
/** `export { name } */
Name = 'Name',
/** `export default expression` */
Default = 'Default',
/**
* If the exported value is not locally accessible from within the module.
* `export default function () {}`
*/
None = 'None'
}
export interface ImportEntry {
/**
* The name under which the desired binding is exported by the module.
*
* ```js
* import { foo } from "mod";
* // ^^^
* import { foo as bar } from "mod";
* // ^^^
* ```
*/
importName: ImportName
/**
* The name that is used to locally access the imported value from within the importing module.
* ```js
* import { foo } from "mod";
* // ^^^
* import { foo as bar } from "mod";
* // ^^^
* ```
*/
localName: ValueSpan
/**
* Whether this binding is for a TypeScript type-only import.
*
* `true` for the following imports:
* ```ts
* import type { foo } from "mod";
* import { type foo } from "mod";
* ```
*/
isType: boolean
}
export interface ImportName {
kind: ImportNameKind
name?: string
start?: number
end?: number
}
export declare const enum ImportNameKind {
/** `import { x } from "mod"` */
Name = 'Name',
/** `import * as ns from "mod"` */
NamespaceObject = 'NamespaceObject',
/** `import defaultExport from "mod"` */
Default = 'Default'
}
/**
* # Panics
* Parse asynchronously.
*
* * Tokio crashes
* Note: This function can be slower than `parseSync` due to the overhead of spawning a thread.
*/
export declare function parseAsync(sourceText: string, options?: ParserOptions | undefined | null): Promise<ParseResult>
export interface ParseResult {
program: import("@oxc-project/types").Program
module: EcmaScriptModule
comments: Array<Comment>
errors: Array<string>
}
/**
* Babel Parser Options
*
* <https://github.com/babel/babel/blob/v7.26.2/packages/babel-parser/typings/babel-parser.d.ts>
*/
/** Babel Parser Options */
export interface ParserOptions {
sourceType?: 'script' | 'module' | 'unambiguous' | undefined
sourceFilename?: string
@ -42,22 +161,47 @@ export interface ParserOptions {
preserveParens?: boolean
}
/**
* # Panics
*
* * File extension is invalid
* * Serde JSON serialization
*/
/** Parse synchronously. */
export declare function parseSync(sourceText: string, options?: ParserOptions | undefined | null): ParseResult
/**
* Parse without returning anything.
*
* This is for benchmark purposes such as measuring napi communication overhead.
*
* # Panics
*
* * File extension is invalid
* * Serde JSON serialization
*/
export declare function parseWithoutReturn(sourceText: string, options?: ParserOptions | undefined | null): void
export interface StaticExport {
start: number
end: number
entries: Array<ExportEntry>
}
export interface StaticImport {
/** Start of import statement. */
start: number
/** End of import statement. */
end: number
/**
* Import source.
*
* ```js
* import { foo } from "mod";
* // ^^^
* ```
*/
moduleRequest: ValueSpan
/**
* Import specifiers.
*
* Empty for `import "mod"`.
*/
entries: Array<ImportEntry>
}
export interface ValueSpan {
value: string
start: number
end: number
}

View file

@ -3,6 +3,7 @@
"private": true,
"scripts": {
"build": "napi build --platform --release --js bindings.js",
"build-dev": "napi build --platform --js bindings.js",
"test": "vitest --typecheck run ./test"
},
"napi": {

View file

@ -11,7 +11,7 @@ use oxc::{
allocator::Allocator,
ast::CommentKind,
diagnostics::{Error, NamedSource},
napi::parse::{Comment, ParseResult, ParserOptions},
napi::parse::{Comment, EcmaScriptModule, ParseResult, ParserOptions},
parser::{ParseOptions, Parser, ParserReturn},
span::SourceType,
};
@ -81,7 +81,8 @@ fn parse_with_return(source_text: &str, options: &ParserOptions) -> ParseResult
})
.collect::<Vec<Comment>>();
ParseResult { program, comments, errors }
let module = EcmaScriptModule::from(&ret.module_record);
ParseResult { program, module, comments, errors }
}
/// Parse synchronously.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import { describe, expect, it, test } from 'vitest';
import * as oxc from '../index.js';
describe('esm', () => {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#syntax
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export#syntax
let code = `
import defaultExport from "module-name";
import * as name from "module-name";
import { export1 } from "module-name";
import { export1 as alias1 } from "module-name";
import { default as alias } from "module-name";
import { export1, export2 } from "module-name";
import { export1, export2 as alias2, /* … */ } from "module-name";
import { "string name" as alias } from "module-name";
import defaultExport, { export1, /* … */ } from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";
export let name1, name2/*, … */; // also var
export const name1 = 1, name2 = 2/*, … */; // also var, let
export function functionName() { /* … */ }
export class ClassName { /* … */ }
export function* generatorFunctionName() { /* … */ }
export const { name1, name2: bar } = o;
export const [ name1, name2 ] = array;
export { name1, /* …, */ nameN };
export { variable1 as name1, variable2 as name2, /* …, */ nameN };
export { variable1 as "string name" };
export { name1 as default /*, … */ };
export default expression;
export default function functionName() { /* … */ }
export default class ClassName { /* … */ }
export default function* generatorFunctionName() { /* … */ }
export default function () { /* … */ }
export default class { /* … */ }
export default function* () { /* … */ }
export * from "module-name";
export * as name1 from "module-name";
export { name1, /* …, */ nameN } from "module-name";
export { import1 as name1, import2 as name2, /* …, */ nameN } from "module-name";
export { default, /* …, */ } from "module-name";
export { default as name1 } from "module-name";
`.split('\n').map((s) => s.trim()).filter(Boolean);
test.each(code)('%s', (s) => {
const ret = oxc.parseSync(s, { sourceFilename: 'test.ts' });
expect(ret.program.body.length).toBeGreaterThan(0);
expect(ret.errors.length).toBe(0);
expect(JSON.stringify(ret.module, null, 2)).toMatchSnapshot();
if (s.startsWith('import')) {
expect(ret.module.staticImports.length).toBe(1);
expect(ret.module.staticExports.length).toBe(0);
}
if (s.startsWith('export')) {
expect(ret.module.staticImports.length).toBe(0);
expect(ret.module.staticExports.length).toBe(1);
}
});
});