TypeScript/scripts/dtsBundler.mjs

501 строка
17 KiB
JavaScript

/**
* 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.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<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 && !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<string, { symbol: ts.Symbol, writeTarget: WriteTarget }>, exports: Map<string, ts.Symbol>}[]} */
const scopeStack = [];
/** @type {Map<ts.Symbol, string>} */
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;
}
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<ts.Modifier> | 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("dummy.d.ts", contents);
return result.replace(/\r\n/g, "\n");
}
fs.writeFileSync(output, dprint(publicContents));
fs.writeFileSync(internalOutput, dprint(internalContents));