scandirectory
Version:
Scan a directory recursively with a lot of control and power
517 lines (483 loc) • 15 kB
text/typescript
// builtin
import { join, resolve, basename as getBasename } from 'path'
import {
Stats,
readdir as readdirBuiltin,
stat as statBuiltin,
readFile as readFileBuiltin,
} from 'fs'
// external
import {
isIgnoredPath,
upgradeOptions,
Options as IgnoreOptions,
} from 'ignorefs'
/** Encoding types that will return a string */
export type StringEncoding = Exclude<BufferEncoding, 'binary'>
/** Encoding that will return a string, a buffer, or not be used at all. */
export type ResultEncoding = BufferEncoding | undefined
/** A type of result, unknown is a WIP. */
export type ResultType = 'unknown' | 'file' | 'dir'
/** The result object */
export interface Result<
Type extends ResultType,
Encoding extends ResultEncoding = undefined,
> {
absolutePath: string
relativePath: string
basename: string
directory: Type extends 'unknown'
? null
: Type extends 'dir'
? true
: Type extends 'file'
? false
: never
stats: Type extends 'unknown'
? null
: Type extends 'dir' | 'file'
? Stats
: never
parent: null | Result<'dir', Encoding>
children: Type extends 'unknown' | 'file'
? null
: Record<string, Result<ResultType, Encoding>>
data: Type extends 'unknown' | 'dir'
? null
: Type extends 'file'
? Encoding extends undefined
? null
: Encoding extends StringEncoding
? string
: Buffer
: never
}
/**
* A mapping of all relative paths to {@link Result}
* If you want a tree, specify {@link Options.includeRoot} as true and use `results['.'].children`
*/
export interface Results<Encoding extends ResultEncoding = undefined> {
[relativePath: string]: Result<'file' | 'dir', Encoding>
}
/**
* Support old action callback format for {@link scanDirectoryCompatibility}
* @deprecated use {@link ActionCallback} instead
* @returns `false` if you wish to discard this result and not recurse into it
*/
export type CompatibilityActionCallback<
Type extends 'file' | 'dir' = 'file' | 'dir',
> = (
absolutePath: string,
relativePath: string,
basename?: string,
stat?: Stats & {
directory: Type extends 'file' ? false : Type extends 'dir' ? true : boolean
}
) => boolean | void
/**
* Action callback for {@link scanDirectory}
* @returns `false` if you wish to discard this result and not recurse into it
*/
export type ActionCallback<
Type extends 'file' | 'dir' = 'file' | 'dir',
Encoding extends ResultEncoding = undefined,
> = (result: Result<Type, Encoding>) => boolean | void
/** Options to configure {@link scanDirectory} and its ignore handling. */
export interface BaseOptions<Encoding extends ResultEncoding = undefined>
extends IgnoreOptions {
/** Directory path to scan */
directory: string
/** Whether to traverse directories */
recurse?: boolean
/** The encoding to read files as */
encoding?: Encoding
/**
* Include the root {@link Options.directory} in the result listing?
* If you want pure tree results, set this to true, then use `results['.'].children` as your listing.
*/
includeRoot?: boolean
}
/** Options to configure {@link scanDirectory} and its ignore handling. */
export interface Options<Encoding extends ResultEncoding = undefined>
extends BaseOptions<Encoding> {
/** Callback invoked on files and directories */
action?: ActionCallback<'file' | 'dir', Encoding>
/** Callback invoked on files, set to false to ignore files */
fileAction?: ActionCallback<'file', Encoding> | false
/** Callback invoked on directories, set to false to ignore directories */
dirAction?: ActionCallback<'dir', Encoding> | false
}
/** List-style result for {@link scanDirectoryCompatibility} */
export type List<Encoding extends ResultEncoding = undefined> = {
[relativePath: string]:
| (Encoding extends undefined
? 'file'
: Encoding extends StringEncoding
? string
: Buffer)
| 'dir'
}
/** Tree-style result for {@link scanDirectoryCompatibility} */
export type Tree<Encoding extends ResultEncoding = undefined> = {
[relativePath: string]:
| (Encoding extends undefined
? true
: Encoding extends StringEncoding
? string
: Buffer)
| Tree<Encoding>
}
/** Generate results for {@link scanDirectoryCompatibility} */
export type CompatibilityNextCallback<
Encoding extends ResultEncoding = undefined,
> = (error?: Error | null, list?: List<Encoding>, tree?: Tree<Encoding>) => void
/** Options to configure {@link scanDirectoryCompatibility} and its ignore handling. */
export interface CompatibilityOptions<
Encoding extends ResultEncoding = undefined,
> extends Partial<BaseOptions<Encoding>> {
/** @deprecated alias for {@link Options.directory} */
path?: string
/** @deprecated use {@link options.encoding} */
readFiles?: never
/** @deprecated use {@link scanDirectory} */
next?: CompatibilityNextCallback<Encoding>
/** Callback invoked on files and directories */
action?:
| ActionCallback<'file' | 'dir', Encoding>
| CompatibilityActionCallback
/** Callback invoked on files, set to false to ignore files */
fileAction?:
| ActionCallback<'file', Encoding>
| CompatibilityActionCallback<'file'>
| false
/** Callback invoked on directories, set to false to ignore directories */
dirAction?:
| ActionCallback<'dir', Encoding>
| CompatibilityActionCallback<'file'>
| false
}
/** Get the string of a file */
async function readHelper(
path: string,
opts: { encoding: StringEncoding; signal?: AbortSignal }
): Promise<string>
/** Get the buffer of a file */
async function readHelper(
path: string,
opts: { encoding: 'binary'; signal?: AbortSignal }
): Promise<Buffer>
/** Get the string or buffer of a file */
async function readHelper(
path: string,
opts: { encoding: ResultEncoding; signal?: AbortSignal }
): Promise<string | Buffer> {
return new Promise(function (resolve, reject) {
readFileBuiltin(path, opts, function (err, data) {
if (err) reject(err)
else resolve(data)
})
})
}
/** Get the stat */
async function statsHelper(path: string): Promise<Stats> {
return new Promise(function (resolve, reject) {
statBuiltin(path, function (err, stats) {
if (err) reject(err)
else resolve(stats)
})
})
}
/** Get a list of files */
async function listHelper(directory: string): Promise<Array<string>> {
return new Promise(function (resolve, reject) {
readdirBuiltin(directory, function (err, files) {
if (err) reject(err)
else resolve(files)
})
})
}
/** Scan the contents of a directory */
export async function scanDirectory<Encoding extends ResultEncoding>(
opts: Options<Encoding>
): Promise<Results<Encoding>> {
// options
opts = Object.assign({}, opts)
if (opts.includeRoot == null) opts.includeRoot = false
if (opts.recurse == null) opts.recurse = true
if (opts.fileAction == null) opts.fileAction = opts.action
if (opts.dirAction == null) opts.dirAction = opts.action
// prepare
const pendingLists: Array<Result<'file' | 'dir', Encoding>> = [
{
absolutePath: resolve(opts.directory),
relativePath: '.',
basename: getBasename(opts.directory),
directory: true,
stats: await statsHelper(opts.directory),
parent: null,
children: null,
data: null,
},
]
const pendingStats: Array<Result<'unknown', Encoding>> = []
const pendingReads: Array<Result<'file', Encoding>> = []
const results: Results<Encoding> = {}
if (opts.includeRoot) results['.'] = pendingLists[0]
// add subsequent
const abort =
typeof AbortController !== 'undefined' ? new AbortController() : null // v14.7+
const signal = abort?.signal
try {
while (pendingLists.length || pendingStats.length || pendingReads.length) {
await Promise.all([
...pendingLists
.splice(0, pendingLists.length)
.map(async function (result) {
const files = (await listHelper(result.absolutePath)).sort()
result.children = {}
for (const basename of files) {
const child: Result<'unknown', Encoding> = {
absolutePath: join(result.absolutePath, basename),
relativePath: join(result.relativePath, basename),
basename,
directory: null,
stats: null,
parent: result,
children: null,
data: null,
}
result.children[child.basename] = child
pendingStats.push(child)
}
}),
...pendingStats
.splice(0, pendingStats.length)
.map(async function (wipResult) {
const stats = await statsHelper(wipResult.absolutePath)
const directory = stats.isDirectory()
const result: Result<'file' | 'dir', Encoding> = Object.assign(
wipResult,
{
directory,
stats,
parent: wipResult.parent,
children: null,
data: null,
}
)
// skip by ignore options?
if (isIgnoredPath(result, opts)) {
if (result.parent) delete result.parent.children[result.basename]
return
}
// dir or file
if (result.directory) {
// skip by dir action?
const skip =
opts.dirAction === false ||
(opts.dirAction && opts.dirAction(result) === false)
if (skip) {
if (result.parent)
delete result.parent.children[result.basename]
return
}
// save
results[result.relativePath] = result
if (opts.recurse) pendingLists.push(result)
} else {
// skip by file action?
const skip =
opts.fileAction === false ||
(opts.fileAction && opts.fileAction(result) === false)
if (skip) {
if (result.parent)
delete result.parent.children[result.basename]
return
}
// save
results[result.relativePath] = result
if (opts.encoding != null) pendingReads.push(result)
}
}),
...pendingReads
.splice(0, pendingReads.length)
.map(async function (result) {
if (opts.encoding === 'binary') {
;(result as Result<'file', 'binary'>).data = await readHelper(
result.absolutePath,
{
encoding: 'binary',
signal,
}
)
} else if (opts.encoding != null) {
;(result as Result<'file', StringEncoding>).data =
(await readHelper(result.absolutePath, {
encoding: opts.encoding,
signal,
})) as string
}
}),
])
}
// sort
const sortedResults: Results<Encoding> = {}
Object.keys(results)
.sort()
.forEach(function (key) {
sortedResults[key] = results[key]
})
return sortedResults
} catch (err) {
abort?.abort()
throw err
}
}
/** Scan the contents of a directory, with compatibility for scandirectory < v8 */
export default async function scanDirectoryCompatibility<
Encoding extends ResultEncoding = undefined,
>(
...args: Array<
| string
| CompatibilityNextCallback<Encoding>
| CompatibilityOptions<Encoding>
>
): Promise<Results<Encoding>> {
const opts: CompatibilityOptions<Encoding> = {}
try {
// parse arguments into options
args.forEach(function (arg) {
switch (typeof arg) {
case 'string':
opts.directory = arg
break
case 'function':
opts.next = arg
break
case 'object':
Object.assign(opts, arg)
break
default:
throw new Error(
'scandirectory: unknown argument: ' + JSON.stringify(arg)
)
}
})
// handle deprecations and verifications
if (opts.next) opts.includeRoot = true
if (opts.path != null) opts.directory = opts.path
if (opts.readFiles) {
throw new Error(
'scandirectory: readFiles renamed to encoding: if you used readFiles = true, use encoding = "utf8", if you used readFiles = "binary", use encoding = "binary"'
)
}
if (!opts.directory) {
throw new Error('scandirectory: path is needed')
}
// action callback compatibility
if (opts.action && opts.action.length >= 1) {
const actionCallback = opts.action
opts.action = function (result: Result<'file' | 'dir', Encoding>) {
return (actionCallback as CompatibilityActionCallback)(
result.absolutePath,
result.relativePath,
result.basename,
Object.assign({ directory: result.directory }, result.stats)
)
}
}
if (opts.dirAction && opts.dirAction.length >= 1) {
const dirActionCallback = opts.dirAction
opts.dirAction = function (result: Result<'dir', Encoding>) {
return (dirActionCallback as CompatibilityActionCallback)(
result.absolutePath,
result.relativePath,
result.basename,
Object.assign({ directory: result.directory }, result.stats)
)
}
}
if (opts.fileAction && opts.fileAction.length >= 1) {
const fileActionCallback = opts.fileAction
opts.fileAction = function (result: Result<'file', Encoding>) {
return (fileActionCallback as CompatibilityActionCallback)(
result.absolutePath,
result.relativePath,
result.basename,
Object.assign({ directory: result.directory }, result.stats)
)
}
}
// upgrade options and fetch results from modern api
const results = await scanDirectory(
upgradeOptions(opts) as Options<Encoding>
)
if (opts.next) {
if (opts.next.length === 1) {
opts.next(null)
} else {
const list: List<Encoding> = toList(results)
if (opts.next.length === 2) {
opts.next(null, list)
} else if (opts.next.length === 3) {
const tree: Tree<Encoding> = toTree(results)
opts.next(null, list, tree)
} else
throw new Error(
'scandirectory: next function must accept 1, 2, or 3 arguments'
)
}
}
return results
} catch (err: any) {
if (opts.next) {
opts.next(err)
return {}
} else {
throw err
}
}
}
/** Compatibility helper for {@link scanDirectoryCompatibility} to generate results compatible with {@link CompatibilityNextCallback} */
export function toList<Encoding extends ResultEncoding = undefined>(
results: Results<Encoding>
): List<Encoding> {
const list: List<Encoding> = {}
// Object.entries requires Node.js >= 8
for (const key of Object.keys(results)) {
const value = results[key]
list[key] = (value.directory ? 'dir' : value.data ?? 'file') as any
}
delete list['.']
return list
}
/** Compatibility helper for {@link scanDirectoryCompatibility} to generate results compatible with {@link CompatibilityNextCallback} */
export function toTree<Encoding extends ResultEncoding = undefined>(
results: Results<Encoding> | null,
descending: boolean = false
): Tree<Encoding> {
if (!results) return {}
const tree: Tree<Encoding> = {}
if (!descending) {
return toTree(results['.'].children, true)
}
// Object.entries requires Node.js >= 8
for (const key of Object.keys(results)) {
const value = results[key]
tree[key] = (
value.directory ? toTree(value.children, descending) : value.data ?? true
) as any
}
return tree
}
/** Convert {@link Results} into a non-recursive JSON string */
export function stringify(any: any, indentation?: string) {
return JSON.stringify(
any,
(key, value) => {
if (key === 'parent') return value.relativePath
return value
},
indentation
)
}