diff --git a/src/fileFinders.ts b/src/fileFinders.ts index dd5ca3b..f8980bf 100644 --- a/src/fileFinders.ts +++ b/src/fileFinders.ts @@ -6,7 +6,14 @@ import { FilterSync, filterSync, } from "./filter"; -import { asyncIterableToArray } from "./iterable"; +import { + allElements, + allElementsSync, + firstElement, + firstElementSync, + strictFirstElement, + strictFirstElementSync, +} from "./iterable"; import { readdir, readdirs, readdirsSync, readdirSync } from "./readdirs"; import { AllFilesFinder, AllFilesFinderSync } from "./allFilesFinder"; @@ -83,15 +90,9 @@ export const findFile: FileFinder = async ( ...filters: Array | FilterSync> ): Promise => { [directories, filters] = handleFunctionOverload(directories, filters); - if (filters.length > 0) { - for await (const file of filter( - readdirs(directories), - conjunction(filters), - )) { - return file; - } - } - return null; + return filters.length > 0 + ? firstElement(filter(readdirs(directories), conjunction(filters))) + : null; }; /** @@ -102,29 +103,13 @@ export const findFileSync: FileFinderSync = ( ...filters: Array> ): string | null => { [directories, filters] = handleFunctionOverloadSync(directories, filters); - if (filters.length > 0) { - for (const file of filterSync( - readdirsSync(directories), - conjunctionSync(filters), - )) { - return file; - } - } - return null; + return filters.length > 0 + ? firstElementSync( + filterSync(readdirsSync(directories), conjunctionSync(filters)), + ) + : null; }; -/** - * Constructs a conflicting files error for files found in a strict file finder. - * @param files The conflicting files. - * @returns An error to display the paths of the conflicting paths. - */ -const conflictingFilesError = (...files: string[]) => - new Error( - `Conflicting files as they match in the same directory:\n${files.join( - "\n", - )}`, - ); - /** * @see [[StrictFileFinder]] */ @@ -140,18 +125,11 @@ export const strictFindFile: StrictFileFinder = async ( [directories, filters] = handleFunctionOverload(directories, filters); if (filters.length > 0) { for await (const directory of directories) { - let retainedMatch: string | undefined; - for await (const match of filter( - readdir(directory), - conjunction(filters), - )) { - if (retainedMatch) { - throw conflictingFilesError(retainedMatch, match); - } - retainedMatch = match; - } - if (retainedMatch) { - return retainedMatch; + const match = await strictFirstElement( + filter(readdir(directory), conjunction(filters)), + ); + if (match) { + return match; } } } @@ -168,18 +146,11 @@ export const strictFindFileSync: StrictFileFinderSync = ( [directories, filters] = handleFunctionOverloadSync(directories, filters); if (filters.length > 0) { for (const directory of directories) { - let retainedMatch: string | undefined; - for (const match of filterSync( - readdirSync(directory), - conjunctionSync(filters), - )) { - if (retainedMatch) { - throw conflictingFilesError(retainedMatch, match); - } - retainedMatch = match; - } - if (retainedMatch) { - return retainedMatch; + const match = strictFirstElementSync( + filterSync(readdirSync(directory), conjunctionSync(filters)), + ); + if (match) { + return match; } } } @@ -201,9 +172,12 @@ export const findAllFiles: AllFilesFinder = async ( [directories, filters] = handleFunctionOverload(directories, filters); return filters.length === 0 ? [] - : asyncIterableToArray(filter(readdirs(directories), conjunction(filters))); + : allElements(filter(readdirs(directories), conjunction(filters))); }; +/** + * @see [[AllFilesFinderSync]] + */ export const findAllFilesSync: AllFilesFinderSync = ( directories?: string | Iterable | FilterSync, ...filters: Array> @@ -211,5 +185,7 @@ export const findAllFilesSync: AllFilesFinderSync = ( [directories, filters] = handleFunctionOverloadSync(directories, filters); return filters.length === 0 ? [] - : [...filterSync(readdirsSync(directories), conjunctionSync(filters))]; + : allElementsSync( + filterSync(readdirsSync(directories), conjunctionSync(filters)), + ); }; diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..63fbb9b --- /dev/null +++ b/src/files.ts @@ -0,0 +1,381 @@ +import { realpath, realpathSync } from "fs"; +import { resolve } from "path"; +import { promisify } from "util"; + +import { readdir, readdirSync } from "./readdirs"; +import { isDirectory, isDirectorySync } from "./stat"; + +/** + * A directory in the file system has a path and a depth relative to another + * directory. + */ +interface Directory { + /** + * The path to this directory. + */ + path: string; + + /** + * The depth of this directory with respect to the traversal it is subject to. + */ + depth: number; +} + +/** + * Constructs a directory at the start of a downward traversal. + * @param path The path to the directory. In order to determine whether or not a + * directory has been traversed, its real path should not be equal to any of the + * traversed directory paths. + * @returns A directory whose depth is `0` at the start of the downward + * traversal. + */ +const startingDirectory = (path: string): Directory => ({ path, depth: 0 }); + +/** + * Constructs a subdirectory given its path and its parent. + * @param path The path to the subdirectory. + * @param parent The parent directory. + * @returns A directory at the given path whose depth is `1` greater than that + * of its parent. + */ +const subdirectory = (path: string, parent: Directory) => ({ + path, + depth: parent.depth + 1, +}); + +/** + * Constructs a function which determines whether or not a path has been + * traversed. + * @param paths The set of traversed paths. + * @returns A function which determines whether or not a path has been + * traversed. + */ +const pathHasNotBeenTraversed = (paths: Iterable) => (path: string) => { + for (const query of paths) { + if (query === path) { + return false; + } + } + return true; +}; + +const realpathNative = promisify(realpath.native); +const realpathNativeSync = realpathSync.native; + +/** + * Constructs an error for a negative maximum depth for downward file fetchers. + * @param maximumDepth The input maximum depth. + * @returns An error for a negative maximum depth. + */ +const negativeMaximumDepthError = (maximumDepth: number) => + new Error(`The maximum depth of ${maximumDepth} should be positive`); + +/** + * Handles the function overload of downward file fetchers. + * @param startDirectory The first argument of the function. + * @param maximumDepth The second argument of the function. + * @returns The validated arguments for the downward file fetchers function + * call. + */ +const handleFunctionOverload = ( + startDirectory: number | string = ".", + maximumDepth?: number, +): [string, number] => { + if (typeof startDirectory === "number") { + maximumDepth = startDirectory; + startDirectory = "."; + } + return [startDirectory, maximumDepth]; +}; + +/** + * A downward files fetcher constructs an iterator over the files downwards from + * a given directory path. + */ +interface DownwardFilesFetcher extends Function { + /** + * Constructs an iterator over the downward files starting from the current + * working directory. Symbolic links are followed, and the directories are + * traversed in breadth-first order. Directories are read only once. + * @returns An iterator over the downward files. + */ + (): AsyncIterable; + + /** + * Constructs an iterator over the downward files starting from the current + * working directory and down to a given maximum depth of a directory. + * Symbolic links are followed, and the directories are traversed in + * breadth-first order. Directories are read only once. + * @param maximumDepth The maximum depth of a read directory relative to the + * start directory. This maximum depth should be zero or positive. + * @throws If the maximum depth is negative. + * @returns An iterator over the downward files down to the maximum depth. + */ + // tslint:disable-next-line:unified-signatures + (maximumDepth: number): AsyncIterable; + + /** + * Constructs an iterator over the downward files starting from a given path. + * Symbolic links are followed, and the directories are traversed in + * breadth-first order. Directories are read only once. + * @param startDirectory The starting directory from which to start the + * downward traversal. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @returns An iterator over the downward files. + */ + // tslint:disable-next-line:unified-signatures + (startDirectory: string): AsyncIterable; + + /** + * Constructs an iterator over the downward files starting from a given path + * and down to a given maximum depth of a directory. Symbolic links are + * followed, and the directories are traversed in breadth-first order. + * Directories are read only once. + * @param startDirectory The starting directory from which to start the + * downward traversal. + * @param maximumDepth The maximum depth of a read directory relative to the + * start directory. This maximum depth should be zero or positive. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @throws If the maximum depth is negative. + * @returns An iterator over the downward files down to the maximum depth. + */ + // tslint:disable-next-line:unified-signatures + (startDirectory: string, maximumDepth: number): AsyncIterable; +} + +/** + * A downward files fetcher constructs an iterator over the files downwards from + * a given directory path. + */ +interface DownwardFilesFetcherSync extends Function { + /** + * Constructs an iterator over the downward files starting from the current + * working directory. Symbolic links are followed, and the directories are + * traversed in breadth-first order. Directories are read only once. + * @returns An iterator over the downward files. + */ + (): Iterable; + + /** + * Constructs an iterator over the downward files starting from the current + * working directory and down to a given maximum depth of a directory. + * Symbolic links are followed, and the directories are traversed in + * breadth-first order. Directories are read only once. + * @param maximumDepth The maximum depth of a read directory relative to the + * start directory. This maximum depth should be zero or positive. + * @throws If the maximum depth is negative. + * @returns An iterator over the downward files down to the maximum depth. + */ + // tslint:disable-next-line:unified-signatures + (maximumDepth: number): Iterable; + + /** + * Constructs an iterator over the downward files starting from a given path. + * Symbolic links are followed, and the directories are traversed in + * breadth-first order. Directories are read only once. + * @param startDirectory The starting directory from which to start the + * downward traversal. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @returns An iterator over the downward files. + */ + // tslint:disable-next-line:unified-signatures + (startDirectory: string): Iterable; + + /** + * Constructs an iterator over the downward files starting from a given path + * and down to a given maximum depth of a directory. Symbolic links are + * followed, and the directories are traversed in breadth-first order. + * Directories are read only once. + * @param startDirectory The starting directory from which to start the + * downward traversal. + * @param maximumDepth The maximum depth of a read directory relative to the + * start directory. This maximum depth should be zero or positive. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @throws If the maximum depth is negative. + * @returns An iterator over the downward files down to the maximum depth. + */ + // tslint:disable-next-line:unified-signatures + (startDirectory: string, maximumDepth: number): Iterable; +} + +/** + * @see [[DownwardFilesFetcher]] The specifications of the function. + */ +export const downwardFiles: DownwardFilesFetcher = ( + startDirectory?: number | string, + maximumDepth?: number, +): AsyncIterable => { + [startDirectory, maximumDepth] = handleFunctionOverload( + startDirectory, + maximumDepth, + ); + if (maximumDepth === undefined) { + return unconstrainedDownwardFiles(startDirectory); + } else if (maximumDepth >= 0) { + return constrainedDownwardFiles(startDirectory, maximumDepth); + } else { + throw negativeMaximumDepthError(maximumDepth); + } +}; + +/** + * Constructs an iterator over the downward files starting from a given path. + * Symbolic links are followed, and the directories are traversed in + * breadth-first order. Directories are read only once. + * @param startDirectory The starting directory from which to start the downward + * traversal. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @returns An iterator over the downward files. + */ +async function* unconstrainedDownwardFiles( + startDirectory: string, +): AsyncIterable { + const start = resolve(startDirectory); + const pendingFiles: Array> = [readdir(start)]; + const traversedDirectories: string[] = [await realpathNative(start)]; + const isUntraversedDirectory = pathHasNotBeenTraversed(traversedDirectories); + while (pendingFiles.length > 0) { + for await (const file of pendingFiles.pop()) { + yield file; + if (await isDirectory(file)) { + const realpath = await realpathNative(file); + if (isUntraversedDirectory(realpath)) { + traversedDirectories.push(realpath); + pendingFiles.unshift(readdir(file)); + } + } + } + } +} + +/** + * Constructs an iterator over the downward files starting from a given path and + * down to a given maximum depth of a directory. Symbolic links are followed, + * and the directories are traversed in breadth-first order. Directories are + * read only once. + * @param startDirectory The starting directory from which to start the downward + * traversal. + * @param maximumDepth The maximum depth of a read directory relative to the + * start directory. This maximum depth should be zero or positive. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @throws If the maximum depth is negative. + * @returns An iterator over the downward files down to the maximum depth. + */ +async function* constrainedDownwardFiles( + startDirectory: string, + maximumDepth: number, +): AsyncIterable { + const start = startingDirectory(resolve(startDirectory)); + const pendingDirectories: Directory[] = [start]; + const traversedDirectories: string[] = [await realpathNative(start.path)]; + const isUntraversed = pathHasNotBeenTraversed(traversedDirectories); + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop(); + for await (const file of readdir(currentDirectory.path)) { + yield file; + if (await isDirectory(file)) { + const directory = subdirectory(file, currentDirectory); + const realpath = await realpathNative(file); + if (directory.depth <= maximumDepth && isUntraversed(realpath)) { + traversedDirectories.push(realpath); + pendingDirectories.unshift(directory); + } + } + } + } +} + +/** + * @see [[DownwardFilesFetcherSync]] The specifications of the function. + */ +export const downwardFilesSync: DownwardFilesFetcherSync = ( + startDirectory?: number | string, + maximumDepth?: number, +): Iterable => { + [startDirectory, maximumDepth] = handleFunctionOverload( + startDirectory, + maximumDepth, + ); + if (maximumDepth === undefined) { + return unconstrainedDownwardFilesSync(startDirectory); + } else if (maximumDepth >= 0) { + return constrainedDownwardFilesSync(startDirectory, maximumDepth); + } else { + throw negativeMaximumDepthError(maximumDepth); + } +}; + +/** + * Constructs an iterator over the downward files starting from a given path. + * Symbolic links are followed, and the directories are traversed in + * breadth-first order. Directories are read only once. + * @param startDirectory The starting directory from which to start the downward + * traversal. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @returns An iterator over the downward files. + */ +function* unconstrainedDownwardFilesSync( + startDirectory: string, +): Iterable { + const start = resolve(startDirectory); + const pendingFiles: Array> = [readdirSync(start)]; + const traversedDirectories: string[] = [realpathNativeSync(start)]; + const isUntraversedDirectory = pathHasNotBeenTraversed(traversedDirectories); + while (pendingFiles.length > 0) { + for (const file of pendingFiles.pop()) { + yield file; + if (isDirectorySync(file)) { + const realpath = realpathNativeSync(file); + if (isUntraversedDirectory(realpath)) { + traversedDirectories.push(realpath); + pendingFiles.unshift(readdirSync(file)); + } + } + } + } +} + +/** + * Constructs an iterator over the downward files starting from a given path and + * down to a given maximum depth of a directory. Symbolic links are followed, + * and the directories are traversed in breadth-first order. Directories are + * read only once. + * @param startDirectory The starting directory from which to start the downward + * traversal. + * @param maximumDepth The maximum depth of a read directory relative to the + * start directory. This maximum depth should be zero or positive. + * @throws If the starting path is a file. + * @throws If the starting path is inexistant. + * @throws If the maximum depth is negative. + * @returns An iterator over the downward files down to the maximum depth. + */ +function* constrainedDownwardFilesSync( + startDirectory: string, + maximumDepth: number, +): Iterable { + const start = startingDirectory(resolve(startDirectory)); + const pendingDirectories: Directory[] = [start]; + const traversedDirectories: string[] = [realpathNativeSync(start.path)]; + const isUntraversed = pathHasNotBeenTraversed(traversedDirectories); + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop(); + for (const file of readdirSync(currentDirectory.path)) { + yield file; + if (isDirectorySync(file)) { + const directory = subdirectory(file, currentDirectory); + const realpath = realpathNativeSync(file); + if (directory.depth <= maximumDepth && isUntraversed(realpath)) { + traversedDirectories.push(realpath); + pendingDirectories.unshift(directory); + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 594e728..7a8171f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,22 @@ +export { + Filter, + FilterSync, + conjunction, + conjunctionSync, + disjunction, + disjunctionSync, + filter, + filterSync, +} from "./filter"; +export { + allElements, + allElementsSync, + firstElement, + firstElementSync, + strictFirstElement, + strictFirstElementSync, +} from "./iterable"; + export { ofBasename, ofDirname, ofExtname, segments } from "./path"; export { isFile, isDirectory, isFileSync, isDirectorySync } from "./stat"; export { hasFile, hasFileSync } from "./hasFile"; @@ -17,10 +36,4 @@ export { strictFindFileSync, } from "./fileFinders"; -export { UpwardDirectoriesFetcher } from "./upwardDirectoriesFetcher"; - -export { upwards } from "./upwards"; - -export { DownwardDirectoriesFetcher } from "./downwardDirectoriesFetcher"; - -export { downwards } from "./downwards"; +export { downwardFiles, downwardFilesSync } from "./files"; diff --git a/src/iterable.ts b/src/iterable.ts index 3ce8a2a..9eeeadb 100644 --- a/src/iterable.ts +++ b/src/iterable.ts @@ -34,3 +34,96 @@ export const asyncIterableToArray = async ( } return array; }; + +/** + * Retrieves the first element of an iterable. + * @param iterable The iterable from which to retrieve the first element. + * @return The first element of the given iterable. + */ +export const firstElement = async ( + iterable: AsyncIterable, +): Promise => { + for await (const element of iterable) { + return element; + } + return null; +}; + +/** + * Retrieves the first element of an iterable. + * @param iterable The iterable from which to retrieve the first element. + * @return The first element of the given iterable. + */ +export const firstElementSync = (iterable: Iterable): T | null => { + for (const element of iterable) { + return element; + } + return null; +}; + +/** + * Constructs an error for conflicting elements. + * @param firstElement The first conflicting element. + * @param secondElement The second conflicting element. + * @returns The error to throw in case of conflicting elements. + */ +const conflictingElementsError = (firstElement: T, secondElement: T) => + new Error(`${firstElement} is conflicting with ${secondElement}`); + +/** + * Retrieves the first and only element of an iterable. + * @param iterable The iterable from which to retrieve the first and only + * element. + * @throws If there is more than one element in the iterable. + * @return The first and only element of the given iterable. + */ +export const strictFirstElement = async ( + iterable: AsyncIterable, +): Promise => { + let retainedElement: T; + for await (const element of iterable) { + if (retainedElement === undefined) { + retainedElement = element; + } else { + throw conflictingElementsError(element, retainedElement); + } + } + return retainedElement; +}; + +/** + * Retrieves the first and only element of an iterable. + * @param iterable The iterable from which to retrieve the first and only + * element. + * @throws If there is more than one element in the iterable. + * @return The first and only element of the given iterable. + */ +export const strictFirstElementSync = (iterable: Iterable): T | null => { + let retainedElement: T; + for (const element of iterable) { + if (retainedElement === undefined) { + retainedElement = element; + } else { + throw conflictingElementsError(element, retainedElement); + } + } + return retainedElement; +}; + +/** + * Retrieves all the elements of an iterable. The iterable should be finite. + * @param iterable The iterable from which to retrieve all the elements. + * @return The array of all the elements of the given iterable in sequential + * order. + */ +export const allElements = async ( + iterable: AsyncIterable, +): Promise => asyncIterableToArray(iterable); + +/** + * Retrieves all the elements of an iterable. The iterable should be finite. + * @param iterable The iterable from which to retrieve all the elements. + * @return The array of all the elements of the given iterable in sequential + * order. + */ +export const allElementsSync = (iterable: Iterable): T[] => [...iterable]; diff --git a/test/downwards-spec.ts b/test/downwards-spec.ts index edee44b..951cb6d 100644 --- a/test/downwards-spec.ts +++ b/test/downwards-spec.ts @@ -3,9 +3,9 @@ import { assert } from "chai"; import * as mock from "mock-fs"; import { basename, resolve } from "path"; -import { downwards } from "../src"; +import { downwards } from "../src/downwards"; -describe("downwards", () => { +describe.skip("downwards", () => { beforeEach(() => { mock( { diff --git a/test/files.spec.ts b/test/files.spec.ts new file mode 100644 index 0000000..11ce781 --- /dev/null +++ b/test/files.spec.ts @@ -0,0 +1,441 @@ +import { rejects } from "assert"; +import { assert } from "chai"; + +import * as mock from "mock-fs"; +import { join, resolve } from "path"; + +import { downwardFiles, downwardFilesSync } from "../src/files"; +import { allElements, allElementsSync } from "../src/iterable"; + +const resolvedPath = (path: string) => resolve(path); +const resolvedPaths = (...paths: string[]) => paths.map(resolvedPath); + +describe("files", () => { + beforeEach(() => { + mock( + { + "/home/user": { + files: { + "file.md": "", + "file.html": "", + }, + "symbolic-files": { + "file.json": "", + "file.html": mock.symlink({ + path: "/home/user/files/file.html", + }), + }, + "symbolic-folder": mock.symlink({ + path: "/home/user/files", + }), + loop: { + "file.md": "", + loop: mock.symlink({ + path: "/home/user/loop", + }), + }, + "breadth-first": { + "1": { + "1": "", + "2": "", + "3": "", + }, + "2": { + "1": "", + "2": "", + "3": "", + }, + "3": { + "1": "", + "2": "", + "3": "", + }, + }, + }, + [process.cwd()]: { + "file.html": mock.symlink({ + path: "/home/user/files/file.html", + }), + files: mock.symlink({ + path: "/home/user/files", + }), + }, + }, + { + createCwd: false, + createTmp: false, + }, + ); + }); + afterEach(() => { + mock.restore(); + }); + describe("downwardFiles", () => { + describe("unconstrained", () => { + it("should terminate", async () => downwardFiles()); + it("should not yield the starting directory", async () => { + const files = await allElements(downwardFiles()); + assert.isFalse( + files.includes(resolvedPath(".")), + `Starting directory ${resolvedPath(".")} was yielded.`, + ); + }); + it("should yield the correct amount of files", async () => { + assert.strictEqual( + (await allElements(downwardFiles())).length, + 4, + "Actual and expected files differ in length.", + ); + }); + it("should only yield correct files", async () => { + const files = resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ); + for await (const file of downwardFiles()) { + assert.isTrue(files.includes(file), `Unexpected file ${file}`); + } + }); + it("should yield all the correct files", async () => { + assert.deepStrictEqual( + (await allElements(downwardFiles())).sort(), + resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ).sort(), + ); + }); + it("should not traverse symbolic cycles infinitely", async () => + downwardFiles("/home/user/loop")); + it("should traverse symbolic cycles only once", async () => { + assert.deepStrictEqual( + (await allElements(downwardFiles("/home/user/loop"))).sort(), + resolvedPaths( + "/home/user/loop/file.md", + "/home/user/loop/loop", + ).sort(), + ); + }); + it("should use breadth-first traversal", async () => { + const concat = (path, segment) => join(path, segment); + const basenames = ["1", "2", "3"]; + const parentDirectories = resolvedPaths( + ...basenames.map((directory) => + concat("/home/user/breadth-first", directory), + ), + ); + const files = await allElements( + downwardFiles("/home/user/breadth-first"), + ); + assert.deepStrictEqual( + files.slice(0, basenames.length).sort(), + parentDirectories.sort(), + ); + let j = basenames.length; + for (let i = 0; i < basenames.length; i++) { + const directory = files[i]; + assert.deepStrictEqual( + basenames.map((file) => concat(directory, file)).sort(), + files.slice(j, j + basenames.length).sort(), + ); + j += basenames.length; + } + }); + it("should throw an error if the starting directory is a file", async () => + rejects(allElements(downwardFiles("./file.html")))); + it("should throw an error if the starting directory does not exist", async () => + rejects(allElements(downwardFiles("./inexistant-directory")))); + }); + describe("constrained", () => { + it("should terminate", async () => downwardFiles(0)); + it("should not yield the starting directory", async () => { + const files = await allElements(downwardFiles(0)); + assert.isFalse( + files.includes(resolvedPath(".")), + `Starting directory ${resolvedPath(".")} was yielded.`, + ); + }); + it("should yield the correct amount of files", async () => { + assert.strictEqual( + (await allElements(downwardFiles(1))).length, + 4, + "Actual and expected files differ in length.", + ); + }); + it("should yield the correct amount of files with a constraint", async () => { + assert.strictEqual( + (await allElements(downwardFiles(0))).length, + 2, + "Actual and expected files differ in length.", + ); + }); + it("should only yield correct files", async () => { + const files = resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ); + for await (const file of downwardFiles(1)) { + assert.isTrue(files.includes(file), `Unexpected file ${file}`); + } + }); + it("should only yield correct files with a constraint", async () => { + const files = resolvedPaths("./file.html", "./files"); + for await (const file of downwardFiles(0)) { + assert.isTrue(files.includes(file), `Unexpected file ${file}`); + } + }); + it("should yield all the correct files", async () => { + assert.deepStrictEqual( + (await allElements(downwardFiles(1))).sort(), + resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ).sort(), + ); + }); + it("should yield all the correct files with a constraint", async () => { + assert.deepStrictEqual( + (await allElements(downwardFiles(0))).sort(), + resolvedPaths("./file.html", "./files").sort(), + ); + }); + it("should not traverse symbolic cycles infinitely", async () => + downwardFiles("/home/user/loop", 5)); + it("should traverse symbolic cycles only once", async () => { + assert.deepStrictEqual( + (await allElements(downwardFiles("/home/user/loop", 5))).sort(), + resolvedPaths( + "/home/user/loop/file.md", + "/home/user/loop/loop", + ).sort(), + ); + }); + it("should use breadth-first traversal", async () => { + const concat = (path, segment) => join(path, segment); + const basenames = ["1", "2", "3"]; + const parentDirectories = resolvedPaths( + ...basenames.map((directory) => + concat("/home/user/breadth-first", directory), + ), + ); + const files = await allElements( + downwardFiles("/home/user/breadth-first", 2), + ); + assert.deepStrictEqual( + files.slice(0, basenames.length).sort(), + parentDirectories.sort(), + ); + let j = basenames.length; + for (let i = 0; i < basenames.length; i++) { + const directory = files[i]; + assert.deepStrictEqual( + basenames.map((file) => concat(directory, file)).sort(), + files.slice(j, j + basenames.length).sort(), + ); + j += basenames.length; + } + }); + it("should throw an error if the starting directory is a file", async () => + rejects(allElements(downwardFiles("./file.html", 5)))); + it("should throw an error if the starting directory does not exist", async () => + rejects(allElements(downwardFiles("./inexistant-directory", 5)))); + it("should throw an error if the constraint is negative", () => + assert.throws(() => downwardFiles("./inexistant-directory", -1))); + }); + }); + describe("downwardFilesSync", () => { + describe("unconstrained", () => { + it("should terminate", () => downwardFilesSync()); + it("should not yield the starting directory", () => { + const files = allElementsSync(downwardFilesSync()); + assert.isFalse( + files.includes(resolvedPath(".")), + `Starting directory ${resolvedPath(".")} was yielded.`, + ); + }); + it("should yield the correct amount of files", () => { + assert.strictEqual( + allElementsSync(downwardFilesSync()).length, + 4, + "Actual and expected files differ in length.", + ); + }); + it("should only yield correct files", () => { + const files = resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ); + for (const file of downwardFilesSync()) { + assert.isTrue(files.includes(file), `Unexpected file ${file}`); + } + }); + it("should yield all the correct files", () => { + assert.deepStrictEqual( + allElementsSync(downwardFilesSync()).sort(), + resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ).sort(), + ); + }); + it("should not traverse symbolic cycles infinitely", () => + downwardFilesSync("/home/user/loop")); + it("should traverse symbolic cycles only once", () => { + assert.deepStrictEqual( + allElementsSync(downwardFilesSync("/home/user/loop")).sort(), + resolvedPaths( + "/home/user/loop/file.md", + "/home/user/loop/loop", + ).sort(), + ); + }); + it("should use breadth-first traversal", () => { + const concat = (path, segment) => join(path, segment); + const basenames = ["1", "2", "3"]; + const parentDirectories = resolvedPaths( + ...basenames.map((directory) => + concat("/home/user/breadth-first", directory), + ), + ); + const files = allElementsSync( + downwardFilesSync("/home/user/breadth-first"), + ); + assert.deepStrictEqual( + files.slice(0, basenames.length).sort(), + parentDirectories.sort(), + ); + let j = basenames.length; + for (let i = 0; i < basenames.length; i++) { + const directory = files[i]; + assert.deepStrictEqual( + basenames.map((file) => concat(directory, file)).sort(), + files.slice(j, j + basenames.length).sort(), + ); + j += basenames.length; + } + }); + it("should throw an error if the starting directory is a file", () => + assert.throws(() => allElementsSync(downwardFilesSync("./file.html")))); + it("should throw an error if the starting directory does not exist", () => + assert.throws(() => + allElementsSync(downwardFilesSync("./inexistant-directory")), + )); + }); + describe("constrained", () => { + it("should terminate", () => downwardFilesSync(0)); + it("should not yield the starting directory", () => { + const files = allElementsSync(downwardFilesSync(0)); + assert.isFalse( + files.includes(resolvedPath(".")), + `Starting directory ${resolvedPath(".")} was yielded.`, + ); + }); + it("should yield the correct amount of files", () => { + assert.strictEqual( + allElementsSync(downwardFilesSync(1)).length, + 4, + "Actual and expected files differ in length.", + ); + }); + it("should yield the correct amount of files with a constraint", () => { + assert.strictEqual( + allElementsSync(downwardFilesSync(0)).length, + 2, + "Actual and expected files differ in length.", + ); + }); + it("should only yield correct files", () => { + const files = resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ); + for (const file of downwardFilesSync(1)) { + assert.isTrue(files.includes(file), `Unexpected file ${file}`); + } + }); + it("should only yield correct files with a constraint", () => { + const files = resolvedPaths("./file.html", "./files"); + for (const file of downwardFilesSync(0)) { + assert.isTrue(files.includes(file), `Unexpected file ${file}`); + } + }); + it("should yield all the correct files", () => { + assert.deepStrictEqual( + allElementsSync(downwardFilesSync(1)).sort(), + resolvedPaths( + "./file.html", + "./files", + "./files/file.md", + "./files/file.html", + ).sort(), + ); + }); + it("should yield all the correct files with a constraint", () => { + assert.deepStrictEqual( + allElementsSync(downwardFilesSync(0)).sort(), + resolvedPaths("./file.html", "./files").sort(), + ); + }); + it("should not traverse symbolic cycles infinitely", () => + downwardFilesSync("/home/user/loop", 5)); + it("should traverse symbolic cycles only once", () => { + assert.deepStrictEqual( + allElementsSync(downwardFilesSync("/home/user/loop", 5)).sort(), + resolvedPaths( + "/home/user/loop/file.md", + "/home/user/loop/loop", + ).sort(), + ); + }); + it("should use breadth-first traversal", () => { + const concat = (path, segment) => join(path, segment); + const basenames = ["1", "2", "3"]; + const parentDirectories = resolvedPaths( + ...basenames.map((directory) => + concat("/home/user/breadth-first", directory), + ), + ); + const files = allElementsSync( + downwardFilesSync("/home/user/breadth-first", 2), + ); + assert.deepStrictEqual( + files.slice(0, basenames.length).sort(), + parentDirectories.sort(), + ); + let j = basenames.length; + for (let i = 0; i < basenames.length; i++) { + const directory = files[i]; + assert.deepStrictEqual( + basenames.map((file) => concat(directory, file)).sort(), + files.slice(j, j + basenames.length).sort(), + ); + j += basenames.length; + } + }); + it("should throw an error if the starting directory is a file", () => + assert.throws(() => + allElementsSync(downwardFilesSync("./file.html", 5)), + )); + it("should throw an error if the starting directory does not exist", () => + assert.throws(() => + allElementsSync(downwardFilesSync("./inexistant-directory", 5)), + )); + it("should throw an error if the constraint is negative", () => + assert.throws(() => downwardFilesSync("./inexistant-directory", -1))); + }); + }); +}); diff --git a/test/upwards-spec.ts b/test/upwards-spec.ts index ba8f041..d5223b0 100644 --- a/test/upwards-spec.ts +++ b/test/upwards-spec.ts @@ -3,7 +3,7 @@ import { assert } from "chai"; import * as mock from "mock-fs"; import { dirname, parse, resolve } from "path"; -import { upwards } from "../src"; +import { upwards } from "../src/upwards"; /** * Constructs the set of upward directories from the given directory included. @@ -20,7 +20,7 @@ const upwardDirectories = (directory: string = process.cwd()): string[] => { return upwardDirectories; }; -describe("upwards", () => { +describe.skip("upwards", () => { beforeEach(() => { mock( {