From be09a2738631aca556c97bad335bb4b2fb46f8fc Mon Sep 17 00:00:00 2001 From: Josh Parnham Date: Mon, 29 Aug 2022 23:06:53 +1000 Subject: [PATCH 1/7] Better support non-relative imports of modules when a project has a baseUrl configured --- src/bundle-generator.ts | 24 +++++++++++++++---- .../config.ts | 5 ++++ .../input.ts | 5 ++++ .../output.d.ts | 6 +++++ .../src/field/type.ts | 9 +++++++ .../tsconfig.json | 6 +++++ 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/test-cases/import-from-non-relative-path-inferred-type/config.ts create mode 100644 tests/e2e/test-cases/import-from-non-relative-path-inferred-type/input.ts create mode 100644 tests/e2e/test-cases/import-from-non-relative-path-inferred-type/output.d.ts create mode 100644 tests/e2e/test-cases/import-from-non-relative-path-inferred-type/src/field/type.ts create mode 100644 tests/e2e/test-cases/import-from-non-relative-path-inferred-type/tsconfig.json diff --git a/src/bundle-generator.ts b/src/bundle-generator.ts index 06ba575..9308c27 100644 --- a/src/bundle-generator.ts +++ b/src/bundle-generator.ts @@ -1,5 +1,6 @@ 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'; @@ -138,7 +139,9 @@ 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 typeRoots = ts.getEffectiveTypeRoots(program.getCompilerOptions(), {}); + const compilerOptions = program.getCompilerOptions(); + const typeRoots = ts.getEffectiveTypeRoots(compilerOptions, {}); + const baseUrl = compilerOptions.baseUrl; const sourceFiles = program.getSourceFiles().filter((file: ts.SourceFile) => { return !program.isSourceFileDefaultLibrary(file); @@ -357,7 +360,7 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options: } // 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); + const moduleFileName = resolveModuleFileName(rootSourceFile.fileName, node.argument.literal.text, baseUrl); return !getModuleInfo(moduleFileName, criteria).isExternal; }, }, @@ -582,8 +585,21 @@ function updateResultForModuleDeclaration(moduleDecl: ts.ModuleDeclaration, para ); } -function resolveModuleFileName(currentFileName: string, moduleName: string): string { - return moduleName.startsWith('.') ? fixPath(path.join(currentFileName, '..', moduleName)) : `node_modules/${moduleName}/`; +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 addTypesReference(library: string, typesReferences: Set): void { diff --git a/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/config.ts b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/config.ts new file mode 100644 index 0000000..f1fb1a8 --- /dev/null +++ b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/config.ts @@ -0,0 +1,5 @@ +import { TestCaseConfig } from '../../test-cases/test-case-config'; + +const config: TestCaseConfig = {}; + +export = config; diff --git a/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/input.ts b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/input.ts new file mode 100644 index 0000000..a4e7c85 --- /dev/null +++ b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/input.ts @@ -0,0 +1,5 @@ +import { returnMyType } from "field/type"; + +export function test() { + return returnMyType() +} diff --git a/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/output.d.ts b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/output.d.ts new file mode 100644 index 0000000..2cec625 --- /dev/null +++ b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/output.d.ts @@ -0,0 +1,6 @@ +export interface MyType { + field: string; +} +export declare function test(): MyType; + +export {}; diff --git a/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/src/field/type.ts b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/src/field/type.ts new file mode 100644 index 0000000..6b567ee --- /dev/null +++ b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/src/field/type.ts @@ -0,0 +1,9 @@ +export interface MyType { + field: string; +} + +export function returnMyType(): MyType { + return { + field: "test" + } +} diff --git a/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/tsconfig.json b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/tsconfig.json new file mode 100644 index 0000000..f41d619 --- /dev/null +++ b/tests/e2e/test-cases/import-from-non-relative-path-inferred-type/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./src" + } +} From 0d17f9bc601f55591fd95d3217d52b97ceb212ff Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Sun, 4 Sep 2022 22:08:16 +0100 Subject: [PATCH 2/7] Bumped min supported typescript version to v4 --- .github/workflows/ci.yml | 2 +- package.json | 2 +- src/helpers/typescript.ts | 47 +----------------------------------- src/types-usage-evaluator.ts | 4 +-- 4 files changed, 4 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 913d3ed..6ea5b47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: with: node-version: 12 - run: npm install - - run: npm install typescript@3.0.1 + - run: npm install typescript@4.0.2 - run: npm run tsc ts-current: diff --git a/package.json b/package.json index d99e9f4..a215625 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "homepage": "https://github.com/timocov/dts-bundle-generator", "dependencies": { - "typescript": ">=3.0.1", + "typescript": ">=4.0.2", "yargs": "^17.2.1" }, "devDependencies": { diff --git a/src/helpers/typescript.ts b/src/helpers/typescript.ts index 24b6330..e05d8ad 100644 --- a/src/helpers/typescript.ts +++ b/src/helpers/typescript.ts @@ -11,10 +11,7 @@ const namedDeclarationKinds = [ ts.SyntaxKind.PropertySignature, ]; -// actually we should use ts.DefaultKeyword instead of ts.Modifier -// but there is no such type in previous versions of the compiler so we cannot use it here -// TODO: replace with ts.DefaultKeyword once the min typescript will be upgraded -export type NodeName = ts.DeclarationName | ts.Modifier; +export type NodeName = ts.DeclarationName | ts.DefaultKeyword; export function isNodeNamedDeclaration(node: ts.Node): node is ts.NamedDeclaration { return namedDeclarationKinds.indexOf(node.kind) !== -1; @@ -124,14 +121,10 @@ export function isNamespaceStatement(node: ts.Node): node is ts.ModuleDeclaratio export function getDeclarationsForSymbol(symbol: ts.Symbol): ts.Declaration[] { const result: ts.Declaration[] = []; - // Disabling tslint is for backward compat with TypeScript < 3 - // tslint:disable-next-line:strict-type-predicates if (symbol.declarations !== undefined) { result.push(...symbol.declarations); } - // Disabling tslint is for backward compat with TypeScript < 3 - // tslint:disable-next-line:strict-type-predicates if (symbol.valueDeclaration !== undefined) { // push valueDeclaration might be already in declarations array // so let's check first to avoid duplication nodes @@ -280,41 +273,3 @@ function getExportsForName( const declarationSymbol = typeChecker.getSymbolAtLocation(name); return exportedSymbols.filter((rootExport: SourceFileExport) => rootExport.symbol === declarationSymbol); } - -// labelled tuples were introduced in TypeScript 4.0, prior 4.0 version type `ts.NamedTupleMember` didn't exist -// so the main question is how to make the compiler happy to compile the code without errors -// and at the same time don't use `any` type and provide proper autocomplete and properties checking _with previous versions of the compiler_? -// the following trick allows us to handle this! -// if ts.NamedTupleMember doesn't exist (< 4.0) - NamedTupleMember will be `any` type -// otherwise it will be a proper type from the compiler's typings -// (this is how @ts-ignore works) -// thus this type must NOT be inlined -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -type NamedTupleMember = ts.NamedTupleMember; - -// if NamedTupleMember is `any` type then let's use a fallback (we don't need to provide full type spec here, just what we're using in the code) -// otherwise we'll use its type so we don't need to use a fallback -type NamedTupleMemberCompat = unknown extends NamedTupleMember ? ts.Node & { name: ts.Identifier } : NamedTupleMember; - -export function isNamedTupleMember(node: ts.Node): node is NamedTupleMemberCompat { - interface CompatibilityTypeScriptPart { - // labelled tuples and this method were introduced in TypeScript 4.0 - // so, to be compiled with TypeScript < 4.0 we need to have this trick - isNamedTupleMember?(node: ts.Node): node is NamedTupleMemberCompat; - } - - type CommonKeys = keyof (CompatibilityTypeScriptPart | typeof ts); - - // if current ts.Program has isNamedTupleMember method - then use it - // if it does not have it yet - use fallback - type CompatibleTypeScript = CommonKeys extends never ? typeof ts & CompatibilityTypeScriptPart : typeof ts; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const compatTs = ts as CompatibleTypeScript; - if (!compatTs.isNamedTupleMember) { - return false; - } - - return compatTs.isNamedTupleMember(node); -} diff --git a/src/types-usage-evaluator.ts b/src/types-usage-evaluator.ts index 4652f23..dfbe60b 100644 --- a/src/types-usage-evaluator.ts +++ b/src/types-usage-evaluator.ts @@ -2,7 +2,6 @@ import * as ts from 'typescript'; import { getActualSymbol, isDeclareModule, - isNamedTupleMember, isNodeNamedDeclaration, splitTransientSymbol, } from './helpers/typescript'; @@ -85,8 +84,7 @@ export class TypesUsageEvaluator { if (ts.isIdentifier(child)) { // identifiers in labelled tuples don't have symbols for their labels // so let's just skip them from collecting - // since this feature is for TypeScript > 4, we have to check that a function exist before accessing it - if (isNamedTupleMember(child.parent) && child.parent.name === child) { + if (ts.isNamedTupleMember(child.parent) && child.parent.name === child) { continue; } From 9444310d286ae75d17b084e59c3f1a670db368d2 Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Sun, 4 Sep 2022 22:22:28 +0100 Subject: [PATCH 3/7] Chore: Upgraded deps and increased min nodejs version to 14 - Upgraded yargs - Upgraded min nodejs version to 14 - Changed target from es5 to es2020 --- .github/workflows/ci.yml | 8 ++++---- package.json | 22 +++++++++++----------- tsconfig.options.json | 5 +++-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ea5b47..c3139b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm run lint @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm install typescript@4.0.2 - run: npm run tsc @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm run tsc - run: npm run test @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm install typescript@next - run: npm run tsc diff --git a/package.json b/package.json index a215625..b76545e 100644 --- a/package.json +++ b/package.json @@ -18,23 +18,23 @@ "homepage": "https://github.com/timocov/dts-bundle-generator", "dependencies": { "typescript": ">=4.0.2", - "yargs": "^17.2.1" + "yargs": "^17.6.0" }, "devDependencies": { - "@types/mocha": "~9.1.1", - "@types/node": "~12.12.47", - "@types/yargs": "~17.0.5", - "@typescript-eslint/eslint-plugin": "~5.28.0", - "@typescript-eslint/parser": "~5.28.0", - "eslint": "~8.18.0", + "@types/mocha": "~10.0.0", + "@types/node": "~14.18.26", + "@types/yargs": "~17.0.13", + "@typescript-eslint/eslint-plugin": "~5.38.1", + "@typescript-eslint/parser": "~5.38.1", + "eslint": "~8.24.0", "eslint-plugin-import": "~2.26.0", "eslint-plugin-prefer-arrow": "~1.2.1", - "eslint-plugin-unicorn": "~42.0.0", + "eslint-plugin-unicorn": "~44.0.0", "mocha": "~10.0.0", "npm-run-all": "~4.1.5", "rimraf": "~3.0.2", - "ts-compiler": "npm:typescript@4.8.2", - "ts-node": "~10.8.1" + "ts-compiler": "npm:typescript@4.8.4", + "ts-node": "~10.9.1" }, "license": "MIT", "readme": "README.md", @@ -43,7 +43,7 @@ "url": "git+https://github.com/timocov/dts-bundle-generator.git" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "scripts": { "clean": "rimraf dist/ dts-out/", diff --git a/tsconfig.options.json b/tsconfig.options.json index d8e5815..239c12a 100644 --- a/tsconfig.options.json +++ b/tsconfig.options.json @@ -1,7 +1,8 @@ { "compilerOptions": { "declaration": true, - "lib": ["es2015"], + "lib": ["es2020"], + "module": "commonjs", "newLine": "LF", "noEmitOnError": true, "noFallthroughCasesInSwitch": true, @@ -9,6 +10,6 @@ "noUnusedLocals": true, "strict": true, "stripInternal": true, - "target": "es5" + "target": "es2020" } } From fea202fbffdd64f68001568aaf955c1f62935607 Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Sun, 4 Sep 2022 22:35:52 +0100 Subject: [PATCH 4/7] Fixed incorrect handling removing import() from types in case of usage 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 --- src/bundle-generator.ts | 46 ++-- src/generate-output.ts | 185 ++++++++------- src/helpers/typescript.ts | 211 ++++++++++++++++++ tests/e2e/test-cases/save-jsdoc/input.ts | 5 +- tests/e2e/test-cases/save-jsdoc/output.d.ts | 2 + tests/e2e/test-cases/save-jsdoc/some-class.ts | 1 + 6 files changed, 326 insertions(+), 124 deletions(-) diff --git a/src/bundle-generator.ts b/src/bundle-generator.ts index 9308c27..325bf47 100644 --- a/src/bundle-generator.ts +++ b/src/bundle-generator.ts @@ -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'; @@ -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); @@ -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; } @@ -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; }, }, { @@ -396,7 +405,7 @@ interface UpdateParams { getDeclarationsForExportedAssignment(exportAssignment: ts.ExportAssignment): ts.Declaration[]; getDeclarationUsagesSourceFiles(declaration: ts.NamedDeclaration): Set; 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 = [ @@ -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): void { diff --git a/src/generate-output.ts b/src/generate-output.ts index 0bc1bd4..1b98981 100644 --- a/src/generate-output.ts +++ b/src/generate-output.ts @@ -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; @@ -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};`; @@ -82,48 +86,21 @@ 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; - }, } ); @@ -131,78 +108,100 @@ function prettifyStatementsText(statementsText: string, helpers: OutputHelpers): } 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[] { @@ -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) => { diff --git a/src/helpers/typescript.ts b/src/helpers/typescript.ts index e05d8ad..a2065be 100644 --- a/src/helpers/typescript.ts +++ b/src/helpers/typescript.ts @@ -273,3 +273,214 @@ function getExportsForName( const declarationSymbol = typeChecker.getSymbolAtLocation(name); return exportedSymbols.filter((rootExport: SourceFileExport) => rootExport.symbol === declarationSymbol); } + +export type ModifiersMap = Record; + +const modifiersPriority: Record = { + [ts.SyntaxKind.ExportKeyword]: 4, + [ts.SyntaxKind.DefaultKeyword]: 3, + [ts.SyntaxKind.DeclareKeyword]: 2, + + [ts.SyntaxKind.AsyncKeyword]: 1, + [ts.SyntaxKind.ConstKeyword]: 1, + + // we don't care about these modifiers as they are used in classes only and cannot be at the root level + [ts.SyntaxKind.AbstractKeyword]: 0, + [ts.SyntaxKind.ReadonlyKeyword]: 0, + [ts.SyntaxKind.StaticKeyword]: 0, + [ts.SyntaxKind.InKeyword]: 0, + [ts.SyntaxKind.OutKeyword]: 0, + [ts.SyntaxKind.OverrideKeyword]: 0, + [ts.SyntaxKind.PrivateKeyword]: 0, + [ts.SyntaxKind.ProtectedKeyword]: 0, + [ts.SyntaxKind.PublicKeyword]: 0, +}; + +export function modifiersToMap(modifiers: (readonly ts.Modifier[]) | undefined | null): ModifiersMap { + modifiers = modifiers || []; + + return modifiers.reduce( + (result: ModifiersMap, modifier: ts.Modifier) => { + result[modifier.kind] = true; + return result; + }, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + {} as Record + ); +} + +export function modifiersMapToArray(modifiersMap: ModifiersMap): ts.Modifier[] { + return Object.entries(modifiersMap) + .filter(([kind, include]) => include) + .map(([kind]) => ts.factory.createModifier(Number(kind))) + .sort((a: ts.Modifier, b: ts.Modifier) => { + // note `|| 0` is here as a fallback in the case if the compiler adds a new modifier + // but the tool isn't updated yet + const aValue = modifiersPriority[a.kind as ts.ModifierSyntaxKind] || 0; + const bValue = modifiersPriority[b.kind as ts.ModifierSyntaxKind] || 0; + return bValue - aValue; + }); +} + +export function recreateRootLevelNodeWithModifiers(node: ts.Node, modifiersMap: ModifiersMap, keepComments: boolean = true): ts.Node { + const newNode = recreateRootLevelNodeWithModifiersImpl(node, modifiersMap); + + if (keepComments) { + ts.setCommentRange(newNode, ts.getCommentRange(node)); + } + + return newNode; +} + +// eslint-disable-next-line complexity +function recreateRootLevelNodeWithModifiersImpl(node: ts.Node, modifiersMap: ModifiersMap): ts.Node { + const modifiers = modifiersMapToArray(modifiersMap); + + if (ts.isArrowFunction(node)) { + return ts.factory.createArrowFunction( + modifiers, + node.typeParameters, + node.parameters, + node.type, + node.equalsGreaterThanToken, + node.body + ); + } + + if (ts.isClassDeclaration(node)) { + return ts.factory.createClassDeclaration( + node.decorators, + modifiers, + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } + + if (ts.isClassExpression(node)) { + return ts.factory.createClassExpression( + node.decorators, + modifiers, + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } + + if (ts.isEnumDeclaration(node)) { + return ts.factory.createEnumDeclaration( + node.decorators, + modifiers, + node.name, + node.members + ); + } + + if (ts.isExportAssignment(node)) { + return ts.factory.createExportAssignment( + node.decorators, + modifiers, + node.isExportEquals, + node.expression + ); + } + + if (ts.isExportDeclaration(node)) { + return ts.factory.createExportDeclaration( + node.decorators, + modifiers, + node.isTypeOnly, + node.exportClause, + node.moduleSpecifier, + node.assertClause + ); + } + + if (ts.isFunctionDeclaration(node)) { + return ts.factory.createFunctionDeclaration( + node.decorators, + modifiers, + node.asteriskToken, + node.name, + node.typeParameters, + node.parameters, + node.type, + node.body + ); + } + + if (ts.isFunctionExpression(node)) { + return ts.factory.createFunctionExpression( + modifiers, + node.asteriskToken, + node.name, + node.typeParameters, + node.parameters, + node.type, + node.body + ); + } + + if (ts.isImportDeclaration(node)) { + return ts.factory.createImportDeclaration( + node.decorators, + modifiers, + node.importClause, + node.moduleSpecifier, + node.assertClause + ); + } + + if (ts.isImportEqualsDeclaration(node)) { + return ts.factory.createImportEqualsDeclaration( + node.decorators, + modifiers, + node.isTypeOnly, + node.name, + node.moduleReference + ); + } + + if (ts.isInterfaceDeclaration(node)) { + return ts.factory.createInterfaceDeclaration( + node.decorators, + modifiers, + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } + + if (ts.isModuleDeclaration(node)) { + return ts.factory.createModuleDeclaration( + node.decorators, + modifiers, + node.name, + node.body, + node.flags + ); + } + + if (ts.isTypeAliasDeclaration(node)) { + return ts.factory.createTypeAliasDeclaration( + node.decorators, + modifiers, + node.name, + node.typeParameters, + node.type + ); + } + + if (ts.isVariableStatement(node)) { + return ts.factory.createVariableStatement( + modifiers, + node.declarationList + ); + } + + throw new Error(`Unknown top-level node kind (with modifiers): ${ts.SyntaxKind[node.kind]}. +If you're seeing this error, please report a bug on https://github.com/timocov/dts-bundle-generator/issues`); +} diff --git a/tests/e2e/test-cases/save-jsdoc/input.ts b/tests/e2e/test-cases/save-jsdoc/input.ts index 8a978e2..1479367 100644 --- a/tests/e2e/test-cases/save-jsdoc/input.ts +++ b/tests/e2e/test-cases/save-jsdoc/input.ts @@ -13,7 +13,10 @@ export type ExportedType = string | number; /** * ExportedConstEnum JSDoc */ -export const enum ExportedConstEnum { Item } +export const enum ExportedConstEnum { + /** Item description */ + Item, +} /** * ExportedEnum JSDoc diff --git a/tests/e2e/test-cases/save-jsdoc/output.d.ts b/tests/e2e/test-cases/save-jsdoc/output.d.ts index 8d8f9b6..7098196 100644 --- a/tests/e2e/test-cases/save-jsdoc/output.d.ts +++ b/tests/e2e/test-cases/save-jsdoc/output.d.ts @@ -9,6 +9,7 @@ export declare const enum NonExportedConstEnum { } declare class NonExportedClass { method(): NonExportedEnum; + /** Method description */ method2(): NonExportedConstEnum; } /** @@ -24,6 +25,7 @@ export declare type ExportedType = string | number; * ExportedConstEnum JSDoc */ export declare const enum ExportedConstEnum { + /** Item description */ Item = 0 } /** diff --git a/tests/e2e/test-cases/save-jsdoc/some-class.ts b/tests/e2e/test-cases/save-jsdoc/some-class.ts index 60c3bf2..6e3362f 100644 --- a/tests/e2e/test-cases/save-jsdoc/some-class.ts +++ b/tests/e2e/test-cases/save-jsdoc/some-class.ts @@ -20,6 +20,7 @@ export class NonExportedClass { return NonExportedEnum.First; } + /** Method description */ public method2(): NonExportedConstEnum { return NonExportedConstEnum.First; } From f55b2f193efd2934895390584182adb4a277389b Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Sun, 2 Oct 2022 22:02:43 +0100 Subject: [PATCH 5/7] Increased slow timeout for mocha tests --- .mocharc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mocharc.js b/.mocharc.js index 517c325..b137924 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -7,7 +7,7 @@ const config = { recursive: true, diff: true, timeout: 10000, - slow: 2500, + slow: 5000, }; if (process.env.TESTS_REPORT_FILE) { From 173dc69be0fb34b7eb43e06f0109b852e7262fd5 Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Sun, 2 Oct 2022 23:24:58 +0100 Subject: [PATCH 6/7] Migrate to typescript@4.8 properly In fact, migrating Node.modifiers usage --- src/generate-output.ts | 4 ++-- src/helpers/typescript.ts | 29 +++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/generate-output.ts b/src/generate-output.ts index 1b98981..2116dbf 100644 --- a/src/generate-output.ts +++ b/src/generate-output.ts @@ -1,7 +1,7 @@ import * as ts from 'typescript'; import { packageVersion } from './helpers/package-version'; -import { modifiersToMap, recreateRootLevelNodeWithModifiers } from './helpers/typescript'; +import { getModifiers, modifiersToMap, recreateRootLevelNodeWithModifiers } from './helpers/typescript'; export interface ModuleImportsSet { defaultImports: Set; @@ -142,7 +142,7 @@ function getStatementText(statement: ts.Statement, includeSortingValue: boolean, return node; } - const modifiersMap = modifiersToMap(node.modifiers); + const modifiersMap = modifiersToMap(getModifiers(node)); if ( ts.isEnumDeclaration(node) diff --git a/src/helpers/typescript.ts b/src/helpers/typescript.ts index a2065be..fa82697 100644 --- a/src/helpers/typescript.ts +++ b/src/helpers/typescript.ts @@ -18,13 +18,15 @@ export function isNodeNamedDeclaration(node: ts.Node): node is ts.NamedDeclarati } export function hasNodeModifier(node: ts.Node, modifier: ts.SyntaxKind): boolean { - return Boolean(node.modifiers && node.modifiers.some((nodeModifier: NonNullable[number]) => nodeModifier.kind === modifier)); + const modifiers = getModifiers(node); + return Boolean(modifiers && modifiers.some((nodeModifier: NonNullable[number]) => nodeModifier.kind === modifier)); } export function getNodeName(node: ts.Node): NodeName | undefined { const nodeName = (node as unknown as ts.NamedDeclaration).name; if (nodeName === undefined) { - const defaultModifier = node.modifiers?.find((mod: NonNullable[number]) => mod.kind === ts.SyntaxKind.DefaultKeyword); + const modifiers = getModifiers(node); + const defaultModifier = modifiers?.find((mod: NonNullable[number]) => mod.kind === ts.SyntaxKind.DefaultKeyword); if (defaultModifier !== undefined) { return defaultModifier as NodeName; } @@ -484,3 +486,26 @@ function recreateRootLevelNodeWithModifiersImpl(node: ts.Node, modifiersMap: Mod throw new Error(`Unknown top-level node kind (with modifiers): ${ts.SyntaxKind[node.kind]}. If you're seeing this error, please report a bug on https://github.com/timocov/dts-bundle-generator/issues`); } + +interface TsCompatWith48 { + canHaveModifiers?(node: ts.Node): boolean; + getModifiers?(node: ts.Node): readonly ts.Modifier[] | undefined; +} + +function canHaveModifiersCompat(node: ts.Node): boolean { + const compatTs = ts as TsCompatWith48; + return compatTs.canHaveModifiers !== undefined ? compatTs.canHaveModifiers(node) : true; +} + +function getModifiersCompat(node: ts.Node): readonly ts.Modifier[] | undefined { + const compatTs = ts as TsCompatWith48; + return compatTs.getModifiers !== undefined ? compatTs.getModifiers(node) : node.modifiers as readonly ts.Modifier[] | undefined; +} + +export function getModifiers(node: ts.Node): readonly ts.Modifier[] | undefined { + if (!canHaveModifiersCompat(node)) { + throw new Error(`Node kind=${ts.SyntaxKind[node.kind]} cannot have modifiers`); + } + + return getModifiersCompat(node); +} From 76c6ba7c042208d5f92bd1170f53995d1a65b81d Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Sun, 2 Oct 2022 23:56:50 +0100 Subject: [PATCH 7/7] Chore: Upgrade min supported typescript version to 4.5 --- .github/workflows/ci.yml | 2 +- package.json | 2 +- src/helpers/typescript.ts | 13 +------------ 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3139b0..97649c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: with: node-version: 14 - run: npm install - - run: npm install typescript@4.0.2 + - run: npm install typescript@4.5.2 - run: npm run tsc ts-current: diff --git a/package.json b/package.json index b76545e..8e5fbb3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "homepage": "https://github.com/timocov/dts-bundle-generator", "dependencies": { - "typescript": ">=4.0.2", + "typescript": ">=4.5.2", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/src/helpers/typescript.ts b/src/helpers/typescript.ts index fa82697..f42cca1 100644 --- a/src/helpers/typescript.ts +++ b/src/helpers/typescript.ts @@ -278,24 +278,13 @@ function getExportsForName( export type ModifiersMap = Record; -const modifiersPriority: Record = { +const modifiersPriority: Partial> = { [ts.SyntaxKind.ExportKeyword]: 4, [ts.SyntaxKind.DefaultKeyword]: 3, [ts.SyntaxKind.DeclareKeyword]: 2, [ts.SyntaxKind.AsyncKeyword]: 1, [ts.SyntaxKind.ConstKeyword]: 1, - - // we don't care about these modifiers as they are used in classes only and cannot be at the root level - [ts.SyntaxKind.AbstractKeyword]: 0, - [ts.SyntaxKind.ReadonlyKeyword]: 0, - [ts.SyntaxKind.StaticKeyword]: 0, - [ts.SyntaxKind.InKeyword]: 0, - [ts.SyntaxKind.OutKeyword]: 0, - [ts.SyntaxKind.OverrideKeyword]: 0, - [ts.SyntaxKind.PrivateKeyword]: 0, - [ts.SyntaxKind.ProtectedKeyword]: 0, - [ts.SyntaxKind.PublicKeyword]: 0, }; export function modifiersToMap(modifiers: (readonly ts.Modifier[]) | undefined | null): ModifiersMap {