1/** 2 * WARNING: this is a very, very rudimentary d.ts bundler; it only works 3 * in the TS project thanks to our history using namespaces, which has 4 * prevented us from duplicating names across files, and allows us to 5 * bundle as namespaces again, even though the project is modules. 6 */ 7 8import fs from "fs"; 9import path from "path"; 10import minimist from "minimist"; 11import url from "url"; 12import ts from "../lib/typescript.js"; 13import assert, { fail } from "assert"; 14 15const __filename = url.fileURLToPath(new URL(import.meta.url)); 16const __dirname = path.dirname(__filename); 17 18// /** @type {any} */ (ts).Debug.enableDebugInfo(); 19 20const dotDts = ".d.ts"; 21 22const options = minimist(process.argv.slice(2), { 23 string: ["project", "entrypoint", "output"], 24}); 25 26const entrypoint = options.entrypoint; 27const output = options.output; 28 29assert(typeof entrypoint === "string" && entrypoint); 30assert(typeof output === "string" && output); 31assert(output.endsWith(dotDts)); 32 33const internalOutput = output.substring(0, output.length - dotDts.length) + ".internal" + dotDts; 34 35console.log(`Bundling ${entrypoint} to ${output} and ${internalOutput}`); 36 37const newLineKind = ts.NewLineKind.LineFeed; 38const newLine = newLineKind === ts.NewLineKind.LineFeed ? "\n" : "\r\n"; 39 40/** 41 * 42 * @param {ts.VariableDeclaration} node 43 * @returns {ts.VariableStatement} 44 */ 45function getParentVariableStatement(node) { 46 const declarationList = node.parent; 47 assert(ts.isVariableDeclarationList(declarationList), `expected VariableDeclarationList at ${nodeToLocation(node)}`); 48 assert(declarationList.declarations.length === 1, `expected VariableDeclarationList of length 1 at ${nodeToLocation(node)}`); 49 const variableStatement = declarationList.parent; 50 assert(ts.isVariableStatement(variableStatement), `expected VariableStatement at ${nodeToLocation(node)}`); 51 return variableStatement; 52} 53 54/** 55 * 56 * @param {ts.Declaration} node 57 * @returns {ts.Statement | undefined} 58 */ 59function getDeclarationStatement(node) { 60 if (ts.isVariableDeclaration(node)) { 61 return getParentVariableStatement(node); 62 } 63 else if (ts.isDeclarationStatement(node)) { 64 return node; 65 } 66 return undefined; 67} 68 69const program = ts.createProgram([entrypoint], { target: ts.ScriptTarget.ES5 }); 70 71const typeChecker = program.getTypeChecker(); 72 73const sourceFile = program.getSourceFile(entrypoint); 74assert(sourceFile, "Failed to load source file"); 75const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); 76assert(moduleSymbol, "Failed to get module's symbol"); 77 78const printer = ts.createPrinter({ newLine: newLineKind }); 79 80/** @type {string[]} */ 81const publicLines = []; 82/** @type {string[]} */ 83const internalLines = []; 84 85const indent = " "; 86let currentIndent = ""; 87 88function increaseIndent() { 89 currentIndent += indent; 90} 91 92function decreaseIndent() { 93 currentIndent = currentIndent.slice(indent.length); 94} 95 96/** 97 * @enum {number} 98 */ 99const WriteTarget = { 100 Public: 1 << 0, 101 Internal: 1 << 1, 102 Both: (1 << 0) | (1 << 1), 103}; 104 105/** 106 * @param {string} s 107 * @param {WriteTarget} target 108 */ 109function write(s, target) { 110 if (!target) { 111 return; 112 } 113 114 const toPush = !s ? [""] : s.split(/\r?\n/).filter(line => line).map(line => (currentIndent + line).trimEnd()); 115 116 if (target & WriteTarget.Public) { 117 publicLines.push(...toPush); 118 } 119 if (target & WriteTarget.Internal) { 120 internalLines.push(...toPush); 121 } 122} 123 124/** 125 * @param {ts.Node} node 126 * @param {ts.SourceFile} sourceFile 127 * @param {WriteTarget} target 128 */ 129function writeNode(node, sourceFile, target) { 130 write(printer.printNode(ts.EmitHint.Unspecified, node, sourceFile), target); 131} 132 133/** @type {Map<ts.Symbol, boolean>} */ 134const containsPublicAPICache = new Map(); 135 136/** 137 * @param {ts.Symbol} symbol 138 * @returns {boolean} 139 */ 140function containsPublicAPI(symbol) { 141 const cached = containsPublicAPICache.get(symbol); 142 if (cached !== undefined) { 143 return cached; 144 } 145 146 const result = containsPublicAPIWorker(); 147 containsPublicAPICache.set(symbol, result); 148 return result; 149 150 function containsPublicAPIWorker() { 151 if (!symbol.declarations?.length) { 152 return false; 153 } 154 155 if (symbol.flags & ts.SymbolFlags.Alias) { 156 const resolved = typeChecker.getAliasedSymbol(symbol); 157 return containsPublicAPI(resolved); 158 } 159 160 // Namespace barrel; actual namespaces are checked below. 161 if (symbol.flags & ts.SymbolFlags.ValueModule && symbol.valueDeclaration?.kind === ts.SyntaxKind.SourceFile) { 162 for (const me of typeChecker.getExportsOfModule(symbol)) { 163 if (containsPublicAPI(me)) { 164 return true; 165 } 166 } 167 return false; 168 } 169 170 for (const decl of symbol.declarations) { 171 const statement = getDeclarationStatement(decl); 172 if (statement && !ts.isInternalDeclaration(statement, statement.getSourceFile())) { 173 return true; 174 } 175 } 176 177 return false; 178 } 179} 180 181/** 182 * @param {ts.Node} node 183 */ 184function nodeToLocation(node) { 185 const sourceFile = node.getSourceFile(); 186 const lc = sourceFile.getLineAndCharacterOfPosition(node.pos); 187 return `${sourceFile.fileName}:${lc.line+1}:${lc.character+1}`; 188} 189 190/** 191 * @param {ts.Node} node 192 * @returns {ts.Node | undefined} 193 */ 194function removeDeclareConstExport(node) { 195 switch (node.kind) { 196 case ts.SyntaxKind.DeclareKeyword: // No need to emit this in d.ts files. 197 case ts.SyntaxKind.ConstKeyword: // Remove const from const enums. 198 case ts.SyntaxKind.ExportKeyword: // No export modifier; we are already in the namespace. 199 return undefined; 200 } 201 return node; 202} 203 204/** @type {Map<string, ts.Symbol>[]} */ 205const scopeStack = []; 206 207/** 208 * @param {string} name 209 */ 210function findInScope(name) { 211 for (let i = scopeStack.length-1; i >= 0; i--) { 212 const scope = scopeStack[i]; 213 const symbol = scope.get(name); 214 if (symbol) { 215 return symbol; 216 } 217 } 218 return undefined; 219} 220 221/** @type {(symbol: ts.Symbol | undefined, excludes?: ts.SymbolFlags) => boolean} */ 222function isNonLocalAlias(symbol, excludes = ts.SymbolFlags.Value | ts.SymbolFlags.Type | ts.SymbolFlags.Namespace) { 223 if (!symbol) return false; 224 return (symbol.flags & (ts.SymbolFlags.Alias | excludes)) === ts.SymbolFlags.Alias || !!(symbol.flags & ts.SymbolFlags.Alias && symbol.flags & ts.SymbolFlags.Assignment); 225} 226 227/** 228 * @param {ts.Symbol} symbol 229 * @param {boolean | undefined} [dontResolveAlias] 230 */ 231function resolveSymbol(symbol, dontResolveAlias = undefined) { 232 return !dontResolveAlias && isNonLocalAlias(symbol) ? typeChecker.getAliasedSymbol(symbol) : symbol; 233} 234 235/** 236 * @param {ts.Symbol} symbol 237 * @returns {ts.Symbol} 238 */ 239function getMergedSymbol(symbol) { 240 return typeChecker.getMergedSymbol(symbol); 241} 242 243/** 244 * @param {ts.Symbol} s1 245 * @param {ts.Symbol} s2 246 */ 247function symbolsConflict(s1, s2) { 248 // See getSymbolIfSameReference in checker.ts 249 s1 = getMergedSymbol(resolveSymbol(getMergedSymbol(s1))); 250 s2 = getMergedSymbol(resolveSymbol(getMergedSymbol(s2))); 251 if (s1 === s2) { 252 return false; 253 } 254 255 const s1Flags = s1.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.Value); 256 const s2Flags = s2.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.Value); 257 258 // If the two symbols differ by type/value space, ignore. 259 if (!(s1Flags & s2Flags)) { 260 return false; 261 } 262 263 return true; 264} 265 266/** 267 * @param {ts.Statement} decl 268 */ 269function verifyMatchingSymbols(decl) { 270 ts.visitEachChild(decl, /** @type {(node: ts.Node) => ts.Node} */ function visit(node) { 271 if (ts.isIdentifier(node) && ts.isPartOfTypeNode(node)) { 272 if (ts.isQualifiedName(node.parent) && node !== node.parent.left) { 273 return node; 274 } 275 if (ts.isParameter(node.parent) && node === node.parent.name) { 276 return node; 277 } 278 if (ts.isNamedTupleMember(node.parent) && node === node.parent.name) { 279 return node; 280 } 281 282 const symbolOfNode = typeChecker.getSymbolAtLocation(node); 283 if (!symbolOfNode) { 284 fail(`No symbol for node at ${nodeToLocation(node)}`); 285 } 286 const symbolInScope = findInScope(symbolOfNode.name); 287 if (!symbolInScope) { 288 // We didn't find the symbol in scope at all. Just allow it and we'll fail at test time. 289 return node; 290 } 291 292 if (symbolsConflict(symbolOfNode, symbolInScope)) { 293 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])}`); 294 } 295 } 296 297 return ts.visitEachChild(node, visit, ts.nullTransformationContext); 298 }, ts.nullTransformationContext); 299} 300 301/** 302 * @param {string} name 303 * @param {ts.Symbol} moduleSymbol 304 */ 305function emitAsNamespace(name, moduleSymbol) { 306 assert(moduleSymbol.flags & ts.SymbolFlags.ValueModule, "moduleSymbol is not a module"); 307 308 scopeStack.push(new Map()); 309 const currentScope = scopeStack[scopeStack.length-1]; 310 311 const target = containsPublicAPI(moduleSymbol) ? WriteTarget.Both : WriteTarget.Internal; 312 313 if (name === "ts") { 314 // We will write `export = ts` at the end. 315 write(`declare namespace ${name} {`, target); 316 } 317 else { 318 // No export modifier; we are already in the namespace. 319 write(`namespace ${name} {`, target); 320 } 321 increaseIndent(); 322 323 const moduleExports = typeChecker.getExportsOfModule(moduleSymbol); 324 for (const me of moduleExports) { 325 currentScope.set(me.name, me); 326 } 327 328 for (const me of moduleExports) { 329 assert(me.declarations?.length); 330 331 if (me.flags & ts.SymbolFlags.Alias) { 332 const resolved = typeChecker.getAliasedSymbol(me); 333 emitAsNamespace(me.name, resolved); 334 continue; 335 } 336 337 for (const decl of me.declarations) { 338 const statement = getDeclarationStatement(decl); 339 const sourceFile = decl.getSourceFile(); 340 341 if (!statement) { 342 fail(`Unhandled declaration for ${me.name} at ${nodeToLocation(decl)}`); 343 } 344 345 verifyMatchingSymbols(statement); 346 347 const isInternal = ts.isInternalDeclaration(statement, statement.getSourceFile()); 348 if (!isInternal) { 349 const publicStatement = ts.visitEachChild(statement, (node) => { 350 // No @internal comments in the public API. 351 if (ts.isInternalDeclaration(node, node.getSourceFile())) { 352 return undefined; 353 } 354 return removeDeclareConstExport(node); 355 }, ts.nullTransformationContext); 356 357 writeNode(publicStatement, sourceFile, WriteTarget.Public); 358 } 359 360 const internalStatement = ts.visitEachChild(statement, removeDeclareConstExport, ts.nullTransformationContext); 361 362 writeNode(internalStatement, sourceFile, WriteTarget.Internal); 363 } 364 } 365 366 scopeStack.pop(); 367 368 decreaseIndent(); 369 write(`}`, target); 370} 371 372emitAsNamespace("ts", moduleSymbol); 373 374write("export = ts;", WriteTarget.Both); 375 376const copyrightNotice = fs.readFileSync(path.join(__dirname, "..", "CopyrightNotice.txt"), "utf-8"); 377const publicContents = copyrightNotice + publicLines.join(newLine); 378const internalContents = copyrightNotice + internalLines.join(newLine); 379 380if (publicContents.includes("@internal")) { 381 console.error("Output includes untrimmed @internal nodes!"); 382} 383 384fs.writeFileSync(output, publicContents); 385fs.writeFileSync(internalOutput, internalContents); 386