diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 99279de..703bf8d 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -45,6 +45,9 @@ jobs: - name: Check format run: npm run format-check + - name: Check types + run: npm run types-check + - name: Coveralls uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8 with: diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..e3f9081 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,56 @@ +/** + * Find a file given configuration and defaults describing lookup behavior. + * + * @param {LookupConfig | string} [pathObj] The user-provided configuration for file lookup. + * @param {LookupConfig} [defaultObj] Default configuration for file lookup. + * @returns {Found | null} Information about the found file or `null` if it cannot be found. + */ +export default function fined( + pathObj?: LookupConfig | string, + defaultObj?: LookupConfig, +): Found | null; +/** + * Object mapping of extensions to some value. + */ +export type ExtensionMap = { + [extension: string]: any; +}; +/** + * Configuration used to lookup a file. + */ +export type LookupConfig = { + /** + * The base path to search within. + */ + path?: string | null | undefined; + /** + * The name of the file without an extension. + */ + name?: string | null | undefined; + /** + * The extensions the file being looked up may have. + */ + extensions?: string | string[] | ExtensionMap | null | undefined; + /** + * The current working directory that `path + name` is resolved against. + */ + cwd?: string | null | undefined; + /** + * Whether file lookup should recurse up the directory structure. + */ + findUp?: boolean | null | undefined; +}; +/** + * Information about the found file. + */ +export type Found = { + /** + * The absolute path of the found file. + */ + path: string; + /** + * The extension that matched from configured extensions. + */ + extension: string | ExtensionMap; +}; +//# sourceMappingURL=index.d.ts.map diff --git a/index.d.ts.map b/index.d.ts.map new file mode 100644 index 0000000..0cd518f --- /dev/null +++ b/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AACH,wCAJW,YAAY,GAAG,MAAM,eACrB,YAAY,GACV,KAAK,GAAG,IAAI,CAKxB;;;;2BAGY;IAAE,CAAC,SAAS,EAAE,MAAM,GAAG,GAAC,CAAA;CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAczB,MAAM;;;;eACN,MAAM,GAAG,YAAY"} \ No newline at end of file diff --git a/index.js b/index.js index af3cc1d..22e188a 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,46 @@ import os from "node:os"; import fs from "node:fs"; import path from "node:path"; - +import process from "node:process"; + +/** + * Find a file given configuration and defaults describing lookup behavior. + * + * @param {LookupConfig | string} [pathObj] The user-provided configuration for file lookup. + * @param {LookupConfig} [defaultObj] Default configuration for file lookup. + * @returns {Found | null} Information about the found file or `null` if it cannot be found. + */ export default function fined(pathObj, defaultObj) { const expandedPath = expandPath(pathObj, defaultObj); return expandedPath ? findWithExpandedPath(expandedPath) : null; } +/** + * @typedef {{ [extension: string]: * }} ExtensionMap Object mapping of extensions to some value. + */ + +/** + * @typedef {object} LookupConfig Configuration used to lookup a file. + * @property {string | null} [path] The base path to search within. + * @property {string | null} [name] The name of the file without an extension. + * @property {string | Array | ExtensionMap | null} [extensions] The extensions the file being looked up may have. + * @property {string | null} [cwd] The current working directory that `path + name` is resolved against. + * @property {boolean | null} [findUp] Whether file lookup should recurse up the directory structure. + */ + +/** + * @typedef {object} Found Information about the found file. + * @property {string} path The absolute path of the found file. + * @property {string | ExtensionMap} extension The extension that matched from configured extensions. + */ + +/** + * Creates an expanded config by joining config & defaults and applying normalization. + * + * @param {LookupConfig | string} [pathObj] The user-provided configuration for file lookup. + * @param {LookupConfig} [defaultObj] Default configuration for file lookup. + * @returns Information about the found file or `null` if not found. + */ function expandPath(pathObj, defaultObj) { if (isString(pathObj)) { pathObj = { path: pathObj }; @@ -14,6 +48,7 @@ function expandPath(pathObj, defaultObj) { pathObj = defaults(pathObj, defaultObj); + /** @type string */ let filePath; if (!isString(pathObj.path)) { return null; @@ -33,6 +68,7 @@ function expandPath(pathObj, defaultObj) { let extArr = createExtensionArray(pathObj.extensions); const extMap = createExtensionMap(pathObj.extensions); + /** @type string */ let basedir = isString(pathObj.cwd) ? pathObj.cwd.toString() : "."; basedir = path.resolve(expandTilde(basedir)); @@ -64,6 +100,12 @@ function expandPath(pathObj, defaultObj) { }; } +/** + * Resolves an expanded configuration to a file. + * + * @param {NonNullable>} expanded The expanded configuration to lookup. + * @returns {Found | null} Information about the found file or `null` if it cannot be found. + */ function findWithExpandedPath(expanded) { const found = expanded.findUp ? findUpFile(expanded.basedir, expanded.path, expanded.extArr) @@ -74,13 +116,24 @@ function findWithExpandedPath(expanded) { } if (expanded.extMap) { - found.extension = pick(expanded.extMap, found.extension); + return { + ...found, + extension: pick(expanded.extMap, found.extension), + }; } return found; } -function findFile(basedir, relpath, extArr) { - const noExtPath = path.resolve(basedir, relpath); +/** + * Non-recursive lookup of file + each extension. + * + * @param {string} basedir The directory to resolve against. + * @param {string} filename The filename (without extension) to resolve. + * @param {Array} extArr The extensions to resolve against the filename. + * @returns Information about the found file or `null` if not found. + */ +function findFile(basedir, filename, extArr) { + const noExtPath = path.resolve(basedir, filename); for (const ext of extArr) { const filepath = noExtPath + ext; try { @@ -94,10 +147,18 @@ function findFile(basedir, relpath, extArr) { return null; } -function findUpFile(basedir, filepath, extArr) { +/** + * Recursive lookup of file + each extension. + * + * @param {string} basedir The directory to resolve against. + * @param {string} filename The filename (without extension) to resolve. + * @param {Array} extArr The extensions to resolve against the filename. + * @returns Information about the found file or `null` if not found. + */ +function findUpFile(basedir, filename, extArr) { let lastdir; do { - const found = findFile(basedir, filepath, extArr); + const found = findFile(basedir, filename, extArr); if (found) { return found; } @@ -109,24 +170,37 @@ function findUpFile(basedir, filepath, extArr) { return null; } +/** + * Builds an array of extensions to match against. Will always contain `[""]` at minimum. + * + * @param {string | Array | ExtensionMap | null} [exts] The configured extensions. + * @returns {Array} All extensions to lookup. + */ function createExtensionArray(exts) { if (isString(exts)) { return [exts]; } if (Array.isArray(exts)) { - exts = exts.filter(isString); - return exts.length > 0 ? exts : [""]; + const filteredExts = exts.filter(isString); + return filteredExts.length > 0 ? filteredExts : [""]; } if (isObject(exts)) { - exts = Object.keys(exts); - return exts.length > 0 ? exts : [""]; + const extKeys = Object.keys(exts); + return extKeys.length > 0 ? extKeys : [""]; } return [""]; } +/** + * Builds an object mapping of extensions to match against if provided as configuration. + * Will always contain `{ "": null }` if an empty object is provided. + * + * @param {?} exts The configured extensions. + * @returns {ExtensionMap | null} Object mapping of extensions if provided or `null` otherwise. + */ function createExtensionMap(exts) { if (!isObject(exts)) { return null; @@ -139,6 +213,13 @@ function createExtensionMap(exts) { return exts; } +/** + * Creates a new object with values defaulted if not provided by `userObj`. + * + * @param {LookupConfig} [userObj] The user-provided object. + * @param {LookupConfig} [defaultObj] An object providing default values. + * @returns {LookupConfig} The new object with values defaulted. + */ function defaults(userObj, defaultObj) { if (!isObject(userObj)) { userObj = {}; @@ -155,19 +236,44 @@ function defaults(userObj, defaultObj) { return Object.assign({}, defaultObj, filteredObj); } +/** + * Creates a new object by extracting exactly the matched key, or an empty object if no match. + * + * @param {{ [s: string]: *; }} map The original object to pick from. + * @param {string} match The key to pick. + * @returns {{ [s: string]: *; }} The new object with only the matched key, otherwise an empty object if no match. + */ function pick(map, match) { const entries = Object.entries(map).filter(([key]) => key === match); return Object.fromEntries(entries); } +/** + * Determines in a value is a non-null, non-array object. + * + * @param {?} value The value to check. + * @returns {value is {}} `true` if the value is a non-null, non-array object. + */ function isObject(value) { return value && typeof value === "object" && !Array.isArray(value); } +/** + * Determines if an object has no keys. + * + * @param {object} object The object to check. + * @returns {boolean} `true` if the object has no keys, otherwise `false`. + */ function isEmpty(object) { return !Object.keys(object).length; } +/** + * Determines if a vale is a string or String object. + * + * @param {?} value The value to check. + * @returns {value is string | String} `true` if the value is a string or String object, otherwise `false`. + */ function isString(value) { if (typeof value === "string") { return true; @@ -184,6 +290,12 @@ function isString(value) { * Copyright (c) 2015 Jon Schlinkert. * Licensed under the MIT license. */ +/** + * Expands `~` and `~+` at the start of a file path to home or pwd respectively. + * + * @param {string} filepath The file path to expand. + * @returns {string} A file path with `~` and `~+` expanded. + */ function expandTilde(filepath) { const home = os.homedir(); diff --git a/node.d.ts b/node.d.ts new file mode 100644 index 0000000..fbfd90f --- /dev/null +++ b/node.d.ts @@ -0,0 +1,19 @@ +declare module "node:process" { + function cwd(): string; +} + +declare module "node:os" { + function homedir(): string; +} + +declare module "node:path" { + function join(p1: string, p2: string): string; + function resolve(p1: string, p2?: string): string; + function parse(p1: string): { root: string; ext: string }; + function isAbsolute(p1: string): boolean; + function dirname(p1: string): string; +} + +declare module "node:fs" { + function statSync(p1: string): asserts p1; +} diff --git a/package-lock.json b/package-lock.json index c5eea55..5b3e116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "devDependencies": { "eslint": "10.11.0", "eslint-config-gulp": "7.0.0", - "prettier": "3.9.6" + "prettier": "3.9.6", + "typescript": "6.0.3" }, "engines": { "node": ">= 22.15.0" @@ -994,6 +995,20 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index bef4212..e96d617 100644 --- a/package.json +++ b/package.json @@ -18,22 +18,28 @@ }, "type": "module", "main": "./index.js", + "types": "./index.d.ts", "exports": "./index.js", "files": [ "index.js", + "index.d.ts", + "index.d.ts.map", "LICENSE" ], "scripts": { "format-check": "prettier . --check", "format-write": "prettier . --write", "lint": "eslint .", - "test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=test/lcov.info \"test/*.js\"" + "test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=test/lcov.info \"test/*.js\"", + "types-check": "tsc index.js --checkJs --noEmit --types ./node.d.ts", + "types-write": "tsc index.js --checkJs --declaration --declarationMap --emitDeclarationOnly --types ./node.d.ts" }, "dependencies": {}, "devDependencies": { "eslint": "10.11.0", "eslint-config-gulp": "7.0.0", - "prettier": "3.9.6" + "prettier": "3.9.6", + "typescript": "6.0.3" }, "keywords": [ "find",