Files

773 lines
24 KiB
TypeScript

/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as yargs from 'yargs';
import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
import { ESLint } from 'eslint';
/**
* Prunes a DTS file based on three main rules:
* - Top level types are only included if they are also exported.
* - Underscore-prefixed members of class and interface types are stripped.
* - Constructors are made private or protected if marked with
* `@hideconstructor`/`@hideconstructor protected`.
*
* This function is meant to operate on DTS files generated by API extractor
* and extracts out the API that is relevant for third-party SDK consumers.
*
* @param inputLocation The file path to the .d.ts produced by API explorer.
* @param outputLocation The output location for the pruned .d.ts file.
*/
export function pruneDts(
inputLocation: string,
outputLocation: string,
otherExportFileLocations: string[] = []
): void {
const compilerOptions = {};
const host = ts.createCompilerHost(compilerOptions);
const program = ts.createProgram(
[inputLocation, ...otherExportFileLocations],
compilerOptions,
host
);
const printer: ts.Printer = ts.createPrinter();
const sourceFile = program.getSourceFile(inputLocation)!;
const otherExportSourceFiles = otherExportFileLocations
.map(otherFileLocation => program.getSourceFile(otherFileLocation))
.filter(value => value !== undefined) as ts.SourceFile[];
const result: ts.TransformationResult<ts.SourceFile> =
ts.transform<ts.SourceFile>(sourceFile, [
dropPrivateApiTransformer.bind(
null,
program,
host,
otherExportSourceFiles
)
]);
const transformedSourceFile: ts.SourceFile = result.transformed[0];
let content = printer.printFile(transformedSourceFile);
// Preserve leading license comment if updateSourceFile dropped it from statement #0
const leadingComments = ts.getLeadingCommentRanges(
sourceFile.getFullText(),
0
);
if (leadingComments && leadingComments.length > 0) {
const firstComment = sourceFile
.getFullText()
.slice(leadingComments[0].pos, leadingComments[0].end);
if (
firstComment.includes('@license') &&
!content.trimStart().startsWith('/**\n * @license') &&
!content.trimStart().startsWith('/*\n * @license')
) {
content = `${firstComment}\n` + content;
}
}
fs.writeFileSync(outputLocation, content);
}
export async function addBlankLines(outputLocation: string): Promise<void> {
const eslint = new ESLint({
fix: true,
overrideConfig: {
parserOptions: {
ecmaVersion: 2017,
sourceType: 'module',
tsconfigRootDir: __dirname,
project: ['./tsconfig.eslint.json']
},
env: {
es6: true
},
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
rules: {
'unused-imports/no-unused-imports-ts': ['off'],
// add blank lines after imports. Otherwise removeUnusedImports() will remove the comment
// of the first item after the import block
'padding-line-between-statements': [
'error',
{ 'blankLine': 'always', 'prev': 'import', 'next': '*' }
]
}
}
});
const results = await eslint.lintFiles(outputLocation);
await ESLint.outputFixes(results);
}
export async function removeUnusedImports(
outputLocation: string
): Promise<void> {
const eslint = new ESLint({
fix: true,
overrideConfig: {
parserOptions: {
ecmaVersion: 2017,
sourceType: 'module',
tsconfigRootDir: __dirname,
project: ['./tsconfig.eslint.json']
},
env: {
es6: true
},
plugins: ['unused-imports', '@typescript-eslint'],
parser: '@typescript-eslint/parser',
rules: {
'unused-imports/no-unused-imports-ts': ['error']
}
}
});
const results = await eslint.lintFiles(outputLocation);
await ESLint.outputFixes(results);
}
/** Determines whether the provided identifier should be hidden. */
function hasPrivatePrefix(name: ts.Identifier): boolean {
// Identifiers that are prefixed with an underscore are not included in
// the public API.
return !!name.escapedText?.toString().startsWith('_');
}
/** Returns whether type identified by `name` is exported. */
function isExported(
typeChecker: ts.TypeChecker,
sourceFile: ts.SourceFile,
name: ts.Identifier
): boolean {
const declarations =
typeChecker.getSymbolAtLocation(name)?.declarations ?? [];
// Check is this is a public symbol (e.g. part of the DOM library)
const isTypeScriptType = declarations.find(
d => d.getSourceFile().fileName.indexOf('typescript/lib') != -1
);
const isImported = declarations.find(d => ts.isImportSpecifier(d));
if (isTypeScriptType || isImported) {
return true;
}
// Check is this is part of the exported symbols of the SDK module
const allExportedSymbols = typeChecker.getExportsOfModule(
typeChecker.getSymbolAtLocation(sourceFile)!
);
return !!allExportedSymbols.find(s => s.name === name.escapedText);
}
/**
* Replaces an existing constructor implementation if the constructor is marked
* with the JSDod tag `@hideconstructor`. The replaced constructor can either
* have `private` visibility` or `protected`. To generate a protected
* constructor, specify `@hideconstructor protected`.
*
* Returns either the modified constructor or the existing constructor if no
* modification was needed.
*/
function maybeHideConstructor(
node: ts.ConstructorDeclaration
): ts.ConstructorDeclaration {
const hideConstructorTag = ts
.getJSDocTags(node)
?.find(t => t.tagName.escapedText === 'hideconstructor');
if (hideConstructorTag) {
const hideConstructorModifier = ts.factory.createModifier(
hideConstructorTag.comment === 'protected'
? ts.SyntaxKind.ProtectedKeyword
: ts.SyntaxKind.PrivateKeyword
);
return ts.factory.createConstructorDeclaration(
[...(ts.getModifiers(node) ?? []), hideConstructorModifier],
[],
undefined
);
} else {
return node;
}
}
/**
* Examines `extends` and `implements` clauses and removes or replaces them if
* they refer to a non-exported type. When an export is removed, all members
* from the removed class are merged into the provided class or interface
* declaration.
*
* @example
* Input:
* class Foo {
* foo: string;
* }
* export class Bar extends Foo {}
*
* Output:
* export class Bar {
* foo: string;
* }
*/
function prunePrivateImports<
T extends ts.InterfaceDeclaration | ts.ClassDeclaration
>(
program: ts.Program,
host: ts.CompilerHost,
sourceFile: ts.SourceFile,
node: T
): T {
const typeChecker = program.getTypeChecker();
// The list of heritage clauses after all private symbols are removed.
const prunedHeritageClauses: ts.HeritageClause[] = [];
// Additional members that are copied from the private symbols into the public
// symbols
const inheritedInterfaceProperties: ts.TypeElement[] = [];
const inheritedClassMembers: ts.ClassElement[] = [];
for (const heritageClause of node.heritageClauses || []) {
const exportedTypes: ts.ExpressionWithTypeArguments[] = [];
for (const type of heritageClause.types) {
if (
ts.isIdentifier(type.expression) &&
isExported(typeChecker, sourceFile, type.expression)
) {
exportedTypes.push(type);
} else {
// Hide the type we are inheriting from and merge its declarations
// into the current class.
const privateType = typeChecker.getTypeAtLocation(type);
if (ts.isClassDeclaration(node)) {
inheritedClassMembers.push(
...convertPropertiesForEnclosingDeclaration<
ts.ClassDeclaration,
ts.ClassElement
>(program, host, sourceFile, privateType.getProperties(), node)
);
} else if (ts.isInterfaceDeclaration(node)) {
inheritedInterfaceProperties.push(
...convertPropertiesForEnclosingDeclaration<
ts.InterfaceDeclaration,
ts.TypeElement
>(program, host, sourceFile, privateType.getProperties(), node)
);
}
}
}
if (exportedTypes.length > 0) {
prunedHeritageClauses.push(
ts.factory.updateHeritageClause(heritageClause, exportedTypes)
);
}
}
if (ts.isClassDeclaration(node)) {
const modifiersAndDecorators = [
...(ts.getModifiers(node) ?? []),
...(ts.getDecorators(node) ?? [])
];
return ts.factory.updateClassDeclaration(
node,
modifiersAndDecorators,
node.name,
node.typeParameters,
prunedHeritageClauses,
[...node.members, ...inheritedClassMembers]
) as T;
} else if (ts.isInterfaceDeclaration(node)) {
return ts.factory.updateInterfaceDeclaration(
node,
node.modifiers,
node.name,
node.typeParameters,
prunedHeritageClauses,
[...node.members, ...inheritedInterfaceProperties]
) as T;
} else {
throw new Error('Only classes or interfaces are supported');
}
}
/**
* Iterates over the provided symbols and returns named declarations for these
* symbols if they are missing from `currentInterface`. This allows us to merge
* interface hierarchies for interfaces whose inherited properties are not part of the
* public API.
*
* This method relies on a private API in TypeScript's `codefix` package.
*/
function convertPropertiesForEnclosingDeclaration<
T extends ts.ClassDeclaration | ts.InterfaceDeclaration,
U extends ts.ClassElement | ts.TypeElement
>(
program: ts.Program,
host: ts.CompilerHost,
sourceFile: ts.SourceFile,
parentClassSymbols: ts.Symbol[],
currentDeclaration: T
): U[] {
const newMembers: U[] = [];
// The `codefix` package is not public but it does exactly what we want. It's
// the same package that is used by VSCode to fill in missing members, which
// is what we are using it for in this script. `codefix` handles missing
// properties, methods and correctly deduces generics.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ts as any).codefix.createMissingMemberNodes(
currentDeclaration,
parentClassSymbols,
sourceFile,
{ program, host },
/* userPreferences= */ {},
/* importAdder= */ undefined,
(missingMember: U) => {
const originalSymbol = parentClassSymbols.find(
symbol =>
symbol.escapedName ==
(missingMember.name as ts.Identifier).escapedText
);
if (originalSymbol) {
const jsDocComment = extractJSDocComment(originalSymbol, newMembers);
if (jsDocComment) {
ts.setSyntheticLeadingComments(missingMember, [
{
kind: ts.SyntaxKind.MultiLineCommentTrivia,
text: `*\n${jsDocComment}\n`,
hasTrailingNewLine: true,
pos: -1,
end: -1
}
]);
}
newMembers.push(missingMember);
}
}
);
return newMembers;
}
/** Extracts the JSDoc comment from `symbol`. */
function extractJSDocComment(
symbol: ts.Symbol,
alreadyAddedMembers: ts.Node[]
): string | null {
const overloadCount = alreadyAddedMembers.filter(
node =>
ts.isClassElement(node) &&
(node.name as ts.Identifier).escapedText == symbol.name
).length;
// Extract the comment from the overload that we are currently processing.
let targetIndex = 0;
const commentParts = symbol
.getDocumentationComment(undefined)
.filter(symbol => {
// Overload comments are separated by line breaks.
if (symbol.kind == 'lineBreak') {
++targetIndex;
return false;
} else {
return overloadCount == targetIndex;
}
});
const comment = commentParts
.map(commentPart => {
if (commentPart.kind === 'linkText') {
/**
* Some links will be spread across more than one line. For example:
* {@link
* DocumentReference}
* In this case, we want to remove the leading ' * ' without removing the newline.
*/
const leadingStar = /(?!\n)\s*\* /;
/**
* For some reason, this text will also have a trailing space (the above example would give
* us 'DocumentReference '), so we trim that from the end of the string.
*/
const trailingSpaces = / *$/;
return commentPart.text
.replace(leadingStar, '\n')
.replace(trailingSpaces, '');
} else {
return commentPart.text;
}
})
.join('');
if (!comment || !symbol.declarations) {
return null;
}
const declaration = symbol.declarations[overloadCount];
const jsDocTags = ts
.getJSDocTags(declaration)
.map(tag =>
tag
.getFullText()
.split('\n')
.map(line => line.replace('*', '').trim())
.filter(line => line !== '') // Remove empty lines
.map(text => text.replace(/s*\* /, '')) // Remove the '*' prefix from all lines
.join('\n')
)
.join('\n');
const maybeNewline = jsDocTags?.length > 0 ? '\n' : '';
const commentAndTags = `${comment}\n${maybeNewline}${jsDocTags}`.trim();
const formattedJSDocComment = commentAndTags
.split('\n')
.map(line => ` * ${line.trim()}`)
.join('\n');
return formattedJSDocComment;
}
/**
* Replaces input types of public APIs that consume non-exported types, which
* allows us to exclude private types from the pruned definitions. Returns the
* the name of the exported API or undefined if no type is found.
*
* @example
* Input:
* class PrivateFoo {}
* export class PublicFoo extends PrivateFoo {}
* export function doFoo(foo: PrivateFoo);
*
* Output:
* export class PublicFoo {}
* export function doFoo(foo: PublicFoo);
*/
function extractExportedSymbol(
typeChecker: ts.TypeChecker,
sourceFile: ts.SourceFile,
typeName: ts.Node
): ts.Symbol | undefined {
if (!ts.isIdentifier(typeName)) {
return undefined;
}
if (isExported(typeChecker, sourceFile, typeName)) {
// Don't replace the type reference if the type is already part of the
// public API.
return undefined;
}
const localSymbolName = typeName.escapedText;
const allExportedSymbols = typeChecker.getExportsOfModule(
typeChecker.getSymbolAtLocation(sourceFile)!
);
// Examine all exported types and check if they extend or implement the
// provided local type. If so, we can use the exported type in lieu of the
// private type.
// Short circuit if the local types is already part of the public types.
for (const symbol of allExportedSymbols) {
if (symbol.name === localSymbolName) {
return symbol;
}
}
// See if there is an exported symbol that extends this private symbol.
// In this case, we can safely use the public symbol instead.
for (const symbol of allExportedSymbols) {
if (symbol.declarations) {
for (const declaration of symbol.declarations) {
if (
ts.isClassDeclaration(declaration) ||
ts.isInterfaceDeclaration(declaration)
) {
for (const heritageClause of declaration.heritageClauses || []) {
for (const type of heritageClause.types || []) {
if (ts.isIdentifier(type.expression)) {
const subclassName = type.expression.escapedText;
if (subclassName === localSymbolName) {
// TODO: We may need to change this to return a Union type if
// more than one public type corresponds to the private type.
return symbol;
}
}
}
}
}
}
}
}
// If no symbol was found that extends the private symbol, check the reverse.
// We might find an exported symbol in the inheritance chain of the local
// symbol. Note that this is not always safe as we might replace the local
// symbol with a less restrictive type.
const localSymbol = typeChecker.getSymbolAtLocation(typeName);
if (localSymbol?.declarations) {
for (const declaration of localSymbol.declarations) {
if (
ts.isClassDeclaration(declaration) ||
ts.isInterfaceDeclaration(declaration)
) {
for (const heritageClause of declaration.heritageClauses || []) {
for (const type of heritageClause.types || []) {
if (ts.isIdentifier(type.expression)) {
if (isExported(typeChecker, sourceFile, type.expression)) {
return typeChecker.getSymbolAtLocation(type.expression);
}
}
}
}
}
}
}
return undefined;
}
function findExternalExport(
typeChecker: ts.TypeChecker,
sourceFile: ts.SourceFile,
node:
| ts.InterfaceDeclaration
| ts.ClassDeclaration
| ts.TypeAliasDeclaration
| ts.EnumDeclaration,
otherExportSourceFiles: ts.SourceFile[]
): ts.SourceFile | undefined {
if (!node.name) return undefined;
const localSymbolName = node.name.text;
for (const otherExportSourceFile of otherExportSourceFiles) {
const otherExportedSymbols = typeChecker.getExportsOfModule(
typeChecker.getSymbolAtLocation(otherExportSourceFile)!
);
for (const symbol of otherExportedSymbols) {
// TODO: ideally this would compare definitions to handle the case
// of name collisions with different definitions. However, this
// implementation currently does not handle function exports,
// which is the only place we expect name collisions.
if (symbol.name === localSymbolName) {
return otherExportSourceFile;
}
}
}
return undefined;
}
function dropPrivateApiTransformer(
program: ts.Program,
host: ts.CompilerHost,
otherExportSourceFiles: ts.SourceFile[],
context: ts.TransformationContext
): ts.Transformer<ts.SourceFile> {
const typeChecker = program.getTypeChecker();
return (sourceFile: ts.SourceFile) => {
const imports: Record<string, Array<string>> = {};
// Get exported symbols
const directExportedSymbols = typeChecker.getExportsOfModule(
typeChecker.getSymbolAtLocation(sourceFile)!
);
// Map exported symbols to aliases.
// For the statement `export { X as Y };`, this list would contain a symbol
// for `X`.
const aliasedExportedSymbols = directExportedSymbols
.map(symbol =>
symbol.flags & ts.SymbolFlags.Alias
? typeChecker.getAliasedSymbol(symbol)
: undefined
)
.filter(symbol => symbol !== undefined);
function ensureImportsForFile(filename: string): Array<string> {
let importsForFile = imports[filename];
if (!importsForFile) {
importsForFile = [];
imports[filename] = importsForFile;
}
return importsForFile;
}
function visit(node: ts.Node): ts.Node {
if (
ts.isInterfaceDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isFunctionDeclaration(node) ||
ts.isVariableStatement(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isModuleDeclaration(node) ||
ts.isEnumDeclaration(node)
) {
// Remove any types that are not exported.
// First we check the modifiers for the symbol `export function X`. If
// the export keyword is found, the symbol is modified.
// Second we check if the symbol has an alias that is exported elsewhere,
// for example: `function X; export { X as Y }`. If the alias is
// exported elsewhere, then we also have to keep the symbol.
if (
!ts
.getModifiers(node)
?.find(m => m.kind === ts.SyntaxKind.ExportKeyword)
) {
// Try to get a symbol for this node.
const symbol =
'name' in node && node.name
? typeChecker.getSymbolAtLocation(node.name)
: undefined;
// Check if that symbol is in the list of aliased exported symbols.
// If it is, we keep the symbol. Otherwise, we remove the symbol.
if (!symbol || !aliasedExportedSymbols.includes(symbol)) {
// NO-OP block to keep the condition readable
return ts.factory.createNotEmittedStatement(node);
}
}
}
if (
ts.isInterfaceDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isEnumDeclaration(node)
) {
// Remove any types that are exported externally
const externalExportFile = findExternalExport(
typeChecker,
sourceFile,
node,
otherExportSourceFiles
);
if (externalExportFile && node.name) {
ensureImportsForFile(
path.relative(
path.dirname(sourceFile.fileName),
externalExportFile.fileName
)
).push(node.name.text);
return ts.factory.createNotEmittedStatement(node);
}
}
if (ts.isConstructorDeclaration(node)) {
// Replace internal constructors with private constructors.
return maybeHideConstructor(node);
} else if (
ts.isClassDeclaration(node) ||
ts.isInterfaceDeclaration(node)
) {
// Remove any imports that reference internal APIs, while retaining
// their public members.
return prunePrivateImports(program, host, sourceFile, node);
} else if (
ts.isPropertyDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isGetAccessor(node)
) {
// Remove any class and interface members that are prefixed with
// underscores.
if (hasPrivatePrefix(node.name as ts.Identifier)) {
const notEmittedStatement =
ts.factory.createNotEmittedStatement(node);
return notEmittedStatement;
}
} else if (ts.isTypeReferenceNode(node)) {
// For public types that refer internal types, find a public type that
// we can refer to instead.
const publicName = extractExportedSymbol(
typeChecker,
sourceFile,
node.typeName
);
return publicName
? ts.factory.updateTypeReferenceNode(
node,
ts.factory.createIdentifier(publicName.name),
node.typeArguments
)
: node;
}
return node;
}
function visitNodeAndChildren<T extends ts.Node>(node: T): T {
return ts.visitEachChild(
visit(node),
childNode => visitNodeAndChildren(childNode),
context
) as T;
}
const result = visitNodeAndChildren(sourceFile);
const moreImports: ts.ImportDeclaration[] = [];
for (let filename in imports) {
const importSpecifiers: ts.ImportSpecifier[] = [];
for (let identifier of imports[filename]) {
importSpecifiers.push(
ts.factory.createImportSpecifier(
false,
undefined,
ts.factory.createIdentifier(identifier)
)
);
}
let outFileName = filename.startsWith('.') ? filename : `./${filename}`;
outFileName = outFileName.replace('.d.ts', '');
const importDeclaration = ts.factory.createImportDeclaration(
[],
ts.factory.createImportClause(
true,
undefined,
ts.factory.createNamedImports(importSpecifiers)
),
ts.factory.createStringLiteral(outFileName, true)
);
moreImports.push(importDeclaration);
}
return ts.factory.updateSourceFile(
result,
[...moreImports, ...result.statements],
true
);
};
}
const argv = yargs
.options({
input: {
type: 'string',
desc: 'The location of the index.ts file'
},
output: {
type: 'string',
desc: 'The location for the index.d.ts file'
}
})
.parseSync();
if (argv.input && argv.output) {
console.log('Removing private exports...');
pruneDts(argv.input, argv.output);
console.log('Removing unused imports...');
removeUnusedImports(argv.output).then(() => console.log('Done.'));
}