• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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