Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 122 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
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<string> | 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 };
}

pathObj = defaults(pathObj, defaultObj);

/** @type string */
let filePath;
if (!isString(pathObj.path)) {
return null;
Expand All @@ -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));

Expand Down Expand Up @@ -64,6 +100,12 @@ function expandPath(pathObj, defaultObj) {
};
}

/**
* Resolves an expanded configuration to a file.
*
* @param {NonNullable<ReturnType<typeof expandPath>>} 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)
Expand All @@ -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<string>} 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 {
Expand All @@ -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<string>} 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;
}
Expand All @@ -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<string> | ExtensionMap | null} [exts] The configured extensions.
* @returns {Array<string>} 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;
Expand All @@ -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 = {};
Expand All @@ -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;
Expand All @@ -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();

Expand Down
19 changes: 19 additions & 0 deletions node.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 16 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading