UNPKG

veritatisdoloremque

Version:
460 lines (430 loc) 14.8 kB
/** * @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 { 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): void { const compilerOptions = {}; const host = ts.createCompilerHost(compilerOptions); const program = ts.createProgram([inputLocation], compilerOptions, host); const printer: ts.Printer = ts.createPrinter(); const sourceFile = program.getSourceFile(inputLocation)!; const result: ts.TransformationResult<ts.SourceFile> = ts.transform<ts.SourceFile>( sourceFile, [dropPrivateApiTransformer.bind(null, program, host)] ); const transformedSourceFile: ts.SourceFile = result.transformed[0]; const content = printer.printFile(transformedSourceFile); fs.writeFileSync(outputLocation, content); } 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 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 { // Check is this is a public symbol (e.g. part of the DOM library) const sourceFileNames = typeChecker .getSymbolAtLocation(name) ?.declarations?.map(d => d.getSourceFile().fileName); if (sourceFileNames?.find(s => s.indexOf('typescript/lib') != -1)) { 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 `proctected`. To generate a protected * constructor, specify `@hideconstructor proctected`. * * 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 modifier = ts.createModifier( hideConstructorTag.comment === 'protected' ? ts.SyntaxKind.ProtectedKeyword : ts.SyntaxKind.PrivateKeyword ); return ts.createConstructor( node.decorators, [modifier], /*parameters=*/ [], /* body= */ 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 additionalMembers: ts.NamedDeclaration[] = []; 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. // TODO: We really only need to do this when the type that is extended // is a class. We should skip this for interfaces. const privateType = typeChecker.getTypeAtLocation(type); additionalMembers.push( ...convertPropertiesForEnclosingClass( program, host, sourceFile, privateType.getProperties(), node ) ); } } if (exportedTypes.length > 0) { prunedHeritageClauses.push( ts.updateHeritageClause(heritageClause, exportedTypes) ); } } if (ts.isClassDeclaration(node)) { return ts.updateClassDeclaration( node, node.decorators, node.modifiers, node.name, node.typeParameters, prunedHeritageClauses, [ ...(node.members as ts.NodeArray<ts.ClassElement>), ...(additionalMembers as ts.ClassElement[]) ] ) as T; } else if (ts.isInterfaceDeclaration(node)) { return ts.updateInterfaceDeclaration( node, node.decorators, node.modifiers, node.name, node.typeParameters, prunedHeritageClauses, [ ...(node.members as ts.NodeArray<ts.TypeElement>), ...(additionalMembers as ts.TypeElement[]) ] ) as T; } else { throw new Error('Only classes or interfaces are supported'); } } /** * Iterates the provided symbols and returns named declarations for these * symbols if they are missing from `currentClass`. This allows us to merge * class hierarchies for classes whose inherited types are not part of the * public API. * * This method relies on a private API in TypeScript's `codefix` package. */ function convertPropertiesForEnclosingClass( program: ts.Program, host: ts.CompilerHost, sourceFile: ts.SourceFile, parentClassSymbols: ts.Symbol[], currentClass: ts.ClassDeclaration | ts.InterfaceDeclaration ): ts.NamedDeclaration[] { const newMembers: ts.NamedDeclaration[] = []; // 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( currentClass, parentClassSymbols, sourceFile, { program, host }, /* userPreferences= */ {}, /* importAdder= */ undefined, (c: ts.ClassElement) => newMembers.push(c) ); return newMembers; } /** * 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) { 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) { 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 dropPrivateApiTransformer( program: ts.Program, host: ts.CompilerHost, context: ts.TransformationContext ): ts.Transformer<ts.SourceFile> { const typeChecker = program.getTypeChecker(); return (sourceFile: ts.SourceFile) => { 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. if ( !node.modifiers?.find(m => m.kind === ts.SyntaxKind.ExportKeyword) ) { return ts.createToken(ts.SyntaxKind.WhitespaceTrivia); } } 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)) { return ts.createToken(ts.SyntaxKind.WhitespaceTrivia); } } 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.updateTypeReferenceNode( node, ts.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; } return visitNodeAndChildren(sourceFile); }; } 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' } }).argv; 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.')); }