diff --git a/README.md b/README.md index 686dc565c..4bd2ea746 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,12 @@ This will analyze the project and output unused files, exports, types and duplic ``` ❯ npx exportman -exportman --config ./config.js[on] +exportman --config ./config.js[on] [options] Options: --config [file] Path of configuration file (JS or JSON), requires `entryFiles: []` and `filePatterns: []` + --cwd Working directory (default: current working directory) --onlyFiles Report only unused files --onlyExports Report only unused exports --onlyTypes Report only unused types @@ -72,8 +73,8 @@ Options: Examples: $ exportman --config ./exportman.json - $ exportman --config ./exportman.js --onlyFiles --onlyDuplicates +$ exportman --config ./exportman.json --cwd packages/client More info: https://github.com/webpro/exportman ``` @@ -82,6 +83,30 @@ More info: https://github.com/webpro/exportman ### Monorepos +#### Separate packages + +In repos with multiple packages, the `--cwd` option comes in handy. With similar package structures, the packages can be +configured conveniently using globs: + +```json +{ + "packages/*": { + "entryFiles": ["src/index.ts"], + "filePatterns": ["src/**/*.{ts,tsx}", "!**/*.spec.{ts,tsx}"] + } +} +``` + +Packages can also be explicitly configured per package directory. To scan the packages separately, using the first match +from the configuration file: + +``` +exportman --cwd packages/client --config exportman.json +exportman --cwd packages/services --config exportman.json +``` + +#### Connected projects (e.g. using Nx) + A good example of a large project setup is a monorepo, such as created with Nx. Let's take an example project configuration for an Nx project using Next.js, Jest and Storybook: diff --git a/package.json b/package.json index 306227a89..637880c94 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ }, "license": "ISC", "dependencies": { + "micromatch": "4.0.5", "ts-morph": "16.0.0", "ts-morph-helpers": "0.3.0" }, "devDependencies": { + "@types/micromatch": "4.0.2", "@types/node": "18.8.1", "release-it": "15.5.0", "tsx": "3.9.0", diff --git a/src/cli.ts b/src/cli.ts index 6e5067735..70e4153e4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,14 +3,15 @@ import path from 'node:path'; import { parseArgs } from 'node:util'; import { printHelp } from './help'; +import { resolveConfig } from './util'; import { logIssueGroupResult } from './log'; import { run } from '.'; -import type { Configuration } from './types'; +import type { ImportedConfiguration } from './types'; const { - positionals: [cwdArg], values: { help, + cwd: cwdArg, config, onlyFiles, onlyExports, @@ -20,9 +21,9 @@ const { noProgress = false } } = parseArgs({ - allowPositionals: true, options: { help: { type: 'boolean' }, + cwd: { type: 'string' }, config: { type: 'string' }, onlyFiles: { type: 'boolean' }, onlyExports: { type: 'boolean' }, @@ -35,12 +36,12 @@ const { if (help || !config) { printHelp(); - process.exit(); + process.exit(0); } const cwd = cwdArg ? path.resolve(cwdArg) : process.cwd(); -const configuration: Configuration = require(path.resolve(config)); +const configuration: ImportedConfiguration = require(path.resolve(config)); const isShowProgress = !noProgress || !process.stdout.isTTY; const isFindAll = !onlyFiles && !onlyExports && !onlyTypes && !onlyDuplicates; @@ -50,8 +51,13 @@ const isFindUnusedTypes = onlyTypes === true || isFindAll; const isFindDuplicateExports = onlyDuplicates === true || isFindAll; const main = async () => { + const config = resolveConfig(configuration, cwdArg); + if (!config) { + printHelp(); + process.exit(1); + } const issues = await run({ - ...configuration, + ...config, cwd, isShowProgress, isFindUnusedFiles, diff --git a/src/help.ts b/src/help.ts index 6e9797021..420035276 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,9 +1,10 @@ export const printHelp = () => { - console.log(`exportman --config ./config.js[on] + console.log(`exportman --config ./config.js[on] [options] Options: --config [file] Path of configuration file (JS or JSON), requires \`entryFiles: []\` and \`filePatterns: []\` + --cwd Working directory (default: current working directory) --onlyFiles Report only unused files --onlyExports Report only unused exports --onlyTypes Report only unused types @@ -14,8 +15,8 @@ Options: Examples: $ exportman --config ./exportman.json - $ exportman --config ./exportman.js --onlyFiles --onlyDuplicates +$ exportman --config ./exportman.json --cwd packages/client More info: https://github.com/webpro/exportman`); }; diff --git a/src/index.ts b/src/index.ts index 7b9921d8a..5d190c41a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,13 +21,13 @@ export async function run(configuration: Configuration) { } = configuration; // Create workspace for entry files + resolved dependencies - const production = createProject(cwd, configuration.entryFiles); + const production = await createProject(cwd, configuration.entryFiles); const entryFiles = production.getSourceFiles(); production.resolveSourceFileDependencies(); const productionFiles = production.getSourceFiles(); // Create workspace for the entire project - const project = createProject(cwd, configuration.filePatterns); + const project = await createProject(cwd, configuration.filePatterns); const projectFiles = project.getSourceFiles(); // Slice & dice used & unused files diff --git a/src/types.ts b/src/types.ts index c18a010d4..5becc2133 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ -import { SourceFile } from 'ts-morph'; - -export type Configuration = { - cwd: string; +type LocalConfiguration = { entryFiles: string[]; filePatterns: string[]; +}; + +export type Configuration = LocalConfiguration & { + cwd: string; isShowProgress: boolean; isFindUnusedFiles?: boolean; isFindUnusedExports?: boolean; @@ -12,6 +13,8 @@ export type Configuration = { isFollowSymbols?: boolean; }; +export type ImportedConfiguration = LocalConfiguration | Record; + type FilePath = string; type Type = 'type' | 'interface' | 'enum'; diff --git a/src/util.ts b/src/util.ts index 89944aa16..a933251ee 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,14 +1,55 @@ +import fs from 'node:fs/promises'; import path from 'node:path'; import { ts, Project } from 'ts-morph'; +import micromatch from 'micromatch'; import type { SourceFile, ExportedDeclarations } from 'ts-morph'; +import type { ImportedConfiguration, Configuration } from './types'; -export const createProject = (cwd: string, paths?: string | string[]) => { +export const resolveConfig = (importedConfiguration: ImportedConfiguration, cwdArg?: string) => { + if (cwdArg && !('filePatterns' in importedConfiguration)) { + const importedConfigKey = Object.keys(importedConfiguration).find(pattern => micromatch.isMatch(cwdArg, pattern)); + if (importedConfigKey) { + return importedConfiguration[importedConfigKey]; + } + } + if (!cwdArg && !('filePatterns' in importedConfiguration)) { + console.error('Unable to find `filePatterns` in configuration.'); + console.info('Add it at root level, or use the --cwd argument with a matching configuration.\n'); + return; + } + return importedConfiguration as Configuration; +}; + +const isFile = async (filePath: string) => { + try { + const stats = await fs.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +}; + +const findFile = async (cwd: string, fileName: string): Promise => { + const filePath = path.join(cwd, fileName); + if (await isFile(filePath)) return filePath; + return findFile(path.resolve(cwd, '..'), fileName); +}; + +export const resolvePaths = (cwd: string, patterns: string | string[]) => { + return [patterns].flat().map(pattern => { + if (pattern.startsWith('!')) return '!' + path.join(cwd, pattern.slice(1)); + return path.join(cwd, pattern); + }); +}; + +export const createProject = async (cwd: string, paths?: string | string[]) => { + const tsConfigFilePath = await findFile(cwd, 'tsconfig.json'); const workspace = new Project({ - tsConfigFilePath: path.join(cwd, 'tsconfig.json'), + tsConfigFilePath, skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true }); - if (paths) workspace.addSourceFilesAtPaths(paths); + if (paths) workspace.addSourceFilesAtPaths(resolvePaths(cwd, paths)); return workspace; }; diff --git a/test/index.spec.ts b/test/index.spec.ts index a8f9a2a5a..416779d56 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,15 +1,12 @@ import test from 'node:test'; import assert from 'node:assert'; import { run } from '../src/index'; -import { SourceFile } from 'ts-morph'; - -const match = (sourceFile: SourceFile, filePath: string) => filePath.endsWith(filePath); test('run', async () => { const issues = await run({ cwd: 'test/fixtures/basic', - entryFiles: ['test/fixtures/basic/index.ts'], - filePatterns: ['test/fixtures/basic/*.ts'], + entryFiles: ['index.ts'], + filePatterns: ['*.ts'], isShowProgress: false, isFindUnusedFiles: true, isFindUnusedExports: true, diff --git a/test/resolveConfig.spec.ts b/test/resolveConfig.spec.ts new file mode 100644 index 000000000..100afa39c --- /dev/null +++ b/test/resolveConfig.spec.ts @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveConfig } from '../src/util'; + +const baseConfig = { entryFiles: ['index.ts'], filePatterns: ['*.ts'] }; + +test('resolveConfig (default)', async () => { + const config = resolveConfig({ ...baseConfig }); + assert.deepEqual(config, { entryFiles: ['index.ts'], filePatterns: ['*.ts'] }); +}); + +test('resolveConfig (static)', async () => { + const config = resolveConfig({ workspace: { ...baseConfig } }, 'workspace'); + assert.deepEqual(config, { entryFiles: ['index.ts'], filePatterns: ['*.ts'] }); +}); + +test('resolveConfig (dynamic)', async () => { + const config = resolveConfig({ 'packages/*': { ...baseConfig } }, 'packages/a'); + assert.deepEqual(config, { entryFiles: ['index.ts'], filePatterns: ['*.ts'] }); +});