Raw File
dtsBundler.mjs
/**
 * 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";

/** @type {(node: ts.Node) => node is ts.DeclarationStatement} */
function isDeclarationStatement(node) {
    return /** @type {any} */ (ts).isDeclarationStatement(node);
}

/** @type {(node: ts.Node) => boolean} */
function isInternalDeclaration(node) {
    return /** @type {any} */ (ts).isInternalDeclaration(node, node.getSourceFile());
}

/**
 *
 * @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 (isDeclarationStatement(node)) {
        return node;
    }
    return undefined;
}

/** @type {ts.TransformationContext} */
const nullTransformationContext = /** @type {any} */ (ts).nullTransformationContext;

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<ts.Symbol, boolean>} */
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 && !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
 * @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<string, ts.Symbol>[]} */
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
 */
function resolveAlias(symbol) {
    return typeChecker.getAliasedSymbol(symbol);
}

/**
 * @param {ts.Symbol} symbol
 * @param {boolean | undefined} [dontResolveAlias]
 */
function resolveSymbol(symbol, dontResolveAlias = undefined) {
    return !dontResolveAlias && isNonLocalAlias(symbol) ? resolveAlias(symbol) : symbol;
}

/**
 * @param {ts.Symbol} symbol
 * @returns {ts.Symbol}
 */
function getMergedSymbol(symbol) {
    return /** @type {any} */ (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.Node} node
 * @returns {boolean}
 */
function isPartOfTypeNode(node) {
    return /** @type {any} */ (ts).isPartOfTypeNode(node);
}

/**
 * @param {ts.Statement} decl
 */
function verifyMatchingSymbols(decl) {
    ts.visitEachChild(decl, /** @type {(node: ts.Node) => ts.Node} */ function visit(node) {
        if (ts.isIdentifier(node) && 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, nullTransformationContext);
    }, 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 = isInternalDeclaration(statement);
            if (!isInternal) {
                const publicStatement = ts.visitEachChild(statement, (node) => {
                    // No @internal comments in the public API.
                    if (isInternalDeclaration(node)) {
                        return undefined;
                    }
                    return removeDeclareConstExport(node);
                }, nullTransformationContext);

                writeNode(publicStatement, sourceFile, WriteTarget.Public);
            }

            const internalStatement = ts.visitEachChild(statement, removeDeclareConstExport, 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);
back to top