Skip to content

Commit

Permalink
Fixed incorrect handling removing import() from types in case of usag…
Browse files Browse the repository at this point in the history
…e baseUrl

- Used `resolveReferencedModule` helper to check whether a referenced module is external or internal
- Refactored generating output and now we're using compiler's API to deal with modifiers
  • Loading branch information
timocov committed Oct 2, 2022
1 parent 9444310 commit fea202f
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 124 deletions.
46 changes: 21 additions & 25 deletions src/bundle-generator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as ts from 'typescript';
import * as path from 'path';
import * as fs from 'fs';

import { compileDts } from './compile-dts';
import { TypesUsageEvaluator } from './types-usage-evaluator';
Expand Down Expand Up @@ -139,9 +138,7 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options:
const { program, rootFilesRemapping } = compileDts(entries.map((entry: EntryPointConfig) => entry.filePath), options.preferredConfigPath, options.followSymlinks);
const typeChecker = program.getTypeChecker();

const compilerOptions = program.getCompilerOptions();
const typeRoots = ts.getEffectiveTypeRoots(compilerOptions, {});
const baseUrl = compilerOptions.baseUrl;
const typeRoots = ts.getEffectiveTypeRoots(program.getCompilerOptions(), {});

const sourceFiles = program.getSourceFiles().filter((file: ts.SourceFile) => {
return !program.isSourceFileDefaultLibrary(file);
Expand Down Expand Up @@ -232,8 +229,17 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options:

return leftSymbols.some((leftSymbol: ts.Symbol) => rightSymbols.includes(leftSymbol));
},
resolveReferencedModule: (node: ts.ExportDeclaration | ts.ModuleDeclaration) => {
const moduleName = ts.isExportDeclaration(node) ? node.moduleSpecifier : node.name;
resolveReferencedModule: (node: ts.ExportDeclaration | ts.ModuleDeclaration | ts.ImportTypeNode) => {
let moduleName: ts.Expression | ts.LiteralTypeNode | undefined;

if (ts.isExportDeclaration(node)) {
moduleName = node.moduleSpecifier;
} else if (ts.isModuleDeclaration(node)) {
moduleName = node.name;
} else if (ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) {
moduleName = node.argument.literal;
}

if (moduleName === undefined) {
return null;
}
Expand Down Expand Up @@ -359,9 +365,12 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options:
return false;
}

// we don't need to specify exact file here since we need to figure out whether a file is external or internal one
const moduleFileName = resolveModuleFileName(rootSourceFile.fileName, node.argument.literal.text, baseUrl);
return !getModuleInfo(moduleFileName, criteria).isExternal;
const resolvedModule = updateResultCommonParams.resolveReferencedModule(node);
if (resolvedModule === null) {
return false;
}

return !updateResultCommonParams.getModuleInfo(resolvedModule).isExternal;
},
},
{
Expand Down Expand Up @@ -396,7 +405,7 @@ interface UpdateParams {
getDeclarationsForExportedAssignment(exportAssignment: ts.ExportAssignment): ts.Declaration[];
getDeclarationUsagesSourceFiles(declaration: ts.NamedDeclaration): Set<ts.SourceFile | ts.ModuleDeclaration>;
areDeclarationSame(a: ts.NamedDeclaration, b: ts.NamedDeclaration): boolean;
resolveReferencedModule(node: ts.ExportDeclaration | ts.ModuleDeclaration): ts.SourceFile | ts.ModuleDeclaration | null;
resolveReferencedModule(node: ts.ExportDeclaration | ts.ModuleDeclaration | ts.ImportTypeNode): ts.SourceFile | ts.ModuleDeclaration | null;
}

const skippedNodes = [
Expand Down Expand Up @@ -585,21 +594,8 @@ function updateResultForModuleDeclaration(moduleDecl: ts.ModuleDeclaration, para
);
}

function resolveModuleFileName(currentFileName: string, moduleName: string, baseUrl?: string): string {
if (moduleName.startsWith('.')) {
return fixPath(path.join(currentFileName, '..', moduleName));
}

// determine if the module is a non-relative import that can be resolved with the baseUrl
if (baseUrl !== undefined) {
const filePath = `${path.join(baseUrl, moduleName)}.ts`;

if (fs.existsSync(filePath)) {
return fixPath(filePath);
}
}

return `node_modules/${moduleName}/`;
function resolveModuleFileName(currentFileName: string, moduleName: string): string {
return moduleName.startsWith('.') ? fixPath(path.join(currentFileName, '..', moduleName)) : `node_modules/${moduleName}/`;
}

function addTypesReference(library: string, typesReferences: Set<string>): void {
Expand Down
185 changes: 87 additions & 98 deletions src/generate-output.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as ts from 'typescript';

import { hasNodeModifier } from './helpers/typescript';
import { packageVersion } from './helpers/package-version';
import { modifiersToMap, recreateRootLevelNodeWithModifiers } from './helpers/typescript';

export interface ModuleImportsSet {
defaultImports: Set<string>;
Expand Down Expand Up @@ -58,13 +58,17 @@ export function generateOutput(params: OutputParams, options: OutputOptions = {}
}
}

const statements = params.statements.map((statement: ts.Statement) => getStatementText(statement, params));
const statements = params.statements.map((statement: ts.Statement) => getStatementText(
statement,
Boolean(options.sortStatements),
params
));

if (options.sortStatements) {
statements.sort(compareStatementText);
}

resultOutput += statementsTextToString(statements, params);
resultOutput += statementsTextToString(statements);

if (params.renamedExports.length !== 0) {
resultOutput += `\n\nexport {\n\t${params.renamedExports.sort().join(',\n\t')},\n};`;
Expand All @@ -82,127 +86,122 @@ export function generateOutput(params: OutputParams, options: OutputOptions = {}
}

interface StatementText {
leadingComment?: string;
text: string;
sortingValue: string;
}

function statementTextToString(s: StatementText): string {
if (s.leadingComment === undefined) {
return s.text;
}

return `${s.leadingComment}\n${s.text}`;
}

function statementsTextToString(statements: StatementText[], helpers: OutputHelpers): string {
const statementsText = statements.map(statementTextToString).join('\n');
return spacesToTabs(prettifyStatementsText(statementsText, helpers));
function statementsTextToString(statements: StatementText[]): string {
const statementsText = statements.map(statement => statement.text).join('\n');
return spacesToTabs(prettifyStatementsText(statementsText));
}

function prettifyStatementsText(statementsText: string, helpers: OutputHelpers): string {
function prettifyStatementsText(statementsText: string): string {
const sourceFile = ts.createSourceFile('output.d.ts', statementsText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
const printer = ts.createPrinter(
{
newLine: ts.NewLineKind.LineFeed,
removeComments: false,
},
{
substituteNode: (hint: ts.EmitHint, node: ts.Node) => {
// `import('module').Qualifier` or `typeof import('module').Qualifier`
if (ts.isImportTypeNode(node) && node.qualifier !== undefined && helpers.needStripImportFromImportTypeNode(node)) {
if (node.isTypeOf) {
// I personally don't like this solution because it spreads the logic of modifying nodes in the code
// I'd prefer to have it somewhere near getStatementText or so
// but at the moment it seems that it's the fastest and most easiest way to remove `import('./module').` form the code
// if you read this and know how to make it better - feel free to share your ideas/PR with fixes
// tslint:disable-next-line:deprecation
return ts.createTypeQueryNode(node.qualifier);
}

return ts.createTypeReferenceNode(node.qualifier, node.typeArguments);
}

return node;
},
}
);

return printer.printFile(sourceFile).trim();
}

function compareStatementText(a: StatementText, b: StatementText): number {
if (a.text > b.text) {
if (a.sortingValue > b.sortingValue) {
return 1;
} else if (a.text < b.text) {
} else if (a.sortingValue < b.sortingValue) {
return -1;
}

return 0;
}

function needAddDeclareKeyword(statement: ts.Statement, nodeText: string): boolean {
// for some reason TypeScript allows to not write `declare` keyword for ClassDeclaration, FunctionDeclaration and VariableDeclaration
// if it already has `export` keyword - so we need to add it
// to avoid TS1046: Top-level declarations in .d.ts files must start with either a 'declare' or 'export' modifier.
if (ts.isClassDeclaration(statement) && (/^class\b/.test(nodeText) || /^abstract\b/.test(nodeText))) {
return true;
}

if (ts.isFunctionDeclaration(statement) && /^function\b/.test(nodeText)) {
return true;
}
function getStatementText(statement: ts.Statement, includeSortingValue: boolean, helpers: OutputHelpers): StatementText {
const shouldStatementHasExportKeyword = helpers.shouldStatementHasExportKeyword(statement);
const needStripDefaultKeyword = helpers.needStripDefaultKeywordForStatement(statement);

if (ts.isVariableStatement(statement) && /^(const|let|var)\b/.test(nodeText)) {
return true;
}
const printer = ts.createPrinter(
{
newLine: ts.NewLineKind.LineFeed,
removeComments: false,
},
{
// eslint-disable-next-line complexity
substituteNode: (hint: ts.EmitHint, node: ts.Node) => {
// `import('module').Qualifier` or `typeof import('module').Qualifier`
if (ts.isImportTypeNode(node) && node.qualifier !== undefined && helpers.needStripImportFromImportTypeNode(node)) {
if (node.isTypeOf) {
return ts.factory.createTypeQueryNode(node.qualifier);
}

if (ts.isEnumDeclaration(statement) && (/^(const)\b/.test(nodeText) || /^(enum)\b/.test(nodeText))) {
return true;
}
return ts.factory.createTypeReferenceNode(node.qualifier, node.typeArguments);
}

return false;
}
if (node !== statement) {
return node;
}

function getStatementText(statement: ts.Statement, helpers: OutputHelpers): StatementText {
const shouldStatementHasExportKeyword = helpers.shouldStatementHasExportKeyword(statement);
const needStripDefaultKeyword = helpers.needStripDefaultKeywordForStatement(statement);
const hasStatementExportKeyword = ts.isExportAssignment(statement) || hasNodeModifier(statement, ts.SyntaxKind.ExportKeyword);
const modifiersMap = modifiersToMap(node.modifiers);

let nodeText = getTextAccordingExport(statement.getText(), hasStatementExportKeyword, shouldStatementHasExportKeyword);
if (
ts.isEnumDeclaration(node)
&& modifiersMap[ts.SyntaxKind.ConstKeyword]
&& helpers.needStripConstFromConstEnum(node)
) {
modifiersMap[ts.SyntaxKind.ConstKeyword] = false;
}

if (
ts.isEnumDeclaration(statement)
&& hasNodeModifier(statement, ts.SyntaxKind.ConstKeyword)
&& helpers.needStripConstFromConstEnum(statement)) {
nodeText = nodeText.replace(/\bconst\s/, '');
}
// strip the `default` keyword from node
if (modifiersMap[ts.SyntaxKind.DefaultKeyword] && needStripDefaultKeyword) {
// we need just to remove `default` from any node except class node
// for classes we need to replace `default` with `declare` instead
modifiersMap[ts.SyntaxKind.DefaultKeyword] = false;
if (ts.isClassDeclaration(node)) {
modifiersMap[ts.SyntaxKind.DeclareKeyword] = true;
}
}

// strip the `default` keyword from node
if (hasNodeModifier(statement, ts.SyntaxKind.DefaultKeyword) && needStripDefaultKeyword) {
// we need just to remove `default` from any node except class node
// for classes we need to replace `default` with `declare` instead
nodeText = nodeText.replace(/\bdefault\s/, ts.isClassDeclaration(statement) ? 'declare ' : '');
}
if (!shouldStatementHasExportKeyword) {
modifiersMap[ts.SyntaxKind.ExportKeyword] = false;
} else {
modifiersMap[ts.SyntaxKind.ExportKeyword] = true;
}

if (needAddDeclareKeyword(statement, nodeText)) {
nodeText = `declare ${nodeText}`;
}
// for some reason TypeScript allows to not write `declare` keyword for ClassDeclaration, FunctionDeclaration and VariableDeclaration
// if it already has `export` keyword - so we need to add it
// to avoid TS1046: Top-level declarations in .d.ts files must start with either a 'declare' or 'export' modifier.
if (!modifiersMap[ts.SyntaxKind.ExportKeyword] &&
(ts.isClassDeclaration(node)
|| ts.isFunctionDeclaration(node)
|| ts.isVariableStatement(node)
|| ts.isEnumDeclaration(node)
)
) {
modifiersMap[ts.SyntaxKind.DeclareKeyword] = true;
}

const result: StatementText = {
text: nodeText,
};

// add jsdoc for exported nodes only
if (shouldStatementHasExportKeyword) {
const start = statement.getStart();
const jsDocStart = statement.getStart(undefined, true);
const nodeJSDoc = statement.getSourceFile().getFullText().substring(jsDocStart, start).trim();
if (nodeJSDoc.length !== 0) {
result.leadingComment = nodeJSDoc;
return recreateRootLevelNodeWithModifiers(node, modifiersMap, shouldStatementHasExportKeyword);
},
}
);

const statementText = printer.printNode(ts.EmitHint.Unspecified, statement, statement.getSourceFile()).trim();

let sortingValue = '';

if (includeSortingValue) {
// it looks like there is no way to get node's text without a comment at the same time as printing it
// so to get the actual node text we have to parse it again
// hopefully it shouldn't take too long (we don't need to do type check, just parse the AST)
// also let's do it opt-in so if someone isn't using node sorting it won't affect them
const tempSourceFile = ts.createSourceFile('temp.d.ts', statementText, ts.ScriptTarget.ESNext);

// we certainly know that there should be 1 statement at the root level (the printed statement)
sortingValue = tempSourceFile.getChildren()[0].getText();
}

return result;
return { text: statementText, sortingValue };
}

function generateImports(libraryName: string, imports: ModuleImportsSet): string[] {
Expand All @@ -228,16 +227,6 @@ function generateReferenceTypesDirective(libraries: string[]): string {
}).join('\n');
}

function getTextAccordingExport(nodeText: string, isNodeExported: boolean, shouldNodeBeExported: boolean): string {
if (shouldNodeBeExported && !isNodeExported) {
return 'export ' + nodeText;
} else if (isNodeExported && !shouldNodeBeExported) {
return nodeText.slice('export '.length);
}

return nodeText;
}

function spacesToTabs(text: string): string {
// eslint-disable-next-line no-regex-spaces
return text.replace(/^( )+/gm, (substring: string) => {
Expand Down

0 comments on commit fea202f

Please sign in to comment.