@effected/walker 0.20.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Confine.js +19 -10
- package/ConfineReal.js +123 -0
- package/Descend.js +60 -126
- package/DescendSync.js +79 -0
- package/Walker.js +193 -38
- package/index.d.ts +591 -19
- package/index.js +4 -2
- package/internal/absolute.js +19 -0
- package/internal/descendMachine.js +282 -0
- package/internal/trimTrailing.js +13 -0
- package/internal/upwardDrivers.js +71 -0
- package/internal/upwardMachine.js +239 -0
- package/node-sync.d.ts +102 -0
- package/node-sync.js +61 -0
- package/package.json +7 -2
package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { PathEscapeError, PathEscapeReason, confineRelative, confineRelativeResult } from "./Confine.js";
|
|
2
|
-
import {
|
|
2
|
+
import { confineReal, confineRealSync } from "./ConfineReal.js";
|
|
3
|
+
import { DescendError, descend, descendEntries } from "./Descend.js";
|
|
4
|
+
import { descendEntriesSync, descendSync } from "./DescendSync.js";
|
|
3
5
|
import { GlobExpansionError, compileAndExpand } from "./Expand.js";
|
|
4
6
|
import { Walker } from "./Walker.js";
|
|
5
7
|
|
|
6
|
-
export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineRelative, confineRelativeResult, descend };
|
|
8
|
+
export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineReal, confineRealSync, confineRelative, confineRelativeResult, descend, descendEntries, descendEntriesSync, descendSync };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/internal/absolute.ts
|
|
2
|
+
/**
|
|
3
|
+
* A win32 path rooted on the CURRENT drive: one leading separator, not two
|
|
4
|
+
* (`\\server\share` and `\\?\C:\x` are UNC and device paths, which name their
|
|
5
|
+
* root). `path.win32.isAbsolute` calls `\repo` absolute, yet which drive it
|
|
6
|
+
* names depends on the process working directory — exactly like the
|
|
7
|
+
* drive-relative `C:x`.
|
|
8
|
+
*/
|
|
9
|
+
const CURRENT_DRIVE_ROOT = /^[\\/](?![\\/])/;
|
|
10
|
+
/** Whether `path` is rooted on the current drive under win32 path rules. */
|
|
11
|
+
const isCurrentDriveRooted = (path, sep) => sep === "\\" && CURRENT_DRIVE_ROOT.test(path);
|
|
12
|
+
/**
|
|
13
|
+
* Whether `path` names one location whatever the process working directory:
|
|
14
|
+
* absolute to the injected implementation, and on win32 not rooted on the
|
|
15
|
+
* current drive.
|
|
16
|
+
*/
|
|
17
|
+
const isPinnedAbsolute = (path, ops) => ops.isAbsolute(path) && !isCurrentDriveRooted(path, ops.sep);
|
|
18
|
+
|
|
19
|
+
export { isCurrentDriveRooted, isPinnedAbsolute };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { resolveSegments } from "./segments.js";
|
|
2
|
+
import { trimTrailing } from "./trimTrailing.js";
|
|
3
|
+
import { isPinnedAbsolute } from "./absolute.js";
|
|
4
|
+
|
|
5
|
+
// src/internal/descendMachine.ts
|
|
6
|
+
/** Directory names never descended into unless the caller overrides `prune`. */
|
|
7
|
+
const DEFAULT_PRUNE = ["node_modules", ".git"];
|
|
8
|
+
/** A glob pattern's one separator: its backslash is an escape, never a separator. */
|
|
9
|
+
const isSlash = (character) => character === "/";
|
|
10
|
+
/** Shared empty ancestor chain: frames under `followSymlinks: false` never consult theirs. */
|
|
11
|
+
const NO_ANCESTORS = Object.freeze([]);
|
|
12
|
+
/** The walk. */
|
|
13
|
+
function* descendMachine(pattern, options, path) {
|
|
14
|
+
const join = path.join;
|
|
15
|
+
/** Yields one request; the answer is typed by the request's tag. */
|
|
16
|
+
function* ask(request) {
|
|
17
|
+
return yield request;
|
|
18
|
+
}
|
|
19
|
+
const maxDepth = options.maxDepth ?? 256;
|
|
20
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) return {
|
|
21
|
+
_tag: "Defect",
|
|
22
|
+
message: `Walker.descend: maxDepth must be a positive integer, received ${maxDepth}`
|
|
23
|
+
};
|
|
24
|
+
if (options.cwd.includes("\0") || !isPinnedAbsolute(options.cwd, path)) return {
|
|
25
|
+
_tag: "Defect",
|
|
26
|
+
message: `Walker.descend: cwd must be an absolute path, received ${JSON.stringify(options.cwd)}`
|
|
27
|
+
};
|
|
28
|
+
const match = options.match ?? "file";
|
|
29
|
+
if (match !== "file" && match !== "directory" && match !== "any") return {
|
|
30
|
+
_tag: "Defect",
|
|
31
|
+
message: `Walker.descend: match must be "file", "directory" or "any", received ${JSON.stringify(match)}`
|
|
32
|
+
};
|
|
33
|
+
const matchFiles = match !== "directory";
|
|
34
|
+
const matchDirectories = match !== "file";
|
|
35
|
+
/** Canonical paths of the directories already answered: a root's base may also be an entry of another root. */
|
|
36
|
+
const answeredDirectories = /* @__PURE__ */ new Set();
|
|
37
|
+
const prune = new Set(options.prune ?? DEFAULT_PRUNE);
|
|
38
|
+
const boundaries = options.boundaries ?? [];
|
|
39
|
+
const onUnreadable = options.onUnreadable ?? "fail";
|
|
40
|
+
const followSymlinks = options.followSymlinks ?? false;
|
|
41
|
+
const unreadable = [];
|
|
42
|
+
const done = (entries) => {
|
|
43
|
+
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
44
|
+
return {
|
|
45
|
+
_tag: "Done",
|
|
46
|
+
entries,
|
|
47
|
+
unreadable
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const unreadableFail = (path) => ({
|
|
51
|
+
_tag: "Fail",
|
|
52
|
+
reason: "unreadableDirectory",
|
|
53
|
+
path
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Whether the directory at `absolute` is a boundary: it holds a marker
|
|
57
|
+
* FILE. `listing`, when the directory was just read, spares the stat for
|
|
58
|
+
* every marker it does not name; a directory named like a marker is not one.
|
|
59
|
+
*/
|
|
60
|
+
function* isBoundary(absolute, listing) {
|
|
61
|
+
for (const marker of boundaries) {
|
|
62
|
+
if (listing !== void 0 && !listing.includes(marker)) continue;
|
|
63
|
+
if ((yield* ask({
|
|
64
|
+
_tag: "Stat",
|
|
65
|
+
path: join(absolute, marker)
|
|
66
|
+
}))?.kind === "File") return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether the way down `segments` (below `cwd`) is closed: a directory among
|
|
72
|
+
* the first `boundaryCount` is a boundary, or — without followSymlinks,
|
|
73
|
+
* where a symlinked directory is never entered — a link is among the first
|
|
74
|
+
* `directoryCount`. Either way the answer is nothing.
|
|
75
|
+
*/
|
|
76
|
+
function* blocked(segments, directoryCount, boundaryCount) {
|
|
77
|
+
if (boundaries.length === 0 && followSymlinks) return false;
|
|
78
|
+
let absolute = options.cwd;
|
|
79
|
+
for (let index = 0; index < directoryCount; index += 1) {
|
|
80
|
+
absolute = join(absolute, segments[index] ?? "");
|
|
81
|
+
if (index < boundaryCount && (yield* isBoundary(absolute, void 0))) return true;
|
|
82
|
+
if (!followSymlinks && (yield* ask({
|
|
83
|
+
_tag: "Lstat",
|
|
84
|
+
path: absolute
|
|
85
|
+
}))?.kind === "SymbolicLink") return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** The real path of a directory the cycle guard must identify, or `undefined` when the walk must not enter it. */
|
|
90
|
+
function* realPathOf(absolute, relative) {
|
|
91
|
+
const answer = yield* ask({
|
|
92
|
+
_tag: "RealPath",
|
|
93
|
+
path: absolute
|
|
94
|
+
});
|
|
95
|
+
if (answer._tag === "Ok") return answer.value;
|
|
96
|
+
if (answer._tag === "NotFound") return void 0;
|
|
97
|
+
if (onUnreadable === "fail") return { fail: true };
|
|
98
|
+
if (onUnreadable === "record") unreadable.push({
|
|
99
|
+
path: relative,
|
|
100
|
+
cause: answer.cause
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (!pattern.hasMagic && !pattern.negated && !/[{}]/.test(pattern.source)) {
|
|
104
|
+
const segments = resolveSegments(pattern.source.split("/"));
|
|
105
|
+
if (segments === void 0) return done([]);
|
|
106
|
+
if (yield* blocked(segments, segments.length - 1, segments.length - 1)) return done([]);
|
|
107
|
+
const answer = yield* ask({
|
|
108
|
+
_tag: "Stat",
|
|
109
|
+
path: join(options.cwd, pattern.source)
|
|
110
|
+
});
|
|
111
|
+
const wanted = answer?.kind === "File" && matchFiles || answer?.kind === "Directory" && matchDirectories && segments.length > 0;
|
|
112
|
+
return done(answer !== void 0 && wanted ? [{
|
|
113
|
+
path: pattern.source,
|
|
114
|
+
info: answer.info
|
|
115
|
+
}] : []);
|
|
116
|
+
}
|
|
117
|
+
const results = [];
|
|
118
|
+
/**
|
|
119
|
+
* Walks one root: the literal directory `base` and everything below it a
|
|
120
|
+
* match can lie in — one level, or all of it when `deep`. An absent base,
|
|
121
|
+
* one that climbs above `cwd`, or one behind a boundary or a link is
|
|
122
|
+
* skipped; a `Fail` aborts the walk.
|
|
123
|
+
*
|
|
124
|
+
* The walk itself is canonical (`cwd` is `""`, never `.`), so each directory
|
|
125
|
+
* is read once however many spellings name it. What it answers is spelled
|
|
126
|
+
* the way the pattern spells it: a file matches when its path, re-spelled
|
|
127
|
+
* through one of the root's spellings, matches the pattern, and it is
|
|
128
|
+
* answered ONCE, in the first such spelling — the earliest brace
|
|
129
|
+
* alternative's, since spellings come in pattern order. A directory the
|
|
130
|
+
* walk reports (unreadable, too deep) is spelled through the root's own
|
|
131
|
+
* earliest spelling.
|
|
132
|
+
*/
|
|
133
|
+
function* walkRoot(rawBase, deep, spellings) {
|
|
134
|
+
const spelled = spellings.map((spelling) => {
|
|
135
|
+
const canonical = resolveSegments(spelling.split("/"))?.join("/") ?? "";
|
|
136
|
+
return {
|
|
137
|
+
spelling,
|
|
138
|
+
canonical: canonical === "" ? "" : `${canonical}/`
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
/** The entry at canonical `relative`, in the first spelling the pattern matches it under, or `undefined`. */
|
|
142
|
+
const matchedAs = (relative) => {
|
|
143
|
+
for (const { spelling, canonical } of spelled) {
|
|
144
|
+
if (canonical !== "" && !relative.startsWith(canonical)) continue;
|
|
145
|
+
const candidate = spelling + relative.slice(canonical.length);
|
|
146
|
+
if (pattern.matches(candidate)) return candidate;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* The directory at canonical `relative`, in the first spelling the pattern
|
|
151
|
+
* matches it under — bare, or with a trailing `/` (a glob's directory-only
|
|
152
|
+
* form) — or `undefined`. Each directory is answered once.
|
|
153
|
+
*/
|
|
154
|
+
const matchedDirectoryAs = (relative) => {
|
|
155
|
+
if (answeredDirectories.has(relative)) return void 0;
|
|
156
|
+
const answer = matchedAs(relative) ?? matchedAs(`${relative}/`);
|
|
157
|
+
if (answer !== void 0) answeredDirectories.add(relative);
|
|
158
|
+
return answer;
|
|
159
|
+
};
|
|
160
|
+
const baseSegments = resolveSegments(rawBase.split("/"));
|
|
161
|
+
if (baseSegments === void 0) return void 0;
|
|
162
|
+
const base = baseSegments.join("/");
|
|
163
|
+
const baseCanonical = base === "" ? "" : `${base}/`;
|
|
164
|
+
const own = spelled.find(({ canonical }) => canonical === baseCanonical)?.spelling ?? baseCanonical;
|
|
165
|
+
const shown = (relative) => relative === base ? trimTrailing(own, isSlash) : own + relative.slice(baseCanonical.length);
|
|
166
|
+
const absoluteBase = base === "" ? options.cwd : join(options.cwd, base);
|
|
167
|
+
const baseStat = yield* ask({
|
|
168
|
+
_tag: "Stat",
|
|
169
|
+
path: absoluteBase
|
|
170
|
+
});
|
|
171
|
+
if (baseStat?.kind !== "Directory") return void 0;
|
|
172
|
+
if (yield* blocked(baseSegments, baseSegments.length, baseSegments.length - 1)) return void 0;
|
|
173
|
+
let baseAncestors = NO_ANCESTORS;
|
|
174
|
+
if (followSymlinks) {
|
|
175
|
+
const real = yield* realPathOf(absoluteBase, shown(base));
|
|
176
|
+
if (typeof real === "object") return unreadableFail(shown(base));
|
|
177
|
+
if (real === void 0) return void 0;
|
|
178
|
+
baseAncestors = [real];
|
|
179
|
+
}
|
|
180
|
+
if (matchDirectories && base !== "") {
|
|
181
|
+
const answer = matchedDirectoryAs(base);
|
|
182
|
+
if (answer !== void 0) results.push({
|
|
183
|
+
path: answer,
|
|
184
|
+
info: baseStat.info
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const frames = [{
|
|
188
|
+
relative: base,
|
|
189
|
+
absolute: absoluteBase,
|
|
190
|
+
depth: 0,
|
|
191
|
+
ancestors: baseAncestors
|
|
192
|
+
}];
|
|
193
|
+
for (let head = 0; head < frames.length; head += 1) {
|
|
194
|
+
const frame = frames[head];
|
|
195
|
+
if (frame === void 0) break;
|
|
196
|
+
const read = yield* ask({
|
|
197
|
+
_tag: "ReadDirectory",
|
|
198
|
+
path: frame.absolute
|
|
199
|
+
});
|
|
200
|
+
let entries = [];
|
|
201
|
+
if (read._tag === "Ok") entries = read.value;
|
|
202
|
+
else if (read._tag === "Failed") {
|
|
203
|
+
if (onUnreadable === "fail") return unreadableFail(shown(frame.relative));
|
|
204
|
+
if (onUnreadable === "record") unreadable.push({
|
|
205
|
+
path: shown(frame.relative),
|
|
206
|
+
cause: read.cause
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (frame.relative !== "" && (yield* isBoundary(frame.absolute, entries))) continue;
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const relative = frame.relative === "" ? entry : `${frame.relative}/${entry}`;
|
|
212
|
+
const absolute = join(frame.absolute, entry);
|
|
213
|
+
const own = yield* ask({
|
|
214
|
+
_tag: "Lstat",
|
|
215
|
+
path: absolute
|
|
216
|
+
});
|
|
217
|
+
const link = own?.kind === "SymbolicLink";
|
|
218
|
+
const stat = link ? yield* ask({
|
|
219
|
+
_tag: "Stat",
|
|
220
|
+
path: absolute
|
|
221
|
+
}) : own;
|
|
222
|
+
if (stat === void 0) continue;
|
|
223
|
+
if (stat.kind === "File") {
|
|
224
|
+
const answer = matchFiles ? matchedAs(relative) : void 0;
|
|
225
|
+
if (answer !== void 0) results.push({
|
|
226
|
+
path: answer,
|
|
227
|
+
info: stat.info
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (stat.kind === "Directory" && matchDirectories) {
|
|
232
|
+
const answer = matchedDirectoryAs(relative);
|
|
233
|
+
if (answer !== void 0) results.push({
|
|
234
|
+
path: answer,
|
|
235
|
+
info: stat.info
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (stat.kind !== "Directory" || !deep) continue;
|
|
239
|
+
if (prune.has(entry)) continue;
|
|
240
|
+
let ancestors = frame.ancestors;
|
|
241
|
+
if (followSymlinks) {
|
|
242
|
+
const parentReal = frame.ancestors[frame.ancestors.length - 1] ?? frame.absolute;
|
|
243
|
+
const real = link ? yield* realPathOf(absolute, shown(relative)) : join(parentReal, entry);
|
|
244
|
+
if (typeof real === "object") return unreadableFail(shown(relative));
|
|
245
|
+
if (real === void 0 || frame.ancestors.includes(real)) continue;
|
|
246
|
+
ancestors = [...frame.ancestors, real];
|
|
247
|
+
} else if (link) continue;
|
|
248
|
+
if (frame.depth + 1 > maxDepth) return {
|
|
249
|
+
_tag: "Fail",
|
|
250
|
+
reason: "depthExceeded",
|
|
251
|
+
path: shown(frame.relative),
|
|
252
|
+
limit: maxDepth
|
|
253
|
+
};
|
|
254
|
+
frames.push({
|
|
255
|
+
relative,
|
|
256
|
+
absolute,
|
|
257
|
+
depth: frame.depth + 1,
|
|
258
|
+
ancestors
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const roots = pattern.negated ? [{
|
|
264
|
+
prefix: "",
|
|
265
|
+
deep: true,
|
|
266
|
+
spellings: [""]
|
|
267
|
+
}] : pattern.enumerationRoots;
|
|
268
|
+
for (const root of roots) {
|
|
269
|
+
const failed = yield* walkRoot(trimTrailing(root.prefix, isSlash), root.deep, root.spellings);
|
|
270
|
+
if (failed !== void 0) return failed;
|
|
271
|
+
}
|
|
272
|
+
return done(results);
|
|
273
|
+
}
|
|
274
|
+
/** The typed error a `Fail` result describes — the one conversion both drivers share. */
|
|
275
|
+
const failureOf = (pattern, result) => ({
|
|
276
|
+
pattern: pattern.source,
|
|
277
|
+
reason: result.reason,
|
|
278
|
+
path: result.path,
|
|
279
|
+
...result.limit === void 0 ? {} : { limit: result.limit }
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
export { DEFAULT_PRUNE, descendMachine, failureOf };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/internal/trimTrailing.ts
|
|
2
|
+
/**
|
|
3
|
+
* `text` without its trailing run of characters `isTrailing` accepts, in time linear in the run.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
const trimTrailing = (text, isTrailing) => {
|
|
8
|
+
let end = text.length;
|
|
9
|
+
while (end > 0 && isTrailing(text.charAt(end - 1))) end -= 1;
|
|
10
|
+
return end === text.length ? text : text.slice(0, end);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { trimTrailing };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Effect, FileSystem } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/internal/upwardDrivers.ts
|
|
4
|
+
/** Runs a machine against core's `FileSystem`. A machine's `Defect` dies. */
|
|
5
|
+
const runUpward = (machine) => Effect.gen(function* () {
|
|
6
|
+
const fs = yield* FileSystem.FileSystem;
|
|
7
|
+
const answer = (request) => {
|
|
8
|
+
if (request._tag === "Exists") return fs.exists(request.path).pipe(Effect.orElseSucceed(() => false));
|
|
9
|
+
if (request._tag === "Present") return fs.lstat(request.path).pipe(Effect.map(() => ({ _tag: "Present" })), Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? { _tag: "NotFound" } : {
|
|
10
|
+
_tag: "Failed",
|
|
11
|
+
cause: error
|
|
12
|
+
})));
|
|
13
|
+
return fs.realPath(request.path).pipe(Effect.map((value) => ({
|
|
14
|
+
_tag: "Ok",
|
|
15
|
+
value
|
|
16
|
+
})), Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? {
|
|
17
|
+
_tag: "NotFound",
|
|
18
|
+
cause: error
|
|
19
|
+
} : {
|
|
20
|
+
_tag: "Failed",
|
|
21
|
+
cause: error
|
|
22
|
+
})));
|
|
23
|
+
};
|
|
24
|
+
let step = machine.next();
|
|
25
|
+
while (step.done !== true) step = machine.next(yield* answer(step.value));
|
|
26
|
+
const result = step.value;
|
|
27
|
+
if (result._tag === "Defect") return yield* Effect.die(new Error(result.message));
|
|
28
|
+
return result.value;
|
|
29
|
+
});
|
|
30
|
+
const isNotFound = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
31
|
+
/** Runs a machine against a synchronous port. A machine's `Defect` throws. */
|
|
32
|
+
const runUpwardSync = (machine, fileSystem) => {
|
|
33
|
+
const answer = (request) => {
|
|
34
|
+
if (request._tag === "Exists") try {
|
|
35
|
+
fileSystem.stat(request.path);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (request._tag === "Present") try {
|
|
41
|
+
fileSystem.lstat(request.path);
|
|
42
|
+
return { _tag: "Present" };
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
return isNotFound(cause) ? { _tag: "NotFound" } : {
|
|
45
|
+
_tag: "Failed",
|
|
46
|
+
cause
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return {
|
|
51
|
+
_tag: "Ok",
|
|
52
|
+
value: fileSystem.realPath(request.path)
|
|
53
|
+
};
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
return isNotFound(cause) ? {
|
|
56
|
+
_tag: "NotFound",
|
|
57
|
+
cause
|
|
58
|
+
} : {
|
|
59
|
+
_tag: "Failed",
|
|
60
|
+
cause
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
let step = machine.next();
|
|
65
|
+
while (step.done !== true) step = machine.next(answer(step.value));
|
|
66
|
+
const result = step.value;
|
|
67
|
+
if (result._tag === "Defect") throw new Error(result.message);
|
|
68
|
+
return result.value;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export { runUpward, runUpwardSync };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { trimTrailing } from "./trimTrailing.js";
|
|
2
|
+
import { isCurrentDriveRooted, isPinnedAbsolute } from "./absolute.js";
|
|
3
|
+
|
|
4
|
+
// src/internal/upwardMachine.ts
|
|
5
|
+
/** Asks for the real path at `path`. */
|
|
6
|
+
function* realPath(path) {
|
|
7
|
+
return yield {
|
|
8
|
+
_tag: "RealPath",
|
|
9
|
+
path
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Asks whether anything — a dangling link included — lives at `path` itself. */
|
|
13
|
+
function* present(path) {
|
|
14
|
+
return yield {
|
|
15
|
+
_tag: "Present",
|
|
16
|
+
path
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Asks whether `path` exists. */
|
|
20
|
+
function* exists(path) {
|
|
21
|
+
return yield {
|
|
22
|
+
_tag: "Exists",
|
|
23
|
+
path
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const done = (value) => ({
|
|
27
|
+
_tag: "Done",
|
|
28
|
+
value
|
|
29
|
+
});
|
|
30
|
+
const defect = (message) => ({
|
|
31
|
+
_tag: "Defect",
|
|
32
|
+
message
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* The lexical chain from `start` toward the root, nearest first — `ascend`'s
|
|
36
|
+
* whole algorithm. Pure: it reads no filesystem, so it is not a generator.
|
|
37
|
+
* `name` prefixes the defect messages, so each public form reports itself.
|
|
38
|
+
*/
|
|
39
|
+
const ascendChain = (name, start, options, path) => {
|
|
40
|
+
const maxDepth = options?.maxDepth ?? 256;
|
|
41
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) return defect(`${name}: maxDepth must be a positive integer, received ${maxDepth}`);
|
|
42
|
+
if (options?.stopAt !== void 0 && !isPinnedAbsolute(options.stopAt, path)) return defect(`${name}: stopAt must be an absolute path, received ${JSON.stringify(options.stopAt)} (ascending from ${JSON.stringify(start)})`);
|
|
43
|
+
const ceiling = options?.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
|
|
44
|
+
const dirs = [];
|
|
45
|
+
let current = start;
|
|
46
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
47
|
+
dirs.push(current);
|
|
48
|
+
if (ceiling !== void 0 && (current === ceiling || path.resolve(current) === ceiling)) break;
|
|
49
|
+
const parent = path.dirname(current);
|
|
50
|
+
if (parent === current) break;
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
return done(dirs);
|
|
54
|
+
};
|
|
55
|
+
/** The real path at `path`, or `undefined` when it cannot be resolved for any reason. */
|
|
56
|
+
function* realPathOrUndefined(path) {
|
|
57
|
+
const answer = yield* realPath(path);
|
|
58
|
+
return answer._tag === "Ok" ? answer.value : void 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `ascendWithin`: the lexical chain, stopped at the nearest ancestor whose real
|
|
62
|
+
* path is the ceiling's. `ceiling === undefined` is `Option.none()` — the chain
|
|
63
|
+
* runs to the root exactly as `ascend` gives it.
|
|
64
|
+
*/
|
|
65
|
+
function* ascendWithinMachine(name, start, ceiling, options, path) {
|
|
66
|
+
const maxDepth = options?.maxDepth === void 0 ? {} : { maxDepth: options.maxDepth };
|
|
67
|
+
if (ceiling === void 0) return ascendChain(name, start, maxDepth, path);
|
|
68
|
+
if (!isPinnedAbsolute(ceiling, path)) return defect(`${name}: ceiling must be an absolute path, received ${JSON.stringify(ceiling)} (ascending from ${JSON.stringify(start)})`);
|
|
69
|
+
const lexical = ascendChain(name, start, {
|
|
70
|
+
...maxDepth,
|
|
71
|
+
stopAt: ceiling
|
|
72
|
+
}, path);
|
|
73
|
+
if (lexical._tag === "Defect") return lexical;
|
|
74
|
+
const dirs = lexical.value;
|
|
75
|
+
const last = dirs.at(-1);
|
|
76
|
+
if (last !== void 0 && path.resolve(last) === path.resolve(ceiling)) return lexical;
|
|
77
|
+
const physical = yield* realPathOrUndefined(ceiling);
|
|
78
|
+
if (physical === void 0) return lexical;
|
|
79
|
+
for (let index = 0; index < dirs.length; index += 1) if ((yield* realPathOrUndefined(dirs[index] ?? "")) === physical) return done(dirs.slice(0, index + 1));
|
|
80
|
+
return lexical;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `ascendToMarker`: the lexical chain, stopped at the nearest directory that
|
|
84
|
+
* holds any of `markers`, inclusive. Probes stop at the hit — nothing above it
|
|
85
|
+
* is ever touched.
|
|
86
|
+
*/
|
|
87
|
+
function* ascendToMarkerMachine(name, start, markers, options, path) {
|
|
88
|
+
if (markers.length === 0) return defect(`${name}: markers must name at least one entry (ascending from ${JSON.stringify(start)})`);
|
|
89
|
+
if (markers.find((marker) => marker === "") !== void 0) return defect(`${name}: a marker must be a non-empty entry name (ascending from ${JSON.stringify(start)})`);
|
|
90
|
+
const chain = ascendChain(name, start, options?.maxDepth === void 0 ? {} : { maxDepth: options.maxDepth }, path);
|
|
91
|
+
if (chain._tag === "Defect") return chain;
|
|
92
|
+
const dirs = chain.value;
|
|
93
|
+
for (let index = 0; index < dirs.length; index += 1) {
|
|
94
|
+
const dir = dirs[index] ?? "";
|
|
95
|
+
for (const marker of markers) if (yield* exists(path.join(dir, marker))) return done(dirs.slice(0, index + 1));
|
|
96
|
+
}
|
|
97
|
+
return chain;
|
|
98
|
+
}
|
|
99
|
+
/** `findUpward`: the first existing candidate, directory-major, or `undefined`. */
|
|
100
|
+
function* findUpwardMachine(dirs, candidatesFor) {
|
|
101
|
+
const candidates = [];
|
|
102
|
+
for (const dir of dirs) candidates.push(...candidatesFor(dir));
|
|
103
|
+
for (const candidate of candidates) if (yield* exists(candidate)) return done(candidate);
|
|
104
|
+
return done(void 0);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* `findUpwardAll`: every existing candidate of the nearest directory with any
|
|
108
|
+
* hit, in candidate order — that directory's candidates are ALL probed, so a
|
|
109
|
+
* second hit (an ambiguity) is never hidden; nothing above it is probed.
|
|
110
|
+
*/
|
|
111
|
+
function* findUpwardAllMachine(dirs, candidatesFor) {
|
|
112
|
+
const expanded = dirs.map((dir) => ({
|
|
113
|
+
dir,
|
|
114
|
+
candidates: candidatesFor(dir)
|
|
115
|
+
}));
|
|
116
|
+
for (const { dir, candidates } of expanded) {
|
|
117
|
+
const found = [];
|
|
118
|
+
for (const candidate of candidates) if (yield* exists(candidate)) found.push(candidate);
|
|
119
|
+
const [first, ...rest] = found;
|
|
120
|
+
if (first !== void 0) return done({
|
|
121
|
+
dir,
|
|
122
|
+
found: [first, ...rest]
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return done(void 0);
|
|
126
|
+
}
|
|
127
|
+
/** A segment of dots and spaces only, which win32 trims to nothing or to `..`. */
|
|
128
|
+
const DOTS_AND_SPACES = /^[. ]+$/;
|
|
129
|
+
/** A win32 reserved device name, with or without an extension, in any case. */
|
|
130
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3]|conin\$|conout\$) *(?:\..*)?$/i;
|
|
131
|
+
/** A win32 drive designator at the start of a path: `C:`, `C:x`, `C:\x`. */
|
|
132
|
+
const DRIVE = /^[A-Za-z]:/;
|
|
133
|
+
/** Whether win32 reads a segment as something other than a plain entry (`.` and `..` are handled before). */
|
|
134
|
+
const isWin32Reserved = (segment) => segment.includes(":") || DOTS_AND_SPACES.test(segment) || DEVICE_NAME.test(segment);
|
|
135
|
+
/**
|
|
136
|
+
* Resolve `absolute` physically, segment by segment, the way the operating
|
|
137
|
+
* system would: a `..` after a link climbs out of the link's TARGET, never
|
|
138
|
+
* lexically out of the link. A first `RealPath` on the whole path settles the
|
|
139
|
+
* common case in one request when no `..` is present (a lexical-first
|
|
140
|
+
* `realpath` — node's JS `fs.realpath` — would otherwise collapse `link/..`
|
|
141
|
+
* before resolving the link). A path, or tail, that does not exist yet is
|
|
142
|
+
* resolved lexically from its real existing ancestor — but `NotFound` from a
|
|
143
|
+
* real-path request is NOT proof of absence: a dangling link answers it too,
|
|
144
|
+
* and a write through one creates its target wherever it points. So every
|
|
145
|
+
* `NotFound` segment is checked with `Present`, and only "nothing lives here"
|
|
146
|
+
* continues lexically; a dangling link (or an entry that appeared between the
|
|
147
|
+
* two requests) is unresolvable, its target unverified. Any failure other
|
|
148
|
+
* than `NotFound` makes the path unresolvable too. Fail closed throughout.
|
|
149
|
+
*/
|
|
150
|
+
function* resolvePhysical(absolute, path, win32, checkSegments) {
|
|
151
|
+
let root = absolute;
|
|
152
|
+
for (let parent = path.dirname(root); parent !== root; parent = path.dirname(root)) root = parent;
|
|
153
|
+
const segments = (absolute.startsWith(root) ? absolute.slice(root.length) : absolute).split(win32 ? /[\\/]/ : "/").filter((segment) => segment !== "");
|
|
154
|
+
if (checkSegments && win32) {
|
|
155
|
+
for (const segment of segments) if (segment !== "." && segment !== ".." && isWin32Reserved(segment)) return {
|
|
156
|
+
_tag: "Refused",
|
|
157
|
+
reason: "reservedName"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const isSep = (character) => character === path.sep || win32 && character === "/";
|
|
161
|
+
const real = (target) => realPath(target);
|
|
162
|
+
if (!segments.includes("..")) {
|
|
163
|
+
const whole = yield* real(absolute);
|
|
164
|
+
if (whole._tag === "Ok") return whole;
|
|
165
|
+
if (whole._tag === "Failed") return {
|
|
166
|
+
_tag: "Unresolvable",
|
|
167
|
+
cause: whole.cause
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const rootAnswer = yield* real(root);
|
|
171
|
+
if (rootAnswer._tag === "Failed") return {
|
|
172
|
+
_tag: "Unresolvable",
|
|
173
|
+
cause: rootAnswer.cause
|
|
174
|
+
};
|
|
175
|
+
let current = rootAnswer._tag === "Ok" ? rootAnswer.value : root;
|
|
176
|
+
for (const segment of segments) {
|
|
177
|
+
if (segment === ".") continue;
|
|
178
|
+
if (segment === "..") {
|
|
179
|
+
current = path.dirname(current);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const next = `${trimTrailing(current, isSep)}${path.sep}${segment}`;
|
|
183
|
+
const answer = yield* real(next);
|
|
184
|
+
if (answer._tag === "Failed") return {
|
|
185
|
+
_tag: "Unresolvable",
|
|
186
|
+
cause: answer.cause
|
|
187
|
+
};
|
|
188
|
+
if (answer._tag === "Ok") {
|
|
189
|
+
current = answer.value;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const entry = yield* present(next);
|
|
193
|
+
if (entry._tag === "Failed") return {
|
|
194
|
+
_tag: "Unresolvable",
|
|
195
|
+
cause: entry.cause
|
|
196
|
+
};
|
|
197
|
+
if (entry._tag === "Present") return {
|
|
198
|
+
_tag: "Unresolvable",
|
|
199
|
+
cause: answer.cause
|
|
200
|
+
};
|
|
201
|
+
current = next;
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
_tag: "Ok",
|
|
205
|
+
value: current
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* `confineReal`: the candidate's real path when it lies strictly inside the
|
|
210
|
+
* base's real path, or why not. A relative `base` is a wiring defect.
|
|
211
|
+
*/
|
|
212
|
+
function* confineRealMachine(name, base, candidate, path) {
|
|
213
|
+
if (base.includes("\0") || !isPinnedAbsolute(base, path)) return defect(`${name}: base must be an absolute path, received ${JSON.stringify(base)}`);
|
|
214
|
+
const refuse = (reason) => done({
|
|
215
|
+
_tag: "Refused",
|
|
216
|
+
reason
|
|
217
|
+
});
|
|
218
|
+
if (candidate.includes("\0")) return refuse("nulByte");
|
|
219
|
+
if (candidate === "") return refuse("empty");
|
|
220
|
+
const win32 = path.sep === "\\";
|
|
221
|
+
const isSep = (character) => character === path.sep || win32 && character === "/";
|
|
222
|
+
if (win32 && DRIVE.test(candidate) && !path.isAbsolute(candidate)) return refuse("absolute");
|
|
223
|
+
if (isCurrentDriveRooted(candidate, path.sep)) return refuse("absolute");
|
|
224
|
+
const full = path.isAbsolute(candidate) ? candidate : `${trimTrailing(base, isSep)}${path.sep}${candidate}`;
|
|
225
|
+
const baseReal = yield* resolvePhysical(base, path, win32, false);
|
|
226
|
+
if (baseReal._tag !== "Ok") return done(baseReal);
|
|
227
|
+
const candidateReal = yield* resolvePhysical(full, path, win32, true);
|
|
228
|
+
if (candidateReal._tag !== "Ok") return done(candidateReal);
|
|
229
|
+
const root = trimTrailing(baseReal.value, isSep);
|
|
230
|
+
const resolved = trimTrailing(candidateReal.value, isSep);
|
|
231
|
+
if (resolved === root) return refuse("empty");
|
|
232
|
+
if (!resolved.startsWith(`${root}${path.sep}`)) return refuse("outside");
|
|
233
|
+
return done({
|
|
234
|
+
_tag: "Ok",
|
|
235
|
+
value: candidateReal.value
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export { ascendChain, ascendToMarkerMachine, ascendWithinMachine, confineRealMachine, findUpwardAllMachine, findUpwardMachine };
|