/** * 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 fs from "fs"; import path from "path"; import minimist from "minimist"; import url from "url"; import ts from "../lib/typescript.js"; import assert, { fail } from "assert"; 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.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"); const printer = ts.createPrinter({ newLine: newLineKind }); /** @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(printer.printNode(ts.EmitHint.Unspecified, 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, statement.getSourceFile())) { 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 * @returns {ts.Node | undefined} */ function removeDeclareConstExport(node) { 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. case ts.SyntaxKind.ExportKeyword: // No export modifier; we are already in the namespace. return undefined; } return node; } /** @type {Map[]} */ const scopeStack = []; /** * @param {string} name */ function findInScope(name) { for (let i = scopeStack.length-1; i >= 0; i--) { const scope = scopeStack[i]; const symbol = scope.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 */ function verifyMatchingSymbols(decl) { 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) { // 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, ts.nullTransformationContext); }, ts.nullTransformationContext); } /** * @param {string} name * @param {ts.Symbol} moduleSymbol */ function emitAsNamespace(name, moduleSymbol) { assert(moduleSymbol.flags & ts.SymbolFlags.ValueModule, "moduleSymbol is not a module"); scopeStack.push(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. write(`declare namespace ${name} {`, target); } else { // No export modifier; we are already in the namespace. write(`namespace ${name} {`, target); } increaseIndent(); const moduleExports = typeChecker.getExportsOfModule(moduleSymbol); for (const me of moduleExports) { currentScope.set(me.name, me); } for (const me of moduleExports) { assert(me.declarations?.length); if (me.flags & ts.SymbolFlags.Alias) { const resolved = typeChecker.getAliasedSymbol(me); emitAsNamespace(me.name, resolved); 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)}`); } verifyMatchingSymbols(statement); const isInternal = ts.isInternalDeclaration(statement, statement.getSourceFile()); if (!isInternal) { const publicStatement = ts.visitEachChild(statement, (node) => { // No @internal comments in the public API. if (ts.isInternalDeclaration(node, node.getSourceFile())) { return undefined; } return removeDeclareConstExport(node); }, ts.nullTransformationContext); writeNode(publicStatement, sourceFile, WriteTarget.Public); } const internalStatement = ts.visitEachChild(statement, removeDeclareConstExport, ts.nullTransformationContext); writeNode(internalStatement, sourceFile, WriteTarget.Internal); } } scopeStack.pop(); decreaseIndent(); write(`}`, target); } emitAsNamespace("ts", moduleSymbol); 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!"); } fs.writeFileSync(output, publicContents); fs.writeFileSync(internalOutput, internalContents);