UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

1,467 lines (1,368 loc) 43.3 kB
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 }