517 строки
18 KiB
JavaScript
517 строки
18 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.Node} node
|
|
*/
|
|
function removeAllComments(node) {
|
|
/** @type {any} */ (ts).removeAllComments(node);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
// TODO: remove after https://github.com/microsoft/TypeScript/pull/58187 is released
|
|
if (ts.canHaveModifiers(node)) {
|
|
for (const modifier of ts.getModifiers(node) ?? []) {
|
|
if (modifier.kind === ts.SyntaxKind.PrivateKeyword) {
|
|
removeAllComments(node);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
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({ filePath: "dummy.d.ts", fileText: contents });
|
|
return result.replace(/\r\n/g, "\n");
|
|
}
|
|
|
|
fs.writeFileSync(output, dprint(publicContents));
|
|
fs.writeFileSync(internalOutput, dprint(internalContents));
|