From b3e2a261b5429f79baeceb66ceec0245089f6233 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:26 +0200 Subject: [PATCH 1/9] test: read the crawl's files in "crawl directory with options", which the edit that turned on errors dropped --- __tests__/fdir.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/__tests__/fdir.test.ts b/__tests__/fdir.test.ts index ae63510..5d97245 100644 --- a/__tests__/fdir.test.ts +++ b/__tests__/fdir.test.ts @@ -45,6 +45,7 @@ for (const type of apiTypes) { includeBasePath: true, suppressErrors: false, }).crawl("__tests__"); + const files = await api[type](); t.expect(files.every((file) => file.startsWith("__tests__"))).toBeTruthy(); }); From 5b31dd6abcc626261d0295730c5386bf87450d86 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:26 +0200 Subject: [PATCH 2/9] test: import normalizePath, which the separator test calls --- __tests__/fdir.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/fdir.test.ts b/__tests__/fdir.test.ts index 5d97245..f42b5a5 100644 --- a/__tests__/fdir.test.ts +++ b/__tests__/fdir.test.ts @@ -3,7 +3,7 @@ import fs from "fs"; import mock from "mock-fs"; import { test, beforeEach, TestContext, vi } from "vitest"; import path, { sep } from "path"; -import { convertSlashes } from "../src/utils"; +import { convertSlashes, normalizePath } from "../src/utils"; import picomatch from "picomatch"; import { apiTypes, APITypes, cwd, restricted, root } from "./utils"; From b56af2e354b210c37a5e0827c6dbe58e75ce0fe7 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:55 +0200 Subject: [PATCH 3/9] test: keep the shared symlink fixtures free of a broken link, and test one on its own, skipped by default and reported with errors --- __tests__/symlinks.test.ts | 49 ++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/__tests__/symlinks.test.ts b/__tests__/symlinks.test.ts index 00795e7..9a12cb5 100644 --- a/__tests__/symlinks.test.ts +++ b/__tests__/symlinks.test.ts @@ -16,9 +16,6 @@ const fsWithRelativeSymlinks = { fileSymlink: mock.symlink({ path: "../../../../other-relative/dir/file-2", }), - fileSymlink2: mock.symlink({ - path: "../../../../other-relative/dir/file-3", - }), dirSymlink: mock.symlink({ path: "../../../../sym-relative/linked", }), @@ -133,9 +130,6 @@ const mockFs = { fileSymlink: mock.symlink({ path: "/other/dir/file-2", }), - fileSymlink2: mock.symlink({ - path: "/other/dir/file-3", - }), dirSymlink: mock.symlink({ path: "/sym/linked", }), @@ -376,7 +370,7 @@ for (const type of apiTypes) { const api = new fdir().withErrors().crawl("/some/dir"); const files = await api[type](); t.expect(files.sort()).toStrictEqual( - normalize(["dirSymlink", "fileSymlink", "fileSymlink2"]) + normalize(["dirSymlink", "fileSymlink"]) ); }); @@ -405,6 +399,47 @@ for (const type of apiTypes) { { timeout: 1000 } ); }); + + describe(`${type}: a symlink whose target is missing`, () => { + beforeAll(() => { + mock({ + "/elsewhere": { + file: "file contents", + }, + "/dangling": { + kept: mock.symlink({ + path: "/elsewhere/file", + }), + broken: mock.symlink({ + path: "/elsewhere/missing", + }), + }, + }); + }); + + afterAll(() => { + mock.restore(); + }); + + test(`is listed by its name when symlinks are not resolved`, async (t) => { + const api = new fdir().withErrors().crawl("/dangling"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual(normalize(["broken", "kept"])); + }); + + test(`is skipped when symlinks are resolved`, async (t) => { + const api = new fdir().withSymlinks().crawl("/dangling"); + const files = await api[type](); + t.expect(files).toStrictEqual(normalize(["/elsewhere/file"])); + }); + + test(`is reported when symlinks are resolved with errors`, async (t) => { + const api = new fdir().withErrors().withSymlinks().crawl("/dangling"); + await t + .expect(Promise.resolve().then(() => api[type]())) + .rejects.toThrow(/ENOENT/); + }); + }); } function resolveSymlinkRoot(p: string) { From 046c4d2c429ec1a249a97484575a4383943fc972 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:49:05 +0200 Subject: [PATCH 4/9] test: mark the inverted separator crawl as a known failure on POSIX, where the walker reads the disk through the output path --- __tests__/fdir.test.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/__tests__/fdir.test.ts b/__tests__/fdir.test.ts index f42b5a5..15053fb 100644 --- a/__tests__/fdir.test.ts +++ b/__tests__/fdir.test.ts @@ -346,15 +346,22 @@ for (const type of apiTypes) { ).toBeTruthy(); }); - test(`[${type}] crawl all files and invert path separator`, async (t) => { - const api = new fdir() - .withErrors() - .withPathSeparator(sep === "/" ? "\\" : "/") - .crawl("node_modules"); - const files = await api[type](); - - t.expect(files.every((f) => !f.includes(sep))).toBeTruthy(); - }); + // A known bug on POSIX: the walker reads the disk through the output path, so a "\\" + // separator reaches readdir and the crawl fails. This turns red once the walker keeps + // native paths for the file system, and the marker goes with that fix. + const crawlsWithAnySeparator = sep === "/" ? test.fails : test; + crawlsWithAnySeparator( + `[${type}] crawl all files and invert path separator`, + async (t) => { + const api = new fdir() + .withErrors() + .withPathSeparator(sep === "/" ? "\\" : "/") + .crawl("node_modules"); + const files = await api[type](); + + t.expect(files.every((f) => !f.includes(sep))).toBeTruthy(); + } + ); test(`[${type}] crawl files that match using a custom glob`, async (t) => { const globFunction = vi.fn((glob: string | string[]) => { From 4641b412f7c9a54e83a2637e0c78ae800fb3922f Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:13:41 +0200 Subject: [PATCH 5/9] fix: crawl through native paths and give only the output a custom separator --- __tests__/fdir.test.ts | 27 ++++----- src/api/functions/join-path.ts | 13 +++- src/api/functions/resolve-symlink.ts | 4 +- src/api/functions/walk-directory.ts | 14 ++++- src/api/walker.ts | 88 +++++++++++++++++++--------- 5 files changed, 96 insertions(+), 50 deletions(-) diff --git a/__tests__/fdir.test.ts b/__tests__/fdir.test.ts index 15053fb..8aa9859 100644 --- a/__tests__/fdir.test.ts +++ b/__tests__/fdir.test.ts @@ -346,22 +346,17 @@ for (const type of apiTypes) { ).toBeTruthy(); }); - // A known bug on POSIX: the walker reads the disk through the output path, so a "\\" - // separator reaches readdir and the crawl fails. This turns red once the walker keeps - // native paths for the file system, and the marker goes with that fix. - const crawlsWithAnySeparator = sep === "/" ? test.fails : test; - crawlsWithAnySeparator( - `[${type}] crawl all files and invert path separator`, - async (t) => { - const api = new fdir() - .withErrors() - .withPathSeparator(sep === "/" ? "\\" : "/") - .crawl("node_modules"); - const files = await api[type](); - - t.expect(files.every((f) => !f.includes(sep))).toBeTruthy(); - } - ); + test(`[${type}] crawl all files and invert path separator`, async (t) => { + const api = new fdir() + .withErrors() + .withBasePath() + .withPathSeparator(sep === "/" ? "\\" : "/") + .crawl("node_modules"); + const files = await api[type](); + + t.expect(files).not.toHaveLength(0); + t.expect(files.every((f) => !f.includes(sep))).toBeTruthy(); + }); test(`[${type}] crawl files that match using a custom glob`, async (t) => { const globFunction = vi.fn((glob: string | string[]) => { diff --git a/src/api/functions/join-path.ts b/src/api/functions/join-path.ts index d103cb2..76f32c7 100644 --- a/src/api/functions/join-path.ts +++ b/src/api/functions/join-path.ts @@ -7,9 +7,15 @@ export function joinPathWithBasePath(filename: string, directoryPath: string) { } function joinPathWithRelativePath(root: string, options: Options) { - return function (filename: string, directoryPath: string) { + return function ( + filename: string, + outputPath: string, + directoryPath: string + ) { const sameRoot = directoryPath.startsWith(root); - if (sameRoot) return directoryPath.slice(root.length) + filename; + // The output path is the native path with other separators, so the root + // ends at the same index in both + if (sameRoot) return outputPath.slice(root.length) + filename; else return ( convertSlashes(relative(root, directoryPath), options.pathSeparator) + @@ -31,8 +37,11 @@ export function joinDirectoryPath( return directoryPath + filename + separator; } +// Joins a name to its directory's output path. The native path serves +// `relative()`, when a resolved symlink leads outside the root. export type JoinPathFunction = ( filename: string, + outputPath: string, directoryPath: string ) => string; diff --git a/src/api/functions/resolve-symlink.ts b/src/api/functions/resolve-symlink.ts index b23cdb2..4480ce0 100644 --- a/src/api/functions/resolve-symlink.ts +++ b/src/api/functions/resolve-symlink.ts @@ -1,6 +1,6 @@ import type { Stats } from "fs"; import { WalkerState, Options } from "../../types"; -import { dirname } from "path"; +import { dirname, sep } from "path"; export type ResolveSymlinkFunction = ( path: string, @@ -89,5 +89,5 @@ function isRecursive(path: string, resolved: string, state: WalkerState) { } function isRecursiveUsingRealPaths(resolved: string, state: WalkerState) { - return state.visited.includes(resolved + state.options.pathSeparator); + return state.visited.includes(resolved + sep); } diff --git a/src/api/functions/walk-directory.ts b/src/api/functions/walk-directory.ts index f2b9d36..5ee4616 100644 --- a/src/api/functions/walk-directory.ts +++ b/src/api/functions/walk-directory.ts @@ -5,8 +5,14 @@ export type WalkDirectoryFunction = ( state: WalkerState, crawlPath: string, directoryPath: string, + outputPath: string, depth: number, - callback: (entries: Dirent[], directoryPath: string, depth: number) => void + callback: ( + entries: Dirent[], + directoryPath: string, + outputPath: string, + depth: number + ) => void ) => void; const readdirOpts = { withFileTypes: true } as const; @@ -15,6 +21,7 @@ const walkAsync: WalkDirectoryFunction = ( state, crawlPath, directoryPath, + outputPath, currentDepth, callback ) => { @@ -30,7 +37,7 @@ const walkAsync: WalkDirectoryFunction = ( // Perf: Node >= 10 introduced withFileTypes that helps us // skip an extra fs.stat call. fs.readdir(crawlPath || ".", readdirOpts, (error, entries = []) => { - callback(entries, directoryPath, currentDepth); + callback(entries, directoryPath, outputPath, currentDepth); state.queue.dequeue(state.options.suppressErrors ? null : error, state); }); @@ -40,6 +47,7 @@ const walkSync: WalkDirectoryFunction = ( state, crawlPath, directoryPath, + outputPath, currentDepth, callback ) => { @@ -54,7 +62,7 @@ const walkSync: WalkDirectoryFunction = ( } catch (e) { if (!state.options.suppressErrors) throw e; } - callback(entries, directoryPath, currentDepth); + callback(entries, directoryPath, outputPath, currentDepth); }; export function build(isSynchronous: boolean): WalkDirectoryFunction { diff --git a/src/api/walker.ts b/src/api/walker.ts index e6edc0d..59e7229 100644 --- a/src/api/walker.ts +++ b/src/api/walker.ts @@ -1,5 +1,5 @@ -import { basename, dirname } from "path"; -import { isRootDirectory, normalizePath } from "../utils"; +import { basename, dirname, sep } from "path"; +import { convertSlashes, isRootDirectory, normalizePath } from "../utils"; import { ResultCallback, WalkerState, Options } from "../types"; import * as joinPath from "./functions/join-path"; import * as pushDirectory from "./functions/push-directory"; @@ -16,8 +16,17 @@ import { Output } from "../types"; import { Counter } from "./counter"; import { Aborter } from "./aborter"; +/* + * The walker hands the file system native paths and gives the custom separator + * only to what it outputs: converting an output path back would read a name + * that contains the other separator as two names. Each directory carries both + * forms, which are the same string when the separators match. + */ export class Walker { private readonly root: string; + private readonly outputRoot: string; + private readonly outputsNativePaths: boolean; + private readonly nativeOptions: Options; private readonly isSynchronous: boolean; private readonly state: WalkerState; private readonly joinPath: joinPath.JoinPathFunction; @@ -37,7 +46,9 @@ export class Walker { this.isSynchronous = !callback; this.callbackInvoker = invokeCallback.build(options, this.isSynchronous); - this.root = normalizePath(root, options); + this.outputsNativePaths = options.pathSeparator === sep; + this.nativeOptions = { ...options, pathSeparator: sep }; + this.root = normalizePath(root, this.nativeOptions); this.state = { root: isRootDirectory(this.root) ? this.root : this.root.slice(0, -1), // Perf: we explicitly tell the compiler to optimize for String arrays @@ -53,6 +64,7 @@ export class Walker { controller: new Aborter(), fs: options.fs || nativeFs, }; + this.outputRoot = this.toOutputPath(this.root); /* * Perf: We conditionally change functions according to options. This gives a slight @@ -60,7 +72,7 @@ export class Walker { * by the javascript engine so there's no function call overhead (in most cases). */ this.joinPath = joinPath.build(this.root, options); - this.pushDirectory = pushDirectory.build(this.root, options); + this.pushDirectory = pushDirectory.build(this.outputRoot, options); this.pushFile = pushFile.build(options); this.getArray = getArray.build(options); this.groupFiles = groupFiles.build(options); @@ -69,18 +81,34 @@ export class Walker { } start(): TOutput | null { - this.pushDirectory(this.root, this.state.paths, this.state.options.filters); + this.pushDirectory( + this.outputRoot, + this.state.paths, + this.state.options.filters + ); this.walkDirectory( this.state, this.root, this.root, + this.outputRoot, this.state.options.maxDepth, this.walk ); return this.isSynchronous ? this.callbackInvoker(this.state, null) : null; } - private walk = (entries: Dirent[], directoryPath: string, depth: number) => { + private toOutputPath(path: string) { + return this.outputsNativePaths + ? path + : convertSlashes(path, this.state.options.pathSeparator); + } + + private walk = ( + entries: Dirent[], + directoryPath: string, + outputPath: string, + depth: number + ) => { const { paths, options: { @@ -111,35 +139,37 @@ export class Walker { entry.isFile() || (entry.isSymbolicLink() && !resolveSymlinks && !excludeSymlinks) ) { - const filename = this.joinPath(entry.name, directoryPath); + const filename = this.joinPath(entry.name, outputPath, directoryPath); this.pushFile(filename, files, this.state.counts, filters); } else if (entry.isDirectory()) { - let path = joinPath.joinDirectoryPath( - entry.name, - directoryPath, - this.state.options.pathSeparator + let path = joinPath.joinDirectoryPath(entry.name, directoryPath, sep); + const output = this.outputsNativePaths + ? path + : joinPath.joinDirectoryPath(entry.name, outputPath, pathSeparator); + if (exclude && exclude(entry.name, output)) continue; + this.pushDirectory(output, paths, filters); + this.walkDirectory( + this.state, + path, + path, + output, + depth - 1, + this.walk ); - if (exclude && exclude(entry.name, path)) continue; - this.pushDirectory(path, paths, filters); - this.walkDirectory(this.state, path, path, depth - 1, this.walk); } else if (this.resolveSymlink && entry.isSymbolicLink()) { let path = joinPath.joinPathWithBasePath(entry.name, directoryPath); this.resolveSymlink(path, this.state, (stat, resolvedPath) => { if (stat.isDirectory()) { - resolvedPath = normalizePath(resolvedPath, this.state.options); - if ( - exclude && - exclude( - entry.name, - useRealPaths ? resolvedPath : path + pathSeparator - ) - ) - return; + resolvedPath = normalizePath(resolvedPath, this.nativeOptions); + const directoryPath = useRealPaths ? resolvedPath : path + sep; + const outputPath = this.toOutputPath(directoryPath); + if (exclude && exclude(entry.name, outputPath)) return; this.walkDirectory( this.state, resolvedPath, - useRealPaths ? resolvedPath : path + pathSeparator, + directoryPath, + outputPath, depth - 1, this.walk ); @@ -148,15 +178,19 @@ export class Walker { const filename = basename(resolvedPath); const directoryPath = normalizePath( dirname(resolvedPath), - this.state.options + this.nativeOptions + ); + resolvedPath = this.joinPath( + filename, + this.toOutputPath(directoryPath), + directoryPath ); - resolvedPath = this.joinPath(filename, directoryPath); this.pushFile(resolvedPath, files, this.state.counts, filters); } }); } } - this.groupFiles(this.state.groups, directoryPath, files); + this.groupFiles(this.state.groups, outputPath, files); }; } From 3f9e70afd2a14c9fae54542b503d417c0d4dccc9 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:13:41 +0200 Subject: [PATCH 6/9] test: output paths in either separator, and names that contain "\" with the "\" separator --- __tests__/fdir.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/__tests__/fdir.test.ts b/__tests__/fdir.test.ts index 8aa9859..ac6d8cf 100644 --- a/__tests__/fdir.test.ts +++ b/__tests__/fdir.test.ts @@ -1,7 +1,7 @@ import { FSLike, fdir } from "../src/index"; import fs from "fs"; import mock from "mock-fs"; -import { test, beforeEach, TestContext, vi } from "vitest"; +import { test, describe, beforeEach, afterEach, TestContext, vi } from "vitest"; import path, { sep } from "path"; import { convertSlashes, normalizePath } from "../src/utils"; import picomatch from "picomatch"; @@ -358,6 +358,124 @@ for (const type of apiTypes) { t.expect(files.every((f) => !f.includes(sep))).toBeTruthy(); }); + for (const separator of ["/", "\\"] as const) { + describe(`[${type}] output paths with the "${separator}" separator`, () => { + const inSeparator = (paths: string[]) => + paths.map((p) => convertSlashes(p, separator)).sort(); + + beforeEach(() => { + mock({ "/separators/a/b": { file: "" }, "/separators/c": "" }); + }); + + afterEach(() => { + mock.restore(); + }); + + test(`with base paths`, async (t) => { + const api = new fdir() + .withErrors() + .withBasePath() + .withPathSeparator(separator) + .crawl("/separators"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + inSeparator(["/separators/a/b/file", "/separators/c"]) + ); + }); + + test(`with relative paths`, async (t) => { + const api = new fdir() + .withErrors() + .withRelativePaths() + .withPathSeparator(separator) + .crawl("/separators"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual(inSeparator(["a/b/file", "c"])); + }); + + test(`with directories`, async (t) => { + const api = new fdir() + .withErrors() + .withDirs() + .withPathSeparator(separator) + .crawl("/separators"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + inSeparator([ + "/separators/", + "/separators/a/", + "/separators/a/b/", + "c", + "file", + ]) + ); + }); + + test(`with directories and relative paths`, async (t) => { + const api = new fdir() + .withErrors() + .withDirs() + .withRelativePaths() + .withPathSeparator(separator) + .crawl("/separators"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + inSeparator([".", "a/", "a/b/", "a/b/file", "c"]) + ); + }); + + test(`in groups`, async (t) => { + const api = new fdir() + .withErrors() + .withBasePath() + .withPathSeparator(separator) + .group() + .crawl("/separators"); + const groups = await api[type](); + t.expect(groups.map((group) => group.directory).sort()).toStrictEqual( + inSeparator(["/separators/", "/separators/a/", "/separators/a/b/"]) + ); + t.expect(groups.flatMap((group) => group.files).sort()).toStrictEqual( + inSeparator(["/separators/a/b/file", "/separators/c"]) + ); + }); + + test(`given to exclude`, async (t) => { + const excluded: string[] = []; + const api = new fdir() + .withErrors() + .withPathSeparator(separator) + .exclude((_name, path) => { + excluded.push(path); + return false; + }) + .crawl("/separators"); + await api[type](); + t.expect(excluded.sort()).toStrictEqual( + inSeparator(["/separators/a/", "/separators/a/b/"]) + ); + }); + }); + } + + test.skipIf(sep === "\\")( + `[${type}] crawl names that contain "\\" with the "\\" separator`, + async (t) => { + mock({ "/separators": { "a\\b": "", "c\\d": { file: "" } } }); + const api = new fdir() + .withErrors() + .withBasePath() + .withPathSeparator("\\") + .crawl("/separators"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual([ + "\\separators\\a\\b", + "\\separators\\c\\d\\file", + ]); + mock.restore(); + } + ); + test(`[${type}] crawl files that match using a custom glob`, async (t) => { const globFunction = vi.fn((glob: string | string[]) => { return (test: string): boolean => test.endsWith(".js"); From 3ad4583611264775fabc73d1c33e5072a30d9978 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:13:41 +0200 Subject: [PATCH 7/9] test: resolve symlinks, recursive ones included, with the other separator --- __tests__/symlinks.test.ts | 75 +++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/__tests__/symlinks.test.ts b/__tests__/symlinks.test.ts index 9a12cb5..0234797 100644 --- a/__tests__/symlinks.test.ts +++ b/__tests__/symlinks.test.ts @@ -1,7 +1,8 @@ import { afterAll, beforeAll, beforeEach, describe, test } from "vitest"; import { apiTypes, normalize, root } from "./utils"; import mock from "mock-fs"; -import { fdir, Options } from "../src"; +import { fdir, Options, PathSeparator } from "../src"; +import { convertSlashes } from "../src/utils"; import path from "path"; const fsWithRelativeSymlinks = { @@ -398,6 +399,78 @@ for (const type of apiTypes) { }, { timeout: 1000 } ); + + // Each crawl gives the files of its native-separator crawl, in the other separator + const separator = path.sep === "/" ? "\\" : "/"; + const crawls: Record< + string, + (api: fdir, separator: PathSeparator) => ReturnType + > = { + "resolve symlinks": (api) => api.withSymlinks().crawl("/some/dir"), + "resolve symlinks (real paths: false)": (api) => + api.withSymlinks({ resolvePaths: false }).crawl("/some/dir"), + "resolve symlinks (real paths: false, relative paths: true)": (api) => + api + .withSymlinks({ resolvePaths: false }) + .withRelativePaths() + .crawl("/some/dir"), + "resolve symlinks (real paths: true, relative paths: true)": (api) => + api.withSymlinks().withRelativePaths().crawl("./relative/dir"), + "resolve symlinks (exclude /sym/linked/)": (api, separator) => + api + .withSymlinks() + .exclude( + (_name, path) => + path === + convertSlashes(resolveSymlinkRoot("/sym/linked/"), separator) + ) + .crawl("/some/dir"), + "resolve symlinks (exclude /some/dir/dirSymlink/, real paths: false)": ( + api, + separator + ) => + api + .withSymlinks({ resolvePaths: false }) + .exclude( + (_name, path) => + path === + convertSlashes( + resolveSymlinkRoot("/some/dir/dirSymlink/"), + separator + ) + ) + .crawl("/some/dir"), + "resolve recursive symlinks": (api) => + api.withSymlinks().crawl("/recursive"), + "resolve recursive symlinks (real paths: false)": (api) => + api.withSymlinks({ resolvePaths: false }).crawl("/recursive"), + "resolve recursive symlinks (real paths: true, relative paths: true)": ( + api + ) => api.withSymlinks().withRelativePaths().crawl("./recursive"), + "resolve recursive symlinks (real paths: false, relative paths: true)": ( + api + ) => + api + .withSymlinks({ resolvePaths: false }) + .withRelativePaths() + .crawl("./recursive"), + }; + + for (const [name, crawl] of Object.entries(crawls)) { + test(`${name} with the "${separator}" separator`, async (t) => { + const nativeFiles = await crawl(new fdir().withErrors(), path.sep)[ + type + ](); + const files = await crawl( + new fdir().withErrors().withPathSeparator(separator), + separator + )[type](); + t.expect(files).not.toHaveLength(0); + t.expect(files.sort()).toStrictEqual( + nativeFiles.map((file) => convertSlashes(file, separator)).sort() + ); + }); + } }); describe(`${type}: a symlink whose target is missing`, () => { From ae00633f759e8229f082d40fad9b7ae24231e7a3 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:51:44 +0200 Subject: [PATCH 8/9] fix: normalize the paths the walker reads in native syntax, so a POSIX name keeps its backslash, with the default separator too --- __tests__/fdir.test.ts | 23 +++++++++++++-- __tests__/symlinks.test.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/api/walker.ts | 8 ++--- src/utils.ts | 18 ++++-------- 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/__tests__/fdir.test.ts b/__tests__/fdir.test.ts index ac6d8cf..9487949 100644 --- a/__tests__/fdir.test.ts +++ b/__tests__/fdir.test.ts @@ -476,6 +476,25 @@ for (const type of apiTypes) { } ); + for (const separator of ["/", "\\"] as const) { + test.skipIf(sep === "\\")( + `[${type}] crawl a root whose name ends with "\\" with the "${separator}" separator`, + async (t) => { + mock({ + "literal\\": { "wanted.txt": "" }, + literal: { "decoy.txt": "" }, + }); + const api = new fdir() + .withErrors() + .withPathSeparator(separator) + .crawl("literal\\"); + const files = await api[type](); + t.expect(files).toStrictEqual(["wanted.txt"]); + mock.restore(); + } + ); + } + test(`[${type}] crawl files that match using a custom glob`, async (t) => { const globFunction = vi.fn((glob: string | string[]) => { return (test: string): boolean => test.endsWith(".js"); @@ -654,7 +673,5 @@ test(`do not convert \\\\ to \\`, async (t) => { }); test(`do not add a separator if path already ends with one`, async (t) => { - t.expect(normalizePath("some-root/", { pathSeparator: "\\" })).toBe( - "some-root\\" - ); + t.expect(normalizePath("some-root/", {})).toBe(`some-root${sep}`); }); diff --git a/__tests__/symlinks.test.ts b/__tests__/symlinks.test.ts index 0234797..8d79b16 100644 --- a/__tests__/symlinks.test.ts +++ b/__tests__/symlinks.test.ts @@ -513,6 +513,66 @@ for (const type of apiTypes) { .rejects.toThrow(/ENOENT/); }); }); + + describe.skipIf(path.sep === "\\")( + `${type}: symlinks to a directory whose name contains "\\"`, + () => { + beforeAll(() => { + mock({ + "/backslash": { + "dir\\name": { wanted: "" }, + dir: { name: { decoy: "" } }, + root: { + link: mock.symlink({ path: "/backslash/dir\\name" }), + "file-link": mock.symlink({ + path: "/backslash/dir\\name/wanted", + }), + }, + }, + }); + }); + + afterAll(() => { + mock.restore(); + }); + + for (const separator of ["/", "\\"] as const) { + // Only the separators change: the backslash in the name stays + const inSeparator = (paths: string[]) => + paths.map((p) => p.split("/").join(separator)).sort(); + + test(`resolve them with the "${separator}" separator`, async (t) => { + const api = new fdir() + .withErrors() + .withPathSeparator(separator) + .withSymlinks() + .crawl("/backslash/root"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + inSeparator([ + "/backslash/dir\\name/wanted", + "/backslash/dir\\name/wanted", + ]) + ); + }); + + test(`resolve them (real paths: false) with the "${separator}" separator`, async (t) => { + const api = new fdir() + .withErrors() + .withPathSeparator(separator) + .withSymlinks({ resolvePaths: false }) + .crawl("/backslash/root"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + inSeparator([ + "/backslash/root/file-link", + "/backslash/root/link/wanted", + ]) + ); + }); + } + } + ); } function resolveSymlinkRoot(p: string) { diff --git a/src/api/walker.ts b/src/api/walker.ts index 59e7229..0a94f9b 100644 --- a/src/api/walker.ts +++ b/src/api/walker.ts @@ -26,7 +26,6 @@ export class Walker { private readonly root: string; private readonly outputRoot: string; private readonly outputsNativePaths: boolean; - private readonly nativeOptions: Options; private readonly isSynchronous: boolean; private readonly state: WalkerState; private readonly joinPath: joinPath.JoinPathFunction; @@ -47,8 +46,7 @@ export class Walker { this.callbackInvoker = invokeCallback.build(options, this.isSynchronous); this.outputsNativePaths = options.pathSeparator === sep; - this.nativeOptions = { ...options, pathSeparator: sep }; - this.root = normalizePath(root, this.nativeOptions); + this.root = normalizePath(root, options); this.state = { root: isRootDirectory(this.root) ? this.root : this.root.slice(0, -1), // Perf: we explicitly tell the compiler to optimize for String arrays @@ -160,7 +158,7 @@ export class Walker { let path = joinPath.joinPathWithBasePath(entry.name, directoryPath); this.resolveSymlink(path, this.state, (stat, resolvedPath) => { if (stat.isDirectory()) { - resolvedPath = normalizePath(resolvedPath, this.nativeOptions); + resolvedPath = normalizePath(resolvedPath, this.state.options); const directoryPath = useRealPaths ? resolvedPath : path + sep; const outputPath = this.toOutputPath(directoryPath); if (exclude && exclude(entry.name, outputPath)) return; @@ -178,7 +176,7 @@ export class Walker { const filename = basename(resolvedPath); const directoryPath = normalizePath( dirname(resolvedPath), - this.nativeOptions + this.state.options ); resolvedPath = this.joinPath( filename, diff --git a/src/utils.ts b/src/utils.ts index 76905e5..0a46f7c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -22,15 +22,13 @@ export function isRootDirectory(path: string) { return path === "/" || WINDOWS_ROOT_DIR_REGEX.test(path); } +// Normalizes a path for the file system in the platform's own syntax, where a +// POSIX name can contain a backslash, and ends it with the native separator export function normalizePath( path: string, - options: { - resolvePaths?: boolean; - normalizePath?: boolean; - pathSeparator: PathSeparator; - } + options: { resolvePaths?: boolean; normalizePath?: boolean } ) { - const { resolvePaths, normalizePath, pathSeparator } = options; + const { resolvePaths, normalizePath } = options; const pathNeedsCleaning = (process.platform === "win32" && path.includes("/")) || path.startsWith("."); @@ -40,10 +38,6 @@ export function normalizePath( if (path === ".") return ""; - const needsSeperator = - path[path.length - 1] !== "/" && path[path.length - 1] !== "\\"; - return convertSlashes( - needsSeperator ? path + pathSeparator : path, - pathSeparator - ); + // On Windows a "/" gets the path cleaned above, so only `sep` can end it + return path[path.length - 1] === sep ? path : path + sep; } From 3ef0e02fd99aa885221df9118605382adca8d790 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:52:53 +0200 Subject: [PATCH 9/9] fix: keep a POSIX backslash in relative paths that leave the root, converting them only to a custom separator --- __tests__/symlinks.test.ts | 13 +++++++++++++ src/api/functions/join-path.ts | 4 ++-- src/api/walker.ts | 23 ++++++++--------------- src/utils.ts | 5 +++++ 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/__tests__/symlinks.test.ts b/__tests__/symlinks.test.ts index 8d79b16..274178c 100644 --- a/__tests__/symlinks.test.ts +++ b/__tests__/symlinks.test.ts @@ -570,6 +570,19 @@ for (const type of apiTypes) { ]) ); }); + + test(`resolve them (relative paths: true) with the "${separator}" separator`, async (t) => { + const api = new fdir() + .withErrors() + .withPathSeparator(separator) + .withSymlinks() + .withRelativePaths() + .crawl("/backslash/root"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + inSeparator(["../dir\\name/wanted", "../dir\\name/wanted"]) + ); + }); } } ); diff --git a/src/api/functions/join-path.ts b/src/api/functions/join-path.ts index 76f32c7..74cd69e 100644 --- a/src/api/functions/join-path.ts +++ b/src/api/functions/join-path.ts @@ -1,6 +1,6 @@ import { relative } from "path"; import { Options, PathSeparator } from "../../types"; -import { convertSlashes } from "../../utils"; +import { toOutputPath } from "../../utils"; export function joinPathWithBasePath(filename: string, directoryPath: string) { return directoryPath + filename; @@ -18,7 +18,7 @@ function joinPathWithRelativePath(root: string, options: Options) { if (sameRoot) return outputPath.slice(root.length) + filename; else return ( - convertSlashes(relative(root, directoryPath), options.pathSeparator) + + toOutputPath(relative(root, directoryPath), options.pathSeparator) + options.pathSeparator + filename ); diff --git a/src/api/walker.ts b/src/api/walker.ts index 0a94f9b..f8d92ac 100644 --- a/src/api/walker.ts +++ b/src/api/walker.ts @@ -1,5 +1,5 @@ import { basename, dirname, sep } from "path"; -import { convertSlashes, isRootDirectory, normalizePath } from "../utils"; +import { isRootDirectory, normalizePath, toOutputPath } from "../utils"; import { ResultCallback, WalkerState, Options } from "../types"; import * as joinPath from "./functions/join-path"; import * as pushDirectory from "./functions/push-directory"; @@ -25,7 +25,6 @@ import { Aborter } from "./aborter"; export class Walker { private readonly root: string; private readonly outputRoot: string; - private readonly outputsNativePaths: boolean; private readonly isSynchronous: boolean; private readonly state: WalkerState; private readonly joinPath: joinPath.JoinPathFunction; @@ -45,8 +44,8 @@ export class Walker { this.isSynchronous = !callback; this.callbackInvoker = invokeCallback.build(options, this.isSynchronous); - this.outputsNativePaths = options.pathSeparator === sep; this.root = normalizePath(root, options); + this.outputRoot = toOutputPath(this.root, options.pathSeparator); this.state = { root: isRootDirectory(this.root) ? this.root : this.root.slice(0, -1), // Perf: we explicitly tell the compiler to optimize for String arrays @@ -62,7 +61,6 @@ export class Walker { controller: new Aborter(), fs: options.fs || nativeFs, }; - this.outputRoot = this.toOutputPath(this.root); /* * Perf: We conditionally change functions according to options. This gives a slight @@ -95,12 +93,6 @@ export class Walker { return this.isSynchronous ? this.callbackInvoker(this.state, null) : null; } - private toOutputPath(path: string) { - return this.outputsNativePaths - ? path - : convertSlashes(path, this.state.options.pathSeparator); - } - private walk = ( entries: Dirent[], directoryPath: string, @@ -141,9 +133,10 @@ export class Walker { this.pushFile(filename, files, this.state.counts, filters); } else if (entry.isDirectory()) { let path = joinPath.joinDirectoryPath(entry.name, directoryPath, sep); - const output = this.outputsNativePaths - ? path - : joinPath.joinDirectoryPath(entry.name, outputPath, pathSeparator); + const output = + pathSeparator === sep + ? path + : joinPath.joinDirectoryPath(entry.name, outputPath, pathSeparator); if (exclude && exclude(entry.name, output)) continue; this.pushDirectory(output, paths, filters); this.walkDirectory( @@ -160,7 +153,7 @@ export class Walker { if (stat.isDirectory()) { resolvedPath = normalizePath(resolvedPath, this.state.options); const directoryPath = useRealPaths ? resolvedPath : path + sep; - const outputPath = this.toOutputPath(directoryPath); + const outputPath = toOutputPath(directoryPath, pathSeparator); if (exclude && exclude(entry.name, outputPath)) return; this.walkDirectory( @@ -180,7 +173,7 @@ export class Walker { ); resolvedPath = this.joinPath( filename, - this.toOutputPath(directoryPath), + toOutputPath(directoryPath, pathSeparator), directoryPath ); this.pushFile(resolvedPath, files, this.state.counts, filters); diff --git a/src/utils.ts b/src/utils.ts index 0a46f7c..6f43a81 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,6 +17,11 @@ export function convertSlashes(path: string, separator: PathSeparator) { return path.replace(SLASHES_REGEX, separator); } +// A native path in the output's separator, which leaves it as is when native +export function toOutputPath(path: string, separator: PathSeparator) { + return separator === sep ? path : convertSlashes(path, separator); +} + const WINDOWS_ROOT_DIR_REGEX = /^[a-z]:[\\/]$/i; export function isRootDirectory(path: string) { return path === "/" || WINDOWS_ROOT_DIR_REGEX.test(path);