/** * WARNING: this is a very, very rudimentary d.ts bundler; it only works * in the TS project thanks to our history using namespaces, which has * prevented us from duplicating names across files, and allows us to * bundle as namespaces again, even though the project is modules. */ import * as dprintFormatter from "@dprint/formatter"; import * as dprintTypeScript from "@dprint/typescript"; import assert, { fail } from "assert"; import fs from "fs"; import minimist from "minimist"; import path from "path"; import ts from "typescript"; import url from "url"; const __filename = url.fileURLToPath(new URL(import.meta.url)); const __dirname = path.dirname(__filename); // /** @type {any} */ (ts).Debug.enableDebugInfo(); const dotDts = ".d.ts"; const options = minimist(process.argv.slice(2), { string: ["project", "entrypoint", "output"], }); const entrypoint = options.entrypoint; const output = options.output; assert(typeof entrypoint === "string" && entrypoint); assert(typeof output === "string" && output); assert(output.endsWith(dotDts)); const internalOutput = output.substring(0, output.length - dotDts.length) + ".internal" + dotDts; console.log(`Bundling ${entrypoint} to ${output} and ${internalOutput}`); const newLineKind = ts.NewLineKind.LineFeed; const newLine = newLineKind === ts.NewLineKind.LineFeed ? "\n" : "\r\n"; /** * @param {ts.Node} node */ function removeAllComments(node) { /** @type {any} */ (ts).removeAllComments(node); } /** * @param {ts.VariableDeclaration} node * @returns {ts.VariableStatement} */ function getParentVariableStatement(node) { const declarationList = node.parent; assert(ts.isVariableDeclarationList(declarationList), `expected VariableDeclarationList at ${nodeToLocation(node)}`); assert(declarationList.declarations.length === 1, `expected VariableDeclarationList of length 1 at ${nodeToLocation(node)}`); const variableStatement = declarationList.parent; assert(ts.isVariableStatement(variableStatement), `expected VariableStatement at ${nodeToLocation(node)}`); return variableStatement; } /** * @param {ts.Declaration} node * @returns {ts.Statement | undefined} */ function getDeclarationStatement(node) { if (ts.isVariableDeclaration(node)) { return getParentVariableStatement(node); } else if (ts.isDeclarationStatement(node)) { return node; } return undefined; } const program = ts.createProgram([entrypoint], { target: ts.ScriptTarget.ES5 }); const typeChecker = program.getTypeChecker(); const sourceFile = program.getSourceFile(entrypoint); assert(sourceFile, "Failed to load source file"); const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); assert(moduleSymbol, "Failed to get module's symbol"); /** @type {{ writeNode(hint: ts.EmitHint, node: ts.Node, sourceFile: ts.SourceFile | undefined, writer: any): void }} */ const printer = /** @type {any} */ (ts.createPrinter({ newLine: newLineKind })); /** @type {{ writeComment(s: string): void; getText(): string; clear(): void }} */ const writer = /** @type {any} */ (ts).createTextWriter("\n"); const originalWriteComment = writer.writeComment.bind(writer); writer.writeComment = s => { // Hack; undo https://github.com/microsoft/TypeScript/pull/50097 // We printNode directly, so we get all of the original source comments. // If we were using actual declaration emit instead, this wouldn't be needed. if (s.startsWith("//")) { return; } originalWriteComment(s); }; /** * @param {ts.Node} node * @param {ts.SourceFile} sourceFile */ function printNode(node, sourceFile) { printer.writeNode(ts.EmitHint.Unspecified, node, sourceFile, writer); const text = writer.getText(); writer.clear(); return text; } /** @type {string[]} */ const publicLines = []; /** @type {string[]} */ const internalLines = []; const indent = " "; let currentIndent = ""; function increaseIndent() { currentIndent += indent; } function decreaseIndent() { currentIndent = currentIndent.slice(indent.length); } /** * @enum {number} */ const WriteTarget = { Public: 1 << 0, Internal: 1 << 1, Both: (1 << 0) | (1 << 1), }; /** * @param {string} s * @param {WriteTarget} target */ function write(s, target) { if (!target) { return; } const toPush = !s ? [""] : s.split(/\r?\n/).filter(line => line).map(line => (currentIndent + line).trimEnd()); if (target & WriteTarget.Public) { publicLines.push(...toPush); } if (target & WriteTarget.Internal) { internalLines.push(...toPush); } } /** * @param {ts.Node} node * @param {ts.SourceFile} sourceFile * @param {WriteTarget} target */ function writeNode(node, sourceFile, target) { write(printNode(node, sourceFile), target); } /** @type {Map} */ const containsPublicAPICache = new Map(); /** * @param {ts.Symbol} symbol * @returns {boolean} */ function containsPublicAPI(symbol) { const cached = containsPublicAPICache.get(symbol); if (cached !== undefined) { return cached; } const result = containsPublicAPIWorker(); containsPublicAPICache.set(symbol, result); return result; function containsPublicAPIWorker() { if (!symbol.declarations?.length) { return false; } if (symbol.flags & ts.SymbolFlags.Alias) { const resolved = typeChecker.getAliasedSymbol(symbol); return containsPublicAPI(resolved); } // Namespace barrel; actual namespaces are checked below. if (symbol.flags & ts.SymbolFlags.ValueModule && symbol.valueDeclaration?.kind === ts.SyntaxKind.SourceFile) { for (const me of typeChecker.getExportsOfModule(symbol)) { if (containsPublicAPI(me)) { return true; } } return false; } for (const decl of symbol.declarations) { const statement = getDeclarationStatement(decl); if (statement && !ts.isInternalDeclaration(statement)) { return true; } } return false; } } /** * @param {ts.Node} node */ function nodeToLocation(node) { const sourceFile = node.getSourceFile(); const lc = sourceFile.getLineAndCharacterOfPosition(node.pos); return `${sourceFile.fileName}:${lc.line + 1}:${lc.character + 1}`; } /** * @param {ts.Node} node * @param {boolean} needExportModifier * @returns {ts.Node | undefined} */ function removeDeclareConstExport(node, needExportModifier) { switch (node.kind) { case ts.SyntaxKind.DeclareKeyword: // No need to emit this in d.ts files. case ts.SyntaxKind.ConstKeyword: // Remove const from const enums. return undefined; case ts.SyntaxKind.ExportKeyword: // No export modifier; we are already in the namespace. if (!needExportModifier) { return undefined; } } return node; } /** @type {{ locals: Map, exports: Map}[]} */ const scopeStack = []; /** @type {Map} */ const symbolToNamespace = new Map(); /** * @param {string} name */ function findInScope(name) { for (let i = scopeStack.length - 1; i >= 0; i--) { const scope = scopeStack[i]; const symbol = scope.exports.get(name); if (symbol) { return symbol; } } return undefined; } /** @type {(symbol: ts.Symbol | undefined, excludes?: ts.SymbolFlags) => boolean} */ function isNonLocalAlias(symbol, excludes = ts.SymbolFlags.Value | ts.SymbolFlags.Type | ts.SymbolFlags.Namespace) { if (!symbol) return false; return (symbol.flags & (ts.SymbolFlags.Alias | excludes)) === ts.SymbolFlags.Alias || !!(symbol.flags & ts.SymbolFlags.Alias && symbol.flags & ts.SymbolFlags.Assignment); } /** * @param {ts.Symbol} symbol * @param {boolean | undefined} [dontResolveAlias] */ function resolveSymbol(symbol, dontResolveAlias = undefined) { return !dontResolveAlias && isNonLocalAlias(symbol) ? typeChecker.getAliasedSymbol(symbol) : symbol; } /** * @param {ts.Symbol} symbol * @returns {ts.Symbol} */ function getMergedSymbol(symbol) { return typeChecker.getMergedSymbol(symbol); } /** * @param {ts.Symbol} s1 * @param {ts.Symbol} s2 */ function symbolsConflict(s1, s2) { // See getSymbolIfSameReference in checker.ts s1 = getMergedSymbol(resolveSymbol(getMergedSymbol(s1))); s2 = getMergedSymbol(resolveSymbol(getMergedSymbol(s2))); if (s1 === s2) { return false; } const s1Flags = s1.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.Value); const s2Flags = s2.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.Value); // If the two symbols differ by type/value space, ignore. if (!(s1Flags & s2Flags)) { return false; } return true; } /** * @param {ts.Statement} decl * @param {boolean} isInternal */ function verifyMatchingSymbols(decl, isInternal) { ts.visitEachChild(decl, /** @type {(node: ts.Node) => ts.Node} */ function visit(node) { if (ts.isIdentifier(node) && ts.isPartOfTypeNode(node)) { if (ts.isQualifiedName(node.parent) && node !== node.parent.left) { return node; } if (ts.isParameter(node.parent) && node === node.parent.name) { return node; } if (ts.isNamedTupleMember(node.parent) && node === node.parent.name) { return node; } const symbolOfNode = typeChecker.getSymbolAtLocation(node); if (!symbolOfNode) { fail(`No symbol for node at ${nodeToLocation(node)}`); } const symbolInScope = findInScope(symbolOfNode.name); if (!symbolInScope) { if (symbolOfNode.declarations?.every(d => isLocalDeclaration(d) && d.getSourceFile() === decl.getSourceFile()) && !isSelfReference(node, symbolOfNode)) { // The symbol is a local that needs to be copied into the scope. scopeStack[scopeStack.length - 1].locals.set(symbolOfNode.name, { symbol: symbolOfNode, writeTarget: isInternal ? WriteTarget.Internal : WriteTarget.Both }); } // We didn't find the symbol in scope at all. Just allow it and we'll fail at test time. return node; } if (symbolsConflict(symbolOfNode, symbolInScope)) { fail(`Declaration at ${nodeToLocation(decl)}\n references ${symbolOfNode.name} at ${symbolOfNode.declarations && nodeToLocation(symbolOfNode.declarations[0])},\n but containing scope contains a symbol with the same name declared at ${symbolInScope.declarations && nodeToLocation(symbolInScope.declarations[0])}`); } } return ts.visitEachChild(node, visit, /*context*/ undefined); }, /*context*/ undefined); } /** * @param {ts.Declaration} decl */ function isLocalDeclaration(decl) { return ts.canHaveModifiers(decl) && !ts.getModifiers(decl)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) && !!getDeclarationStatement(decl); } /** * @param {ts.Node} reference * @param {ts.Symbol} symbol */ function isSelfReference(reference, symbol) { return symbol.declarations?.every(parent => ts.findAncestor(reference, p => p === parent)); } /** * @param {string} name * @param {string} parent * @param {boolean} needExportModifier * @param {ts.Symbol} moduleSymbol */ function emitAsNamespace(name, parent, moduleSymbol, needExportModifier) { assert(moduleSymbol.flags & ts.SymbolFlags.ValueModule, "moduleSymbol is not a module"); const fullName = parent ? `${parent}.${name}` : name; scopeStack.push({ locals: new Map(), exports: new Map() }); const currentScope = scopeStack[scopeStack.length - 1]; const target = containsPublicAPI(moduleSymbol) ? WriteTarget.Both : WriteTarget.Internal; if (name === "ts") { // We will write `export = ts` at the end. assert(!needExportModifier, "ts namespace should not have an export modifier"); write(`declare namespace ${name} {`, target); } else { write(`${needExportModifier ? "export " : ""}namespace ${name} {`, target); } increaseIndent(); const moduleExports = typeChecker.getExportsOfModule(moduleSymbol); for (const me of moduleExports) { currentScope.exports.set(me.name, me); symbolToNamespace.set(me, fullName); } /** @type {[ts.Statement, ts.SourceFile, WriteTarget][]} */ const exportedStatements = []; /** @type {[name: string, fullName: string, moduleSymbol: ts.Symbol][]} */ const nestedNamespaces = []; for (const me of moduleExports) { assert(me.declarations?.length); if (me.flags & ts.SymbolFlags.Alias) { const resolved = typeChecker.getAliasedSymbol(me); if (resolved.flags & ts.SymbolFlags.ValueModule) { nestedNamespaces.push([me.name, fullName, resolved]); } else { const namespaceName = symbolToNamespace.get(resolved); assert(namespaceName, `Failed to find namespace for ${me.name} at ${nodeToLocation(me.declarations[0])}`); write(`export import ${me.name} = ${namespaceName}.${me.name}`, target); } continue; } for (const decl of me.declarations) { const statement = getDeclarationStatement(decl); const sourceFile = decl.getSourceFile(); if (!statement) { fail(`Unhandled declaration for ${me.name} at ${nodeToLocation(decl)}`); } const isInternal = ts.isInternalDeclaration(statement); if (!ts.isModuleDeclaration(decl)) { verifyMatchingSymbols(statement, isInternal); } if (!isInternal) { const publicStatement = ts.visitEachChild(statement, node => { // No @internal comments in the public API. if (ts.isInternalDeclaration(node)) { return undefined; } // TODO: remove after https://github.com/microsoft/TypeScript/pull/58187 is released if (ts.canHaveModifiers(node)) { for (const modifier of ts.getModifiers(node) ?? []) { if (modifier.kind === ts.SyntaxKind.PrivateKeyword) { removeAllComments(node); break; } } } return node; }, /*context*/ undefined); exportedStatements.push([publicStatement, sourceFile, WriteTarget.Public]); } exportedStatements.push([statement, sourceFile, WriteTarget.Internal]); } } const childrenNeedExportModifier = !!currentScope.locals.size; nestedNamespaces.forEach(namespace => emitAsNamespace(...namespace, childrenNeedExportModifier)); currentScope.locals.forEach(({ symbol, writeTarget }) => { symbol.declarations?.forEach(decl => { // We already checked that getDeclarationStatement(decl) works for each declaration. const statement = getDeclarationStatement(decl); writeNode(/** @type {ts.Statement} */ (statement), decl.getSourceFile(), writeTarget); }); }); exportedStatements.forEach(([statement, ...rest]) => { let updated = ts.visitEachChild(statement, node => removeDeclareConstExport(node, childrenNeedExportModifier), /*context*/ undefined); if (childrenNeedExportModifier && ts.canHaveModifiers(updated) && !updated.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) { updated = ts.factory.replaceModifiers( updated, [ ts.factory.createModifier(ts.SyntaxKind.ExportKeyword), .../**@type {ts.NodeArray | undefined}*/ (updated.modifiers) ?? [], ], ); } writeNode(updated, ...rest); }); scopeStack.pop(); decreaseIndent(); write(`}`, target); } emitAsNamespace("ts", "", moduleSymbol, /*needExportModifier*/ false); write("export = ts;", WriteTarget.Both); const copyrightNotice = fs.readFileSync(path.join(__dirname, "CopyrightNotice.txt"), "utf-8"); const publicContents = copyrightNotice + publicLines.join(newLine); const internalContents = copyrightNotice + internalLines.join(newLine); if (publicContents.includes("@internal")) { console.error("Output includes untrimmed @internal nodes!"); } const buffer = fs.readFileSync(dprintTypeScript.getPath()); const formatter = dprintFormatter.createFromBuffer(buffer); formatter.setConfig({ indentWidth: 4, lineWidth: 1000, newLineKind: "auto", useTabs: false, }, { quoteStyle: "preferDouble", }); /** * @param {string} contents * @returns {string} */ function dprint(contents) { const result = formatter.formatText({ filePath: "dummy.d.ts", fileText: contents }); return result.replace(/\r\n/g, "\n"); } fs.writeFileSync(output, dprint(publicContents)); fs.writeFileSync(internalOutput, dprint(internalContents));