@napi-rs/cli
Version:
Cli tools for napi-rs
1,467 lines (1,368 loc) • 43.3 kB
text/typescript
import { createRequire } from 'node:module'
import { dirname, relative, resolve } from 'node:path'
import { sortBy } from 'es-toolkit'
import type {
CompilerHost,
CompilerOptions,
Diagnostic,
EntityName,
Identifier,
NodeArray,
SourceFile,
Statement,
SymbolFlags,
} from 'typescript'
import { readFileAsync } from './misc.js'
const require = createRequire(import.meta.url)
type TypeScriptModule = typeof import('typescript')
const TOP_LEVEL_NAMESPACE = '__TOP_LEVEL_MODULE__'
export const DEFAULT_TYPE_DEF_HEADER = `/* auto-generated by NAPI-RS */
/* eslint-disable */
`
const ITERATOR_OBJECT_COMPATIBILITY_DECLARATION = ` interface IteratorObject<T, TReturn = unknown, TNext = unknown>
extends globalThis.Iterator<T, TReturn, TNext> {
[globalThis.Symbol.iterator](): globalThis.IteratorObject<T, TReturn, TNext>
}`
function asyncGeneratorDeclaration(name: string): string {
// TOwner keeps downstream declaration emit importing the module that
// installs this global augmentation, even though it is structurally unused.
return ` interface ${name}<TOwner, T, TReturn, TNext> {
next(...[value]: [] | [TNext]): globalThis.Promise<globalThis.IteratorResult<T, TReturn | undefined>>
return(...[value]: [] | [TReturn]): globalThis.Promise<globalThis.IteratorResult<T, TReturn | undefined>>
throw(exception?: unknown): globalThis.Promise<globalThis.IteratorResult<T, TReturn | undefined>>
[globalThis.Symbol.asyncIterator](): this
}`
}
export function createWasmModuleTypeDef() {
return `${DEFAULT_TYPE_DEF_HEADER}
declare const wasmModule: WebAssembly.Module
export default wasmModule
`
}
enum TypeDefKind {
Const = 'const',
Enum = 'enum',
StringEnum = 'string_enum',
Interface = 'interface',
Type = 'type',
Fn = 'fn',
Struct = 'struct',
Extends = 'extends',
Impl = 'impl',
}
interface TypeDefLine {
kind: TypeDefKind
name: string
original_name?: string
def: string
def_with_type_import_markers?: string
extends?: string
asyncIterator?: [yieldType: string, returnType: string, nextType: string]
js_doc?: string
js_mod?: string
type_imports?: TypeImport[]
}
export interface TypeImport {
marker?: string
name: string
module: string
}
function parseTypeParameters(
source: string,
): [yieldType: string, returnType: string, nextType: string] | undefined {
const parameters: string[] = []
let start = 0
const depths = {
angle: 0,
brace: 0,
bracket: 0,
parenthesis: 0,
}
let quote: "'" | '"' | '`' | undefined
let escaped = false
for (let index = 0; index < source.length; index += 1) {
const character = source[index]
if (quote) {
if (escaped) {
escaped = false
} else if (character === '\\') {
escaped = true
} else if (character === quote) {
quote = undefined
}
continue
}
if (character === "'" || character === '"' || character === '`') {
quote = character
continue
}
switch (character) {
case '<':
depths.angle += 1
break
case '>':
if (source[index - 1] !== '=') {
depths.angle -= 1
}
break
case '{':
depths.brace += 1
break
case '}':
depths.brace -= 1
break
case '[':
depths.bracket += 1
break
case ']':
depths.bracket -= 1
break
case '(':
depths.parenthesis += 1
break
case ')':
depths.parenthesis -= 1
break
case ',':
if (Object.values(depths).every((depth) => depth === 0)) {
parameters.push(source.slice(start, index).trim())
start = index + 1
}
break
}
if (Object.values(depths).some((depth) => depth < 0)) {
return
}
}
if (quote || Object.values(depths).some((depth) => depth !== 0)) {
return
}
parameters.push(source.slice(start).trim())
if (parameters.length !== 3 || parameters.some((parameter) => !parameter)) {
return
}
return parameters as [string, string, string]
}
function parseIteratorExtends(
extendsDef: string,
): [yieldType: string, returnType: string, nextType: string] | undefined {
const match = extendsDef.trim().match(/^Iterator\s*<([\s\S]*)>$/)
return match ? parseTypeParameters(match[1]) : undefined
}
function parseAsyncGeneratorImpl(
implDef: string,
): [yieldType: string, returnType: string, nextType: string] | undefined {
const match = implDef
.trim()
.match(/^\[Symbol\.asyncIterator\]\(\):\s*AsyncGenerator\s*<([\s\S]*)>$/)
return match ? parseTypeParameters(match[1]) : undefined
}
/**
* Render a single intermediate type-def line as the TypeScript source it
* should produce in `index.d.ts`.
*
* @param line - The intermediate type-def entry to render.
* @param constEnum - When true, emit numeric and string `#[napi]` enums as
* `const enum`. When false (`--no-const-enum`), numeric enums become
* regular runtime enums and string enums fall back to a type-only union
* unless `runtimeStringEnum` is also true.
* @param runtimeStringEnum - When true under `--no-const-enum`, emit
* `#[napi(string_enum)]` as a runtime enum (`export declare enum`)
* instead of a type-only union. No-op when `constEnum` is true.
* @param ident - Indentation level applied to the rendered output.
* @param ambient - When true, emit declarations in the ambient form used
* inside `declare namespace` blocks (e.g. drop the `declare` keyword).
*/
function prettyPrint(
line: TypeDefLine,
constEnum: boolean,
runtimeStringEnum: boolean,
ident: number,
ambient = false,
asyncGeneratorHelperName?: string,
): string {
let s = line.js_doc ?? ''
switch (line.kind) {
case TypeDefKind.Interface:
s += `export interface ${line.name} {\n${line.def}\n}`
break
case TypeDefKind.Type:
s += `export type ${line.name} = \n${line.def}`
break
case TypeDefKind.Enum: {
const enumName = constEnum ? 'const enum' : 'enum'
s += `${exportDeclare(ambient)} ${enumName} ${line.name} {\n${line.def}\n}`
break
}
case TypeDefKind.StringEnum: {
if (constEnum) {
s += `${exportDeclare(ambient)} const enum ${line.name} {\n${line.def}\n}`
} else if (runtimeStringEnum) {
s += `${exportDeclare(ambient)} enum ${line.name} {\n${line.def}\n}`
} else {
s += `export type ${line.name} = ${line.def.replaceAll(/.*=/g, '').replaceAll(',', '|')};`
}
break
}
case TypeDefKind.Struct: {
let classDef = line.def
let extendsDef = line.extends ? ` extends ${line.extends}` : ''
let iteratorInterface = ''
const iteratorTypes = line.extends
? parseIteratorExtends(line.extends)
: undefined
if (iteratorTypes) {
// Runtime instances inherit from Iterator.prototype when it exists,
// but the generated constructor does not extend the global Iterator.
const [T, TResult, TNext] = iteratorTypes
const resultType = `(${TResult}) | undefined`
classDef +=
`\n[globalThis.Symbol.iterator](): this` +
`\nnext(...[value]: [] | [${TNext}]): globalThis.IteratorResult<${T}, ${resultType}>` +
`\nreturn(...[value]: [] | [${TResult}]): globalThis.IteratorResult<${T}, ${resultType}>` +
`\nthrow(exception?: unknown): globalThis.IteratorResult<${T}, ${resultType}>`
extendsDef = ''
iteratorInterface =
`\n\nexport interface ${line.name} ` +
`extends globalThis.Omit<globalThis.IteratorObject<${T}, ${resultType}, ${TNext}>, 'next' | 'return' | 'throw'> {}`
}
if (line.asyncIterator) {
if (!asyncGeneratorHelperName) {
throw new Error('Async generator helper name was not initialized')
}
const [T, TResult, TNext] = line.asyncIterator
classDef += `\n[globalThis.Symbol.asyncIterator](): globalThis.${asyncGeneratorHelperName}<${line.name}, ${T}, ${TResult}, ${TNext}>`
}
s += `${exportDeclare(ambient)} class ${line.name}${extendsDef} {\n${classDef}\n}`
s += iteratorInterface
if (line.original_name && line.original_name !== line.name) {
s += `\nexport type ${line.original_name} = ${line.name}`
}
break
}
case TypeDefKind.Fn:
s += `${exportDeclare(ambient)} ${line.def}`
break
default:
s += line.def
}
return correctStringIdent(s, ident)
}
function exportDeclare(ambient: boolean): string {
if (ambient) {
return 'export'
}
return 'export declare'
}
/**
* Read the napi-derive-emitted intermediate type-def file and render its
* entries into the `index.d.ts` source string plus the list of names to
* re-export from `index.js`.
*
* @param intermediateTypeFile - Path to the JSONL type-def file produced
* by napi-derive (one entry per `#[napi]` item).
* @param constEnum - See {@link prettyPrint}.
* @param runtimeStringEnum - See {@link prettyPrint}. Defaults to `false`.
*/
export async function processTypeDef(
intermediateTypeFile: string,
constEnum: boolean,
runtimeStringEnum: boolean = false,
) {
return processTypeDefs([intermediateTypeFile], constEnum, runtimeStringEnum)
}
export async function processTypeDefs(
intermediateTypeFiles: string[],
constEnum: boolean,
runtimeStringEnum: boolean = false,
reservedDeclarationText: string = '',
) {
const exports: string[] = []
const typeDefs = await Promise.all(
intermediateTypeFiles.map((file) => readIntermediateTypeFile(file)),
)
const typeDefsWithUniqueMarkers = makeTypeImportMarkersUnique(
typeDefs,
reservedDeclarationText,
)
const typeImports = collectTypeImports(typeDefsWithUniqueMarkers.flat())
const dtsWithTypeImportMarkers = renderTypeDefs(
typeDefsWithUniqueMarkers.map((defs) =>
preprocessTypeDef(preserveTypeImportMarkers(defs)),
),
constEnum,
runtimeStringEnum,
exports,
reservedDeclarationText,
)
const dts = rewriteTypeImportReferences(
dtsWithTypeImportMarkers,
typeImports,
false,
)
return {
dts,
dtsWithTypeImportMarkers,
exports,
typeImports,
}
}
function renderTypeDefs(
groupedTypeDefs: Map<string, TypeDefLine[]>[],
constEnum: boolean,
runtimeStringEnum: boolean,
exports: string[],
reservedDeclarationText: string,
): string {
const topLevelExportNames = new Set<string>()
for (const groupedDefs of groupedTypeDefs) {
for (const [namespace, namespaceDefs] of groupedDefs) {
if (namespace !== TOP_LEVEL_NAMESPACE) {
if (namespace === 'globalThis') {
throw new Error(
'The export name `globalThis` is reserved by NAPI-RS type generation',
)
}
topLevelExportNames.add(namespace)
}
for (const def of namespaceDefs) {
if (def.name === 'globalThis' || def.original_name === 'globalThis') {
throw new Error(
'The export name `globalThis` is reserved by NAPI-RS type generation',
)
}
if (namespace === TOP_LEVEL_NAMESPACE) {
topLevelExportNames.add(def.name)
if (def.original_name) {
topLevelExportNames.add(def.original_name)
}
}
}
}
}
const hasIteratorClass = groupedTypeDefs.some((groupedDefs) =>
Array.from(groupedDefs.values()).some((defs) =>
defs.some(
(def) =>
def.kind === TypeDefKind.Struct &&
def.extends !== undefined &&
parseIteratorExtends(def.extends) !== undefined,
),
),
)
const hasAsyncIteratorClass = groupedTypeDefs.some((groupedDefs) =>
Array.from(groupedDefs.values()).some((defs) =>
defs.some(
(def) =>
def.kind === TypeDefKind.Struct && def.asyncIterator !== undefined,
),
),
)
let asyncGeneratorHelperName = '__NapiRsAsyncGenerator'
for (
let suffix = 1;
topLevelExportNames.has(asyncGeneratorHelperName) ||
reservedDeclarationText.includes(asyncGeneratorHelperName);
suffix += 1
) {
asyncGeneratorHelperName = `__NapiRsAsyncGenerator_${suffix}`
}
const renderedDefs = groupedTypeDefs
.map((groupedDefs) =>
sortBy(Array.from(groupedDefs), [([namespace]) => namespace])
.map(([namespace, defs]) => {
if (namespace === TOP_LEVEL_NAMESPACE) {
return defs
.map((def) => {
switch (def.kind) {
case TypeDefKind.Const:
case TypeDefKind.Enum:
case TypeDefKind.StringEnum:
case TypeDefKind.Fn:
case TypeDefKind.Struct: {
exports.push(def.name)
if (def.original_name && def.original_name !== def.name) {
exports.push(def.original_name)
}
break
}
default:
break
}
return prettyPrint(
def,
constEnum,
runtimeStringEnum,
0,
false,
asyncGeneratorHelperName,
)
})
.join('\n\n')
} else {
exports.push(namespace)
let declaration = ''
declaration += `export declare namespace ${namespace} {\n`
for (const def of defs) {
declaration +=
prettyPrint(
def,
constEnum,
runtimeStringEnum,
2,
true,
asyncGeneratorHelperName,
) + '\n'
}
declaration += '}'
return declaration
}
})
.join('\n\n'),
)
.join('\n')
const globalDeclarations = []
if (hasIteratorClass) {
globalDeclarations.push(ITERATOR_OBJECT_COMPATIBILITY_DECLARATION)
}
if (hasAsyncIteratorClass) {
globalDeclarations.push(asyncGeneratorDeclaration(asyncGeneratorHelperName))
}
const dts =
(globalDeclarations.length
? `declare global {\n${globalDeclarations.join('\n\n')}\n}\n\n`
: '') +
renderedDefs +
'\n'
return dts
}
function makeTypeImportMarkersUnique(
typeDefGroups: TypeDefLine[][],
reservedDeclarationText: string,
): TypeDefLine[][] {
const reservedText = [
reservedDeclarationText,
...typeDefGroups
.flat()
.flatMap((def) => [def.def, def.js_doc ?? '', def.name]),
].join('\n')
const allocated = new Set<string>()
return typeDefGroups.map((defs) =>
defs.map((def) => {
const markedDefinition = def.def_with_type_import_markers
const reallocations: Array<{
importedName: string
nextMarker: string
previousMarker: string
}> = []
const typeImports = def.type_imports?.map((typeImport) => {
if (!typeImport.marker || markedDefinition === undefined) {
return { ...typeImport }
}
const base = typeImport.marker
let marker = base
for (
let suffix = 1;
allocated.has(marker) || reservedText.includes(marker);
suffix += 1
) {
marker = `${base}_${suffix}`
}
allocated.add(marker)
reallocations.push({
importedName: typeImport.name,
nextMarker: marker,
previousMarker: typeImport.marker,
})
return { ...typeImport, marker }
})
return {
...def,
def_with_type_import_markers:
markedDefinition === undefined
? undefined
: reallocateTypeImportMarkers(
def.def,
markedDefinition,
reallocations,
),
type_imports: typeImports,
}
}),
)
}
function reallocateTypeImportMarkers(
definition: string,
markedDefinition: string,
reallocations: Array<{
importedName: string
nextMarker: string
previousMarker: string
}>,
) {
const changedReallocations = reallocations
.filter(({ nextMarker, previousMarker }) => previousMarker !== nextMarker)
.sort(
(left, right) => right.previousMarker.length - left.previousMarker.length,
)
if (changedReallocations.length === 0) {
return markedDefinition
}
let definitionOffset = 0
let markedOffset = 0
let rewritten = ''
while (markedOffset < markedDefinition.length) {
let markerOffset = -1
let reallocation: (typeof changedReallocations)[number] | undefined
for (const candidate of changedReallocations) {
const candidateOffset = markedDefinition.indexOf(
candidate.previousMarker,
markedOffset,
)
if (
candidateOffset !== -1 &&
(markerOffset === -1 || candidateOffset < markerOffset)
) {
markerOffset = candidateOffset
reallocation = candidate
}
}
if (markerOffset === -1 || reallocation === undefined) {
break
}
const prefix = markedDefinition.slice(markedOffset, markerOffset)
if (!definition.startsWith(prefix, definitionOffset)) {
throw new Error(
`Imported type marker ${reallocation.previousMarker} does not match its declaration`,
)
}
rewritten += prefix
definitionOffset += prefix.length
markedOffset = markerOffset + reallocation.previousMarker.length
if (definition.startsWith(reallocation.previousMarker, definitionOffset)) {
rewritten += reallocation.previousMarker
definitionOffset += reallocation.previousMarker.length
} else if (
definition.startsWith(reallocation.importedName, definitionOffset)
) {
rewritten += reallocation.nextMarker
definitionOffset += reallocation.importedName.length
} else {
throw new Error(
`Imported type marker ${reallocation.previousMarker} does not match ${reallocation.importedName} in its declaration`,
)
}
}
const markedRemainder = markedDefinition.slice(markedOffset)
const definitionRemainder = definition.slice(definitionOffset)
if (markedRemainder !== definitionRemainder) {
throw new Error('Imported type markers leave mismatched declaration text')
}
return rewritten + markedRemainder
}
function preserveTypeImportMarkers(defs: TypeDefLine[]): TypeDefLine[] {
return defs.map((def) => ({
...def,
def: def.def_with_type_import_markers ?? def.def,
}))
}
function collectTypeImports(defs: TypeDefLine[]): TypeImport[] {
const imports = new Map<string, TypeImport>()
for (const typeImport of defs.flatMap((def) => def.type_imports ?? [])) {
imports.set(
`${typeImport.marker ?? ''}\0${typeImport.module}\0${typeImport.name}`,
typeImport,
)
}
return [...imports.values()].sort(
(left, right) =>
left.module.localeCompare(right.module) ||
left.name.localeCompare(right.name) ||
(left.marker ?? '').localeCompare(right.marker ?? ''),
)
}
const BUFFER_TYPE_REFERENCE = 'import("buffer").Buffer'
const BUFFER_HERITAGE_ALIAS = '__NapiRsBuffer'
const IN_MEMORY_DECLARATION_FILE = '/__napi_rs_typegen__.d.ts'
export function rewriteTypeImportReferences(
source: string,
typeImports: TypeImport[],
inlineImports: boolean,
): string {
const markerImports = new Map<string, TypeImport>()
for (const typeImport of typeImports) {
const { marker } = typeImport
if (marker) {
markerImports.set(marker, typeImport)
}
}
const rewriteUnboundBuffer = inlineImports && source.includes('Buffer')
if (markerImports.size === 0 && !rewriteUnboundBuffer) {
return source
}
const typeScript = loadTypeScript()
const { program, sourceFile } = createDeclarationProgram(source)
const checker = rewriteUnboundBuffer ? program.getTypeChecker() : undefined
let bufferHeritageAlias: string | undefined
const getBufferHeritageAlias = () => {
bufferHeritageAlias ??= createCollisionSafeIdentifier(
typeScript,
sourceFile,
BUFFER_HERITAGE_ALIAS,
)
return bufferHeritageAlias
}
const replacements: Array<{
end: number
replacement: string
start: number
}> = []
const visit = (node: import('typescript').Node) => {
if (typeScript.isIdentifier(node)) {
const markerImport = markerImports.get(node.text)
if (
markerImport !== undefined &&
typeImportReferenceMeaning(typeScript, node) !== undefined
) {
const useBufferHeritageAlias =
inlineImports &&
markerImport.module === 'buffer' &&
markerImport.name === 'Buffer' &&
isHeritageReference(typeScript, node)
replacements.push({
start: node.getStart(sourceFile),
end: node.end,
replacement: inlineImports
? useBufferHeritageAlias
? getBufferHeritageAlias()
: `import(${JSON.stringify(markerImport.module)}).${markerImport.name}`
: markerImport.name,
})
} else if (
checker !== undefined &&
node.text === 'Buffer' &&
isUnboundBufferReference(typeScript, checker, node)
) {
replacements.push({
start: node.getStart(sourceFile),
end: node.end,
replacement: isHeritageReference(typeScript, node)
? getBufferHeritageAlias()
: BUFFER_TYPE_REFERENCE,
})
}
}
typeScript.forEachChild(node, visit)
}
visit(sourceFile)
let rewritten = source
for (const replacement of replacements.reverse()) {
rewritten =
rewritten.slice(0, replacement.start) +
replacement.replacement +
rewritten.slice(replacement.end)
}
if (bufferHeritageAlias) {
rewritten = appendAliasedImport(
rewritten,
'buffer',
'Buffer',
bufferHeritageAlias,
)
}
return rewritten
}
export function rebaseDeclarationSpecifiers(
source: string,
sourcePath: string,
destinationPath: string,
): string {
const references = collectRelativeDeclarationSpecifierReferences(source)
const replacements: Array<{
end: number
replacement: string
start: number
}> = []
for (const reference of references) {
const absoluteTarget = resolve(dirname(sourcePath), reference.specifier)
let rebased = relative(dirname(destinationPath), absoluteTarget).replaceAll(
'\\',
'/',
)
if (!rebased.startsWith('.')) {
rebased = `./${rebased}`
}
replacements.push({
start: reference.start,
end: reference.end,
replacement: rebased,
})
}
let rebasedSource = source
for (const replacement of replacements
.filter(
(replacement, index, all) =>
all.findIndex(
(candidate) =>
candidate.start === replacement.start &&
candidate.end === replacement.end,
) === index,
)
.sort((left, right) => right.start - left.start)) {
rebasedSource =
rebasedSource.slice(0, replacement.start) +
replacement.replacement +
rebasedSource.slice(replacement.end)
}
return rebasedSource
}
interface DeclarationSpecifierReference {
end: number
specifier: string
start: number
}
export function collectRelativeDeclarationSpecifiers(source: string): string[] {
return [
...new Set(
collectRelativeDeclarationSpecifierReferences(source).map(
({ specifier }) => specifier,
),
),
]
}
function collectRelativeDeclarationSpecifierReferences(
source: string,
): DeclarationSpecifierReference[] {
const typeScript = loadTypeScript()
const sourceFile = typeScript.createSourceFile(
IN_MEMORY_DECLARATION_FILE,
source,
typeScript.ScriptTarget.Latest,
true,
typeScript.ScriptKind.TS,
)
const references: DeclarationSpecifierReference[] = []
const addStringLiteral = (node: import('typescript').StringLiteralLike) => {
if (!node.text.startsWith('.')) {
return
}
references.push({
start: node.getStart(sourceFile) + 1,
end: node.end - 1,
specifier: node.text,
})
}
const visit = (node: import('typescript').Node) => {
if (
(typeScript.isImportDeclaration(node) ||
typeScript.isExportDeclaration(node)) &&
node.moduleSpecifier &&
typeScript.isStringLiteralLike(node.moduleSpecifier)
) {
addStringLiteral(node.moduleSpecifier)
} else if (
typeScript.isImportTypeNode(node) &&
typeScript.isLiteralTypeNode(node.argument) &&
typeScript.isStringLiteralLike(node.argument.literal)
) {
addStringLiteral(node.argument.literal)
} else if (
typeScript.isExternalModuleReference(node) &&
node.expression &&
typeScript.isStringLiteralLike(node.expression)
) {
addStringLiteral(node.expression)
} else if (
typeScript.isCallExpression(node) &&
node.arguments.length === 1 &&
typeScript.isStringLiteralLike(node.arguments[0]) &&
(node.expression.kind === typeScript.SyntaxKind.ImportKeyword ||
(typeScript.isIdentifier(node.expression) &&
node.expression.text === 'require'))
) {
addStringLiteral(node.arguments[0])
}
typeScript.forEachChild(node, visit)
}
visit(sourceFile)
const preprocessed = typeScript.preProcessFile(source, true, true)
for (const reference of [
...preprocessed.referencedFiles,
...preprocessed.typeReferenceDirectives,
]) {
if (reference.fileName.startsWith('.')) {
references.push({
start: reference.pos,
end: reference.end,
specifier: reference.fileName,
})
}
}
return references
.filter(
(reference, index, all) =>
all.findIndex(
(candidate) =>
candidate.start === reference.start &&
candidate.end === reference.end,
) === index,
)
.sort((left, right) => left.start - right.start)
}
function createCollisionSafeIdentifier(
typeScript: TypeScriptModule,
sourceFile: SourceFile,
baseName: string,
): string {
const identifiers = new Set<string>()
const visit = (node: import('typescript').Node) => {
if (typeScript.isIdentifier(node)) {
identifiers.add(node.text)
}
typeScript.forEachChild(node, visit)
}
visit(sourceFile)
let identifier = baseName
let suffix = 1
while (identifiers.has(identifier)) {
identifier = `${baseName}_${suffix}`
suffix += 1
}
return identifier
}
function isHeritageReference(
typeScript: TypeScriptModule,
identifier: Identifier,
): boolean {
const parent = identifier.parent
return (
typeScript.isExpressionWithTypeArguments(parent) &&
parent.expression === identifier &&
typeScript.isHeritageClause(parent.parent)
)
}
function isUnboundBufferReference(
typeScript: TypeScriptModule,
checker: import('typescript').TypeChecker,
identifier: Identifier,
): boolean {
const meaning = typeImportReferenceMeaning(typeScript, identifier)
return (
meaning !== undefined &&
checker.resolveName(identifier.text, identifier, meaning, false) ===
undefined
)
}
function typeImportReferenceMeaning(
typeScript: TypeScriptModule,
identifier: Identifier,
): SymbolFlags | undefined {
let entityName: EntityName = identifier
while (
typeScript.isQualifiedName(entityName.parent) &&
entityName.parent.left === entityName
) {
entityName = entityName.parent
}
const parent = entityName.parent
if (
typeScript.isTypeReferenceNode(parent) &&
parent.typeName === entityName
) {
return typeScript.SymbolFlags.Type
}
if (typeScript.isTypeQueryNode(parent) && parent.exprName === entityName) {
return typeScript.SymbolFlags.Value
}
if (
typeScript.isExpressionWithTypeArguments(parent) &&
parent.expression === entityName &&
typeScript.isHeritageClause(parent.parent)
) {
const heritageClause = parent.parent
const declaration = heritageClause.parent
return heritageClause.token === typeScript.SyntaxKind.ExtendsKeyword &&
(typeScript.isClassDeclaration(declaration) ||
typeScript.isClassExpression(declaration))
? typeScript.SymbolFlags.Value
: typeScript.SymbolFlags.Type
}
}
function createDeclarationProgram(source: string): {
program: import('typescript').Program
sourceFile: SourceFile
} {
const typeScript = loadTypeScript()
const options: CompilerOptions = {
module: typeScript.ModuleKind.ESNext,
noLib: true,
noResolve: true,
skipLibCheck: true,
target: typeScript.ScriptTarget.Latest,
types: [],
}
const sourceFile = typeScript.createSourceFile(
IN_MEMORY_DECLARATION_FILE,
source,
options.target!,
true,
typeScript.ScriptKind.TS,
)
const host: CompilerHost = {
fileExists: (fileName) => fileName === IN_MEMORY_DECLARATION_FILE,
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: () => '/',
getDefaultLibFileName: () => '',
getDirectories: () => [],
getNewLine: () => '\n',
getSourceFile: (fileName) =>
fileName === IN_MEMORY_DECLARATION_FILE ? sourceFile : undefined,
readFile: (fileName) =>
fileName === IN_MEMORY_DECLARATION_FILE ? source : undefined,
useCaseSensitiveFileNames: () => true,
writeFile: () => {},
}
const program = typeScript.createProgram({
rootNames: [IN_MEMORY_DECLARATION_FILE],
options,
host,
})
const diagnostics = program.getSyntacticDiagnostics(sourceFile)
if (diagnostics.length > 0) {
throwDeclarationDiagnostics(typeScript, sourceFile, diagnostics)
}
return { program, sourceFile }
}
function throwDeclarationDiagnostics(
typeScript: TypeScriptModule,
sourceFile: SourceFile,
diagnostics: readonly Diagnostic[],
): never {
const messages = diagnostics.slice(0, 3).map((diagnostic) => {
const message = typeScript.flattenDiagnosticMessageText(
diagnostic.messageText,
'\n',
)
if (diagnostic.start === undefined) {
return message
}
const { character, line } = sourceFile.getLineAndCharacterOfPosition(
diagnostic.start,
)
return `${line + 1}:${character + 1} ${message}`
})
throw new Error(`Failed to parse declaration source:\n${messages.join('\n')}`)
}
function decodeStructuralNewlines(source: string): string {
let output = ''
let index = 0
while (index < source.length) {
const character = source[index]
if (character === "'" || character === '"' || character === '`') {
const quote = character
output += character
index += 1
while (index < source.length) {
const current = source[index]
output += current
index += 1
if (current === '\\' && index < source.length) {
output += source[index]
index += 1
} else if (current === quote) {
break
}
}
continue
}
if (character === '/' && source[index + 1] === '/') {
const end = source.indexOf('\n', index + 2)
if (end === -1) {
output += source.slice(index)
break
}
output += source.slice(index, end)
index = end
continue
}
if (character === '/' && source[index + 1] === '*') {
const end = source.indexOf('*/', index + 2)
if (end === -1) {
output += source.slice(index)
break
}
output += source.slice(index, end + 2)
index = end + 2
continue
}
if (character === '\\' && source[index + 1] === 'n') {
output += '\n'
index += 2
continue
}
output += character
index += 1
}
return output
}
export function appendTypeImports(
source: string,
typeImports: TypeImport[],
): string {
const typeScript = loadTypeScript()
const parsed = parseDeclarationSource(source)
const missingImports = typeImports.filter(
(typeImport) => !hasNamedImport(parsed.statements, typeImport),
)
if (missingImports.length === 0) {
return source
}
const importsByModule = new Map<string, string[]>()
for (const { module, name } of missingImports) {
const names = importsByModule.get(module) ?? []
names.push(name)
importsByModule.set(module, names)
}
const newline = source.includes('\r\n') ? '\r\n' : '\n'
const importSource = [...importsByModule]
.sort(([left], [right]) => left.localeCompare(right))
.map(
([module, names]) =>
`import type { ${[...new Set(names)].sort().join(', ')} } from ${JSON.stringify(module)}`,
)
.join(newline)
const importDeclarations = parsed.statements.filter(
typeScript.isImportDeclaration,
)
const insertionOffset = importDeclarations.length
? endOfLine(source, importDeclarations.at(-1)!.end)
: leadingDeclarationPreambleEnd(source, parsed)
return insertDeclarationSource(source, insertionOffset, importSource, newline)
}
function appendAliasedImport(
source: string,
module: string,
importedName: string,
localName: string,
): string {
const typeScript = loadTypeScript()
const parsed = parseDeclarationSource(source)
const newline = source.includes('\r\n') ? '\r\n' : '\n'
const importDeclarations = parsed.statements.filter(
typeScript.isImportDeclaration,
)
const insertionOffset = importDeclarations.length
? endOfLine(source, importDeclarations.at(-1)!.end)
: leadingDeclarationPreambleEnd(source, parsed)
return insertDeclarationSource(
source,
insertionOffset,
`import { ${importedName} as ${localName} } from ${JSON.stringify(module)}`,
newline,
)
}
export function removeNodeStreamWebTypeImports(source: string): string {
const typeScript = loadTypeScript()
const parsed = parseDeclarationSource(source)
const removals: Array<{ start: number; end: number }> = []
for (const statement of parsed.statements) {
if (
!typeScript.isImportDeclaration(statement) ||
!typeScript.isStringLiteral(statement.moduleSpecifier) ||
statement.moduleSpecifier.text !== 'node:stream/web'
) {
continue
}
const importClause = statement.importClause
const namedBindings = importClause?.namedBindings
const unaliasedNamedTypeImports =
importClause !== undefined &&
namedBindings !== undefined &&
typeScript.isNamedImports(namedBindings) &&
namedBindings.elements.length > 0 &&
namedBindings.elements.every(
(specifier) =>
(importClause.isTypeOnly || specifier.isTypeOnly) &&
specifier.propertyName === undefined,
)
if (!unaliasedNamedTypeImports) {
throw new Error(
'Threadless declaration headers may only import unaliased types from node:stream/web so DOM globals can replace them',
)
}
removals.push(
expandToDeclarationLine(
source,
statement.getStart(parsed),
statement.end,
),
)
}
let result = source
for (const removal of removals.reverse()) {
result = result.slice(0, removal.start) + result.slice(removal.end)
}
return result
}
export function rewriteUnboundNodeGlobalTypeQueries(source: string): string {
if (!source.includes('global')) {
return source
}
const typeScript = loadTypeScript()
const { program, sourceFile } = createDeclarationProgram(source)
const checker = program.getTypeChecker()
const replacements: Array<{ start: number; end: number }> = []
const visit = (node: import('typescript').Node) => {
if (
typeScript.isIdentifier(node) &&
node.text === 'global' &&
typeScript.isTypeQueryNode(node.parent) &&
node.parent.exprName === node &&
checker.resolveName(
node.text,
node,
typeScript.SymbolFlags.Value,
false,
) === undefined
) {
replacements.push({
start: node.getStart(sourceFile),
end: node.end,
})
}
typeScript.forEachChild(node, visit)
}
visit(sourceFile)
let rewritten = source
for (const replacement of replacements.reverse()) {
rewritten =
rewritten.slice(0, replacement.start) +
'globalThis' +
rewritten.slice(replacement.end)
}
return rewritten
}
function parseDeclarationSource(source: string) {
const typeScript = loadTypeScript()
const parsed = typeScript.createSourceFile(
'index.d.ts',
source,
typeScript.ScriptTarget.Latest,
true,
typeScript.ScriptKind.TS,
)
const diagnostics = (
parsed as typeof parsed & {
parseDiagnostics: readonly Diagnostic[]
}
).parseDiagnostics
if (diagnostics.length > 0) {
throw new Error(
`Failed to parse declaration source:\n${diagnostics
.slice(0, 3)
.map((diagnostic) =>
typeScript.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
)
.join('\n')}`,
)
}
return parsed
}
function hasNamedImport(
statements: NodeArray<Statement>,
typeImport: TypeImport,
): boolean {
const typeScript = loadTypeScript()
return statements.some(
(statement) =>
typeScript.isImportDeclaration(statement) &&
typeScript.isStringLiteral(statement.moduleSpecifier) &&
statement.moduleSpecifier.text === typeImport.module &&
statement.importClause?.namedBindings !== undefined &&
typeScript.isNamedImports(statement.importClause.namedBindings) &&
statement.importClause.namedBindings.elements.some(
(specifier) => specifier.name.text === typeImport.name,
),
)
}
function leadingDeclarationPreambleEnd(
source: string,
parsed: ReturnType<typeof parseDeclarationSource>,
): number {
const typeScript = loadTypeScript()
const firstStatementStart =
parsed.statements[0]?.getStart(parsed) ?? source.length
const leadingComments =
typeScript.getLeadingCommentRanges(
source,
parsed.statements[0]?.pos ?? 0,
) ?? []
let offset = 0
for (const comment of leadingComments) {
if (
comment.end > firstStatementStart ||
source.slice(offset, comment.pos).trim()
) {
break
}
if (source.startsWith('/**', comment.pos)) {
break
}
offset = endOfLine(source, comment.end)
}
return offset
}
let loadedTypeScript: TypeScriptModule | undefined
function loadTypeScript(): TypeScriptModule {
loadedTypeScript ??= require('typescript') as TypeScriptModule
return loadedTypeScript
}
function endOfLine(source: string, offset: number): number {
const newline = source.indexOf('\n', offset)
return newline === -1 ? source.length : newline + 1
}
function insertDeclarationSource(
source: string,
offset: number,
inserted: string,
newline: string,
): string {
const before = source.slice(0, offset)
const after = source.slice(offset)
const beforeSeparator =
before.length > 0 && !before.endsWith('\n') ? newline : ''
const afterSeparator =
after.length > 0 && !after.startsWith('\n') ? newline : ''
const finalNewline = after.length === 0 ? newline : ''
return `${before}${beforeSeparator}${inserted}${afterSeparator}${after}${finalNewline}`
}
function expandToDeclarationLine(
source: string,
start: number,
end: number,
): { start: number; end: number } {
const lineStart = source.lastIndexOf('\n', start - 1) + 1
const newline = source.indexOf('\n', end)
const lineEnd = newline === -1 ? source.length : newline + 1
const prefix = source.slice(lineStart, start)
const suffix = source.slice(end, newline === -1 ? source.length : newline)
return prefix.trim() || suffix.trim()
? { start, end }
: { start: lineStart, end: lineEnd }
}
async function readIntermediateTypeFile(file: string) {
const content = await readFileAsync(file, 'utf8')
const defs = content
.split('\n')
.filter(Boolean)
.map((line) => {
line = line.trim()
const parsed = JSON.parse(line) as TypeDefLine
if (parsed.js_doc) {
parsed.js_doc = parsed.js_doc.replace(/\\n/g, '\n')
}
if (parsed.def) {
parsed.def = decodeStructuralNewlines(parsed.def)
}
if (parsed.def_with_type_import_markers) {
parsed.def_with_type_import_markers = decodeStructuralNewlines(
parsed.def_with_type_import_markers,
)
}
return parsed
})
// move all `struct` def to the very top
// and order the rest alphabetically.
return defs.sort((a, b) => {
if (a.kind === TypeDefKind.Struct) {
if (b.kind === TypeDefKind.Struct) {
return a.name.localeCompare(b.name)
}
return -1
} else if (b.kind === TypeDefKind.Struct) {
return 1
} else {
return a.name.localeCompare(b.name)
}
})
}
function preprocessTypeDef(defs: TypeDefLine[]): Map<string, TypeDefLine[]> {
const namespaceGrouped = new Map<string, TypeDefLine[]>()
const classDefs = new Map<string, TypeDefLine>()
for (const def of defs) {
const namespace = def.js_mod ?? TOP_LEVEL_NAMESPACE
if (!namespaceGrouped.has(namespace)) {
namespaceGrouped.set(namespace, [])
}
const group = namespaceGrouped.get(namespace)!
const classKey = `${namespace}\0${def.name}`
if (def.kind === TypeDefKind.Struct) {
group.push(def)
classDefs.set(classKey, def)
} else if (def.kind === TypeDefKind.Extends) {
const classDef = classDefs.get(classKey)
if (classDef) {
classDef.extends = def.def
}
} else if (def.kind === TypeDefKind.Impl) {
// merge `impl` into class definition
const classDef = classDefs.get(classKey)
if (classDef) {
const asyncIterator = parseAsyncGeneratorImpl(def.def)
if (asyncIterator) {
classDef.asyncIterator = asyncIterator
} else {
if (classDef.def) {
classDef.def += '\n'
}
classDef.def += def.def
// Convert any remaining \n sequences in the merged def to actual newlines
if (classDef.def) {
classDef.def = classDef.def.replace(/\\n/g, '\n')
}
}
}
} else {
group.push(def)
}
}
return namespaceGrouped
}
export function correctStringIdent(src: string, ident: number): string {
let bracketDepth = 0
const result = src
.split('\n')
.map((line) => {
line = line.trim()
if (line === '') {
return ''
}
const isInMultilineComment = line.startsWith('*')
const isClosingBracket = line.endsWith('}')
const isOpeningBracket = line.endsWith('{')
const isTypeDeclaration = line.endsWith('=')
const isTypeVariant = line.startsWith('|')
let rightIndent = ident
if ((isOpeningBracket || isTypeDeclaration) && !isInMultilineComment) {
bracketDepth += 1
rightIndent += (bracketDepth - 1) * 2
} else {
if (
isClosingBracket &&
bracketDepth > 0 &&
!isInMultilineComment &&
!isTypeVariant
) {
bracketDepth -= 1
}
rightIndent += bracketDepth * 2
}
if (isInMultilineComment) {
rightIndent += 1
}
const s = `${' '.repeat(rightIndent)}${line}`
return s
})
.join('\n')
return result
}