diff --git a/changelog.md b/changelog.md index 1f3a254..44fd8f5 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,12 @@ ## Next +### Minor + +- Added support for [import maps](https://github.com/WICG/import-maps), fixing [#4](https://github.com/jaydenseric/find-unused-exports/issues/4): + - Added the CLI command `find-unused-exports` argument `--import-map`. + - Added the function `findUnusedExports` option `importMap`. + ### Patch - Updated dependencies. diff --git a/directoryPathToFileURL.mjs b/directoryPathToFileURL.mjs new file mode 100644 index 0000000..870ac58 --- /dev/null +++ b/directoryPathToFileURL.mjs @@ -0,0 +1,19 @@ +// @ts-check + +import { pathToFileURL } from "node:url"; + +/** + * Converts a directory path to a file URL that always ends with `/` so it can + * be safely used as a base URL for constructing file URLs with relative paths. + * @param {string} directoryPath Directory path to convert. + * @returns {URL} Directory file URL. + */ +export default function directoryPathToFileURL(directoryPath) { + if (typeof directoryPath !== "string") + throw new TypeError("Argument 1 `directoryPath` must be a string."); + + // @ts-ignore https://github.com/microsoft/TypeScript/issues/59996 + return pathToFileURL( + directoryPath.endsWith("/") ? directoryPath : `${directoryPath}/`, + ); +} diff --git a/directoryPathToFileURL.test.mjs b/directoryPathToFileURL.test.mjs new file mode 100644 index 0000000..3659705 --- /dev/null +++ b/directoryPathToFileURL.test.mjs @@ -0,0 +1,35 @@ +// @ts-check + +import { deepStrictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import directoryPathToFileURL from "./directoryPathToFileURL.mjs"; + +describe("Function `directoryPathToFileURL`.", { concurrency: true }, () => { + it("Argument 1 `directoryPath` not a string.", () => { + throws(() => { + directoryPathToFileURL( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `directoryPath` must be a string.")); + }); + + it("Directory path ends with `/`.", () => { + const directoryPath = "/a/b/c/"; + + deepStrictEqual( + directoryPathToFileURL(directoryPath), + new URL(`file://${directoryPath}`), + ); + }); + + it("Directory path doesn’t end with `/`.", () => { + const directoryPath = "/a/b/c"; + + deepStrictEqual( + directoryPathToFileURL(directoryPath), + new URL(`file://${directoryPath}/`), + ); + }); +}); diff --git a/find-unused-exports.mjs b/find-unused-exports.mjs index 7e6fab8..d041412 100755 --- a/find-unused-exports.mjs +++ b/find-unused-exports.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node // @ts-check +/** @import { ImportMap } from "@import-maps/resolve" */ + import { relative } from "node:path"; import arg from "arg"; @@ -18,21 +20,35 @@ import reportCliError from "./reportCliError.mjs"; async function findUnusedExportsCli() { try { const { + "--import-map": importMapJson, "--module-glob": moduleGlob, "--resolve-file-extensions": resolveFileExtensionsList, "--resolve-index-files": resolveIndexFiles, } = arg({ + "--import-map": String, "--module-glob": String, "--resolve-file-extensions": String, "--resolve-index-files": Boolean, }); + /** @type {ImportMap | undefined} */ + let importMap; + + if (importMapJson) { + try { + importMap = JSON.parse(importMapJson); + } catch { + throw new CliError(`The \`--import-map\` argument must be JSON.`); + } + } + if (resolveIndexFiles && !resolveFileExtensionsList) throw new CliError( "The `--resolve-index-files` flag can only be used with the `--resolve-file-extensions` argument.", ); const unusedExports = await findUnusedExports({ + importMap, moduleGlob, resolveFileExtensions: resolveFileExtensionsList ? resolveFileExtensionsList.split(",") diff --git a/find-unused-exports.test.mjs b/find-unused-exports.test.mjs index eb53e1e..1db2de0 100644 --- a/find-unused-exports.test.mjs +++ b/find-unused-exports.test.mjs @@ -91,6 +91,65 @@ describe("CLI command `find-unused-exports`.", { concurrency: true }, () => { strictEqual(status, 1); }); + describe("Arg `--import-map`.", { concurrency: true }, () => { + it("Invalid.", async () => { + const { stdout, stderr, status, error } = spawnSync( + "node", + [FIND_UNUSED_EXPORTS_CLI_PATH, "--import-map", "_"], + { + cwd: new URL("./test/fixtures/import-map", import.meta.url), + env: { + ...process.env, + FORCE_COLOR: "1", + }, + }, + ); + + if (error) throw error; + + strictEqual(stdout.toString(), ""); + await assertSnapshot( + stderr.toString(), + new URL( + "./test/snapshots/find-unused-exports/import-map-invalid-stderr.ans", + import.meta.url, + ), + ); + strictEqual(status, 1); + }); + + it("Valid.", async () => { + const { stdout, stderr, status, error } = spawnSync( + "node", + [ + FIND_UNUSED_EXPORTS_CLI_PATH, + "--import-map", + '"$(cat import-map.json)"', + ], + { + cwd: new URL("./test/fixtures/import-map", import.meta.url), + env: { + ...process.env, + FORCE_COLOR: "1", + }, + shell: true, + }, + ); + + if (error) throw error; + + strictEqual(stdout.toString(), ""); + await assertSnapshot( + stderr.toString(), + new URL( + "./test/snapshots/find-unused-exports/import-map-valid-stderr.ans", + import.meta.url, + ), + ); + strictEqual(status, 1); + }); + }); + it("Arg `--module-glob`.", async () => { const { stdout, stderr, status, error } = spawnSync( "node", diff --git a/findUnusedExports.mjs b/findUnusedExports.mjs index cb7eccf..8fbb339 100644 --- a/findUnusedExports.mjs +++ b/findUnusedExports.mjs @@ -1,12 +1,18 @@ // @ts-check -/** @import { ModuleExports, ModuleScan } from "./scanModuleCode.mjs" */ +/** + * @import { ImportMap, ParsedImportMap } from "@import-maps/resolve" + * @import { ModuleExports, ModuleScan } from "./scanModuleCode.mjs" + */ import { readFile } from "node:fs/promises"; -import { dirname, extname, join, resolve, sep } from "node:path"; +import { extname, join, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parse, resolve as resolveImport } from "@import-maps/resolve"; import { globby } from "globby"; +import directoryPathToFileURL from "./directoryPathToFileURL.mjs"; import isDirectoryPath from "./isDirectoryPath.mjs"; import MODULE_GLOB from "./MODULE_GLOB.mjs"; import scanModuleCode from "./scanModuleCode.mjs"; @@ -18,6 +24,10 @@ import scanModuleCode from "./scanModuleCode.mjs"; * @param {object} [options] Options. * @param {string} [options.cwd] A directory path to scope the search for source * and `.gitignore` files, defaulting to `process.cwd()`. + * @param {ImportMap} [options.importMap] + * [Import map](https://github.com/WICG/import-maps) that’s relative to the + * current working directory specified by the option {@linkcode cwd}. Defaults + * to `{}`. * @param {string} [options.moduleGlob] JavaScript file glob pattern. Defaults * to {@linkcode MODULE_GLOB}. * @param {Array} [options.resolveFileExtensions] File extensions @@ -40,6 +50,7 @@ import scanModuleCode from "./scanModuleCode.mjs"; */ export default async function findUnusedExports({ cwd = process.cwd(), + importMap = {}, moduleGlob = MODULE_GLOB, resolveFileExtensions, resolveIndexFiles = false, @@ -50,6 +61,19 @@ export default async function findUnusedExports({ if (!(await isDirectoryPath(cwd))) throw new TypeError("Option `cwd` must be an accessible directory path."); + const cwdUrl = directoryPathToFileURL(cwd); + + /** @type {ParsedImportMap} */ + let parsedImportMap; + + try { + parsedImportMap = parse(importMap, cwdUrl); + } catch (cause) { + throw new TypeError("Option `importMap` must be a valid import map.", { + cause, + }); + } + if (typeof moduleGlob !== "string") throw new TypeError("Option `moduleGlob` must be a string."); @@ -110,95 +134,98 @@ export default async function findUnusedExports({ for (const [path, { exports }] of Object.entries(scannedModules)) if (exports.size) possiblyUnusedExports[path] = exports; - // Bail if the specifier is bare; this tool only scans project files. for (const [path, { imports }] of Object.entries(scannedModules)) - for (const [specifier, moduleImports] of Object.entries(imports)) - if (specifier.startsWith(".")) { - const specifierAbsolutePath = resolve(dirname(path), specifier); - const specifierExtension = extname(specifierAbsolutePath); - const specifierPossiblePaths = [specifierAbsolutePath]; - - switch (specifierExtension) { - // TypeScript import specifiers may use the `.mjs` file extension to - // resolve an `.mts` file in that directory with the same name. - case ".mjs": { - specifierPossiblePaths.push( - `${specifierAbsolutePath.slice( - 0, - -specifierExtension.length, - )}.mts`, - ); - break; - } + for (const [specifier, moduleImports] of Object.entries(imports)) { + const { resolvedImport } = resolveImport( + specifier, + parsedImportMap, + // @ts-ignore https://github.com/microsoft/TypeScript/issues/59996 + pathToFileURL(path), + ); - // TypeScript import specifiers may use the `.cjs` file extension to - // resolve a `.cts` file in that directory with the same name. - case ".cjs": { - specifierPossiblePaths.push( - `${specifierAbsolutePath.slice( - 0, - -specifierExtension.length, - )}.cts`, - ); - break; - } + // Bail if the specifier is bare and couldn’t be resolved by the import + // map; this tool only scans project files. + if (!resolvedImport) continue; + + const specifierAbsolutePath = fileURLToPath(resolvedImport); + const specifierExtension = extname(specifierAbsolutePath); + const specifierPossiblePaths = [specifierAbsolutePath]; + + switch (specifierExtension) { + // TypeScript import specifiers may use the `.mjs` file extension to + // resolve an `.mts` file in that directory with the same name. + case ".mjs": { + specifierPossiblePaths.push( + `${specifierAbsolutePath.slice(0, -specifierExtension.length)}.mts`, + ); + break; + } - // TypeScript import specifiers may use the `.js` file extension to - // resolve a `.ts` or `.tsx` file in that directory with the same - // name. - case ".js": { - const pathWithoutExtension = specifierAbsolutePath.slice( - 0, - -specifierExtension.length, - ); - - specifierPossiblePaths.push( - `${pathWithoutExtension}.ts`, - `${pathWithoutExtension}.tsx`, - ); - break; - } + // TypeScript import specifiers may use the `.cjs` file extension to + // resolve a `.cts` file in that directory with the same name. + case ".cjs": { + specifierPossiblePaths.push( + `${specifierAbsolutePath.slice(0, -specifierExtension.length)}.cts`, + ); + break; + } + + // TypeScript import specifiers may use the `.js` file extension to + // resolve a `.ts` or `.tsx` file in that directory with the same + // name. + case ".js": { + const pathWithoutExtension = specifierAbsolutePath.slice( + 0, + -specifierExtension.length, + ); + + specifierPossiblePaths.push( + `${pathWithoutExtension}.ts`, + `${pathWithoutExtension}.tsx`, + ); + break; + } - // No file extension. - case "": { - if (resolveFileExtensions) { + // No file extension. + case "": { + if (resolveFileExtensions) { + for (const extension of resolveFileExtensions) + specifierPossiblePaths.push( + `${specifierAbsolutePath}.${extension}`, + ); + + if (resolveIndexFiles) for (const extension of resolveFileExtensions) specifierPossiblePaths.push( - `${specifierAbsolutePath}.${extension}`, + `${specifierAbsolutePath}${sep}index.${extension}`, ); - - if (resolveIndexFiles) - for (const extension of resolveFileExtensions) - specifierPossiblePaths.push( - `${specifierAbsolutePath}${sep}index.${extension}`, - ); - } } } + } - // If there’s no match for the imported module in the map of (so far) - // unused exports it means either none of the imported module’s exports - // remain unused, or the import is simply unresolvable (not an issue for - // this tool). - const importedModulePath = specifierPossiblePaths.find( - (path) => path in possiblyUnusedExports, - ); - - if (importedModulePath) { - // If a namespace import (`import * as`) imported all exports of the - // module, delete every export from the unused exports set. Otherwise, - // delete only the imported exports from the unused exports set. - for (const name of moduleImports.has("*") - ? possiblyUnusedExports[importedModulePath] - : moduleImports) - possiblyUnusedExports[importedModulePath].delete(name); - - // Check if the module still has possibly unused exports. - if (!possiblyUnusedExports[importedModulePath].size) - // Delete the file from the map of unused exports. - delete possiblyUnusedExports[importedModulePath]; - } + // If there’s no match for the imported module in the map of (so far) + // unused exports it means either none of the imported module’s exports + // remain unused, or the import is simply unresolvable (not an issue for + // this tool). + const importedModulePath = specifierPossiblePaths.find( + (path) => path in possiblyUnusedExports, + ); + + if (importedModulePath) { + // If a namespace import (`import * as`) imported all exports of the + // module, delete every export from the unused exports set. Otherwise, + // delete only the imported exports from the unused exports set. + for (const name of moduleImports.has("*") + ? possiblyUnusedExports[importedModulePath] + : moduleImports) + possiblyUnusedExports[importedModulePath].delete(name); + + // Check if the module still has possibly unused exports. + if (!possiblyUnusedExports[importedModulePath].size) + // Delete the file from the map of unused exports. + delete possiblyUnusedExports[importedModulePath]; } + } return possiblyUnusedExports; } diff --git a/findUnusedExports.test.mjs b/findUnusedExports.test.mjs index f26a338..b8878a4 100644 --- a/findUnusedExports.test.mjs +++ b/findUnusedExports.test.mjs @@ -1,6 +1,6 @@ // @ts-check -import { deepStrictEqual, rejects } from "node:assert"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert"; import { join, resolve } from "node:path"; import { describe, it } from "node:test"; import { fileURLToPath } from "node:url"; @@ -138,6 +138,46 @@ describe("Function `findUnusedExports`.", { concurrency: true }, () => { ); }); + describe("Option `importMap`.", { concurrency: true }, () => { + it("Invalid.", async () => { + await rejects( + findUnusedExports({ + // @ts-expect-error Testing invalid. + importMap: true, + }), + (error) => { + ok(error instanceof TypeError); + strictEqual( + error.message, + "Option `importMap` must be a valid import map.", + ); + ok(error.cause instanceof TypeError); + return true; + }, + ); + }); + + it("Valid.", async () => { + const fixtureProjectPath = fileURLToPath( + new URL("./test/fixtures/import-map", import.meta.url), + ); + + deepStrictEqual( + await findUnusedExports({ + cwd: fixtureProjectPath, + importMap: { + imports: { + "#a": "./a.mjs", + }, + }, + }), + { + [join(fixtureProjectPath, "b.mjs")]: new Set(["default"]), + }, + ); + }); + }); + describe("Option `moduleGlob`.", { concurrency: true }, () => { it("Not a string.", async () => { await rejects( diff --git a/package.json b/package.json index 7743816..54597e3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ ], "files": [ "CliError.mjs", + "directoryPathToFileURL.mjs", "errorConsole.mjs", "find-unused-exports.mjs", "findUnusedExports.mjs", @@ -51,6 +52,7 @@ }, "dependencies": { "@babel/core": "^7.25.7", + "@import-maps/resolve": "^2.0.0", "@types/babel__core": "^7.20.5", "@types/node": "*", "arg": "^5.0.2", diff --git a/readme.md b/readme.md index 0b25ffa..aeac434 100644 --- a/readme.md +++ b/readme.md @@ -94,6 +94,7 @@ It implements the function [`findUnusedExports`](./findUnusedExports.mjs). | Argument | Default | Description | | :-- | :-- | :-- | +| `--import-map` | `"{}"` | JSON [import map](https://github.com/WICG/import-maps) that’s relative to the current working directory. | | `--module-glob` | `"**/{!(*.d).mts,!(*.d).cts,!(*.d).ts,*.{mjs,cjs,js,jsx,tsx}}"` | Module file glob pattern. | | `--resolve-file-extensions` | | File extensions (without the leading `.`, multiple separated with `,` in preference order) to automatically resolve in extensionless import specifiers. [Import specifier file extensions are mandatory in Node.js](https://nodejs.org/api/esm.html#mandatory-file-extensions); if your project resolves extensionless imports at build time (e.g. [Next.js](https://nextjs.org), via [webpack](https://webpack.js.org)) `mjs,js` might be appropriate. | | `--resolve-index-files` | | Should directory index files be automatically resolved in extensionless import specifiers. [Node.js doesn’t do this by default](https://nodejs.org/api/esm.html#mandatory-file-extensions); if your project resolves extensionless imports at build time (e.g. [Next.js](https://nextjs.org), via [webpack](https://webpack.js.org)) this argument might be appropriate. This argument only works if the argument `--resolve-file-extensions` is used. | @@ -112,6 +113,12 @@ Using [`npx`](https://docs.npmjs.com/cli/v10/commands/npx) in a typical [webpack npx find-unused-exports --module-glob "**/*.js" --resolve-file-extensions js --resolve-index-files ``` +Using [`npx`](https://docs.npmjs.com/cli/v10/commands/npx) in a project with an [import map](https://github.com/WICG/import-maps) in a file `import-map.json`: + +```sh +npx find-unused-exports --import-map "$(cat import-map.json)" +``` + [`package.json` scripts](https://docs.npmjs.com/cli/v10/using-npm/scripts) for a project that also uses [`eslint`](https://npm.im/eslint) and [`prettier`](https://npm.im/prettier): ```json diff --git a/test/fixtures/import-map/a.mjs b/test/fixtures/import-map/a.mjs new file mode 100644 index 0000000..ff3177b --- /dev/null +++ b/test/fixtures/import-map/a.mjs @@ -0,0 +1 @@ +export default true; diff --git a/test/fixtures/import-map/b.mjs b/test/fixtures/import-map/b.mjs new file mode 100644 index 0000000..e584c59 --- /dev/null +++ b/test/fixtures/import-map/b.mjs @@ -0,0 +1,3 @@ +import a from "#a"; + +export default a; diff --git a/test/fixtures/import-map/import-map.json b/test/fixtures/import-map/import-map.json new file mode 100644 index 0000000..26b2dca --- /dev/null +++ b/test/fixtures/import-map/import-map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "#a": "./a.mjs" + } +} diff --git a/test/snapshots/find-unused-exports/import-map-invalid-stderr.ans b/test/snapshots/find-unused-exports/import-map-invalid-stderr.ans new file mode 100644 index 0000000..cb51893 --- /dev/null +++ b/test/snapshots/find-unused-exports/import-map-invalid-stderr.ans @@ -0,0 +1,5 @@ + +Error running find-unused-exports: + + The `--import-map` argument must be JSON. + diff --git a/test/snapshots/find-unused-exports/import-map-valid-stderr.ans b/test/snapshots/find-unused-exports/import-map-valid-stderr.ans new file mode 100644 index 0000000..3933712 --- /dev/null +++ b/test/snapshots/find-unused-exports/import-map-valid-stderr.ans @@ -0,0 +1,6 @@ + +b.mjs + default + +1 unused export in 1 module. +