mdrefine 0.2.0-rc.2 → 0.2.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/CHANGELOG.md +577 -0
- package/CODE_OF_CONDUCT.md +30 -0
- package/CONTRIBUTING.md +189 -16
- package/README.md +100 -154
- package/SECURITY.md +15 -13
- package/config.schema.json +91 -0
- package/dist/cli/diff.d.ts +10 -0
- package/dist/cli/diff.js +247 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/main.js +270 -150
- package/dist/cli/main.js.map +1 -1
- package/dist/config/load.js +67 -6
- package/dist/config/load.js.map +1 -1
- package/dist/config/problems.d.ts +7 -0
- package/dist/config/problems.js +208 -0
- package/dist/config/problems.js.map +1 -0
- package/dist/config/resolve.d.ts +42 -109
- package/dist/config/resolve.js +193 -52
- package/dist/config/resolve.js.map +1 -1
- package/dist/core/directives.d.ts +7 -0
- package/dist/core/directives.js +25 -13
- package/dist/core/directives.js.map +1 -1
- package/dist/core/engine.d.ts +42 -4
- package/dist/core/engine.js +518 -79
- package/dist/core/engine.js.map +1 -1
- package/dist/core/frozen.d.ts +6 -0
- package/dist/core/frozen.js +15 -0
- package/dist/core/frozen.js.map +1 -0
- package/dist/core/scope.d.ts +30 -0
- package/dist/core/scope.js +122 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/core/types.d.ts +202 -23
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +3 -0
- package/dist/node.js +4 -0
- package/dist/node.js.map +1 -0
- package/dist/rules/blocks.d.ts +34 -0
- package/dist/rules/blocks.js +550 -0
- package/dist/rules/blocks.js.map +1 -0
- package/dist/rules/content.d.ts +2 -0
- package/dist/rules/content.js +85 -0
- package/dist/rules/content.js.map +1 -0
- package/dist/rules/dialects.js +10 -6
- package/dist/rules/dialects.js.map +1 -1
- package/dist/rules/links.js +302 -6
- package/dist/rules/links.js.map +1 -1
- package/dist/rules/lists.d.ts +2 -0
- package/dist/rules/lists.js +924 -0
- package/dist/rules/lists.js.map +1 -0
- package/dist/rules/structure.js +127 -1
- package/dist/rules/structure.js.map +1 -1
- package/dist/rules/style.d.ts +8 -2
- package/dist/rules/style.js +1137 -165
- package/dist/rules/style.js.map +1 -1
- package/dist/syntax/block-identifier.d.ts +18 -0
- package/dist/syntax/block-identifier.js +24 -0
- package/dist/syntax/block-identifier.js.map +1 -0
- package/dist/syntax/obsidian.d.ts +29 -11
- package/dist/syntax/obsidian.js +9 -10
- package/dist/syntax/obsidian.js.map +1 -1
- package/dist/syntax/parse.d.ts +87 -3
- package/dist/syntax/parse.js +247 -19
- package/dist/syntax/parse.js.map +1 -1
- package/dist/workspace/access.d.ts +12 -3
- package/dist/workspace/access.js +33 -5
- package/dist/workspace/access.js.map +1 -1
- package/dist/workspace/discovery.d.ts +85 -0
- package/dist/workspace/discovery.js +155 -0
- package/dist/workspace/discovery.js.map +1 -0
- package/dist/workspace/encoding.d.ts +24 -0
- package/dist/workspace/encoding.js +113 -0
- package/dist/workspace/encoding.js.map +1 -0
- package/dist/workspace/files.d.ts +92 -6
- package/dist/workspace/files.js +715 -59
- package/dist/workspace/files.js.map +1 -1
- package/dist/workspace/git.d.ts +54 -0
- package/dist/workspace/git.js +198 -0
- package/dist/workspace/git.js.map +1 -0
- package/dist/workspace/index.d.ts +58 -3
- package/dist/workspace/index.js +136 -37
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/selection.d.ts +2 -2
- package/dist/workspace/selection.js +3 -3
- package/dist/workspace/selection.js.map +1 -1
- package/dist/workspace/slug.js +8 -1
- package/dist/workspace/slug.js.map +1 -1
- package/dist/workspace/vault.js +3 -1
- package/dist/workspace/vault.js.map +1 -1
- package/docs/api.md +239 -0
- package/docs/benchmarks.md +140 -200
- package/docs/changelog.md +1 -0
- package/docs/cli.md +281 -89
- package/docs/configuration.md +193 -480
- package/docs/design.md +163 -284
- package/docs/dialects.md +169 -0
- package/docs/examples.md +151 -109
- package/docs/index.md +21 -37
- package/docs/integrations.md +144 -0
- package/docs/obsidian.md +91 -115
- package/docs/plugins.md +147 -212
- package/docs/quick-start.md +68 -74
- package/docs/rules.md +1237 -0
- package/docs/versions.md +56 -0
- package/examples/README.md +17 -14
- package/examples/automation/summarize-report.mjs +5 -2
- package/examples/library/lint-folder.mjs +5 -4
- package/examples/library/lint-workspace.mjs +32 -0
- package/examples/plugin/notes/release-checklist.md +2 -2
- package/examples/plugin/rules.mjs +17 -23
- package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
- package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
- package/examples/workspaces/repository-docs/after/README.md +2 -2
- package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
- package/examples/workspaces/repository-docs/before/README.md +2 -2
- package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
- package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
- package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
- package/package.json +38 -12
- package/docs/contributing.md +0 -218
- package/docs/releases/0.1.0-beta.1.md +0 -39
- package/docs/releases/0.1.0-beta.2.md +0 -83
- package/docs/releases/0.1.0-beta.3.md +0 -61
- package/docs/releases/0.1.0-beta.4.md +0 -50
- package/docs/releases/0.1.0-rc.1.md +0 -99
- package/docs/releases/0.2.0-rc.1.md +0 -141
- package/docs/releases/0.2.0-rc.2.md +0 -225
- package/docs/releases.md +0 -144
- package/docs/testing.md +0 -93
package/dist/workspace/files.js
CHANGED
|
@@ -1,11 +1,242 @@
|
|
|
1
|
-
import { lstat, readdir, readFile, realpath, open,
|
|
1
|
+
import { access, lstat, readdir, readFile, realpath, open, unlink } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import ignore from "ignore";
|
|
6
|
-
import { minimatch } from "minimatch";
|
|
7
|
-
import { exists, pathError, refusal, refusalText } from "./access.js";
|
|
6
|
+
import { minimatch, Minimatch } from "minimatch";
|
|
7
|
+
import { exists, pathError, refusal, refusalText, systemErrorText } from "./access.js";
|
|
8
|
+
import { decodeText } from "./encoding.js";
|
|
9
|
+
import { excludesFile, ignoreSettings, optionalText, repositoryTop, trackedFiles } from "./git.js";
|
|
10
|
+
import { isMarkdownName } from "./index.js";
|
|
8
11
|
import { enclosingVault, isVault, lineBreakSetting } from "./vault.js";
|
|
12
|
+
/** The words of each skipped entry's message, with the paths it names left to the caller. */
|
|
13
|
+
const wording = new WeakMap();
|
|
14
|
+
/** A skipped entry whose message names paths relative to the root. */
|
|
15
|
+
function skip(entry, words) {
|
|
16
|
+
const item = { ...entry, message: words((file) => file) };
|
|
17
|
+
wording.set(item, words);
|
|
18
|
+
return item;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The message of a skipped entry with each path of the workspace that it names
|
|
22
|
+
* given by `show`, which receives the path relative to the root. The CLI's text
|
|
23
|
+
* names them from the current directory.
|
|
24
|
+
*/
|
|
25
|
+
export function skippedMessage(item, show) {
|
|
26
|
+
return wording.get(item)?.(show) ?? item.message;
|
|
27
|
+
}
|
|
28
|
+
/** The skipped entries of named inputs that ignore rules leave out. */
|
|
29
|
+
const ignoredInputs = new WeakSet();
|
|
30
|
+
/**
|
|
31
|
+
* Whether a skipped entry reports a named file or folder that discovery leaves
|
|
32
|
+
* out as ignored, a warning that the CLI's `--no-warn-ignored` leaves out. An
|
|
33
|
+
* ignored workspace root is not one: no path named it, and none of its files is
|
|
34
|
+
* processed.
|
|
35
|
+
*/
|
|
36
|
+
export function isIgnoredInput(item) {
|
|
37
|
+
return ignoredInputs.has(item);
|
|
38
|
+
}
|
|
39
|
+
/** A file of ignore rules as a message shows it: one outside the repository by its full path. */
|
|
40
|
+
function showFile(show, file) {
|
|
41
|
+
return path.isAbsolute(file) ? file : show(file);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Names of folders and files that discovery never enters or selects, wherever
|
|
45
|
+
* they are. A link can still name what is in them; `unlistedLookup` finds it.
|
|
46
|
+
*/
|
|
47
|
+
const alwaysSkipped = new Set([".git", ".obsidian", "node_modules", "dist", "coverage"]);
|
|
48
|
+
/**
|
|
49
|
+
* Whether letter case is ignored where the file system usually ignores it, as
|
|
50
|
+
* those of Windows and macOS do by default. Git turns `core.ignorecase` on when
|
|
51
|
+
* it creates a repository on such a file system, and `.gitignore` patterns
|
|
52
|
+
* match accordingly; where Git cannot say, this stands in for the setting.
|
|
53
|
+
*/
|
|
54
|
+
const caseInsensitive = process.platform === "win32" || process.platform === "darwin";
|
|
55
|
+
/**
|
|
56
|
+
* Characters that make an input a pattern when no path of its name exists:
|
|
57
|
+
* wildcards, a character class, a brace list, or an extended group.
|
|
58
|
+
*/
|
|
59
|
+
const patternCharacters = /[*?[{]|[!@+]\(/;
|
|
60
|
+
/**
|
|
61
|
+
* How input patterns match, on every system: `/` separates folders and `\`
|
|
62
|
+
* escapes the next character; a name starting with a dot is matched like any
|
|
63
|
+
* other, as discovery finds it; letter case is ignored where the file system
|
|
64
|
+
* usually ignores it, as the `glob` package does.
|
|
65
|
+
*/
|
|
66
|
+
const patternOptions = { dot: true, nocomment: true, nonegate: true, nocase: caseInsensitive };
|
|
67
|
+
/** A gitignore pattern that matches exactly one folder, at a path relative to the file's folder. */
|
|
68
|
+
function folderOnly(folder) {
|
|
69
|
+
return `/${folder.replace(/[\\*?[]/g, "\\$&")}/`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The matchers for the entries of the folder `at`, relative to where the
|
|
73
|
+
* layers' folders are, one for each file of rules above them, nearest last,
|
|
74
|
+
* with the path from that file's folder. Git asks each file only whether its
|
|
75
|
+
* patterns match the path itself; that nothing below an excluded folder counts
|
|
76
|
+
* is settled by the walk, which never enters one. The `ignore` package also
|
|
77
|
+
* answers that a path is ignored when the file's patterns exclude a folder
|
|
78
|
+
* above it, which is wrong when a deeper `.gitignore` included that folder
|
|
79
|
+
* again. For such a folder, the file's patterns are tested with each folder on
|
|
80
|
+
* the way marked as included.
|
|
81
|
+
*/
|
|
82
|
+
function ignoreScopes(at, layers) {
|
|
83
|
+
return layers.map((layer) => {
|
|
84
|
+
const inner = at.slice(layer.base ? layer.base.length + 1 : 0);
|
|
85
|
+
let { matcher } = layer;
|
|
86
|
+
if (inner && matcher.test(`${inner}/`).ignored) {
|
|
87
|
+
const folders = inner
|
|
88
|
+
.split("/")
|
|
89
|
+
.map((_, index, parts) => parts.slice(0, index + 1).join("/"));
|
|
90
|
+
matcher = ignore({ ignorecase: layer.ignoreCase })
|
|
91
|
+
.add(layer.text)
|
|
92
|
+
.add(folders.map((folder) => `!${folderOnly(folder)}`));
|
|
93
|
+
}
|
|
94
|
+
return { layer, matcher, prefix: inner ? `${inner}/` : "" };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The file and pattern that exclude an entry, or undefined when none does: the
|
|
99
|
+
* nearest file whose patterns match the entry decides, and a negated pattern
|
|
100
|
+
* includes it.
|
|
101
|
+
*/
|
|
102
|
+
function gitIgnoreRule(scopes, entry, directory) {
|
|
103
|
+
for (let index = scopes.length - 1; index >= 0; index--) {
|
|
104
|
+
const { layer, matcher, prefix } = scopes[index];
|
|
105
|
+
const result = matcher.test(prefix + entry + (directory ? "/" : ""));
|
|
106
|
+
if (result.ignored)
|
|
107
|
+
return { file: layer.file, pattern: result.rule?.pattern ?? "" };
|
|
108
|
+
if (result.unignored)
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The layer of the rules `text`, read from `file`, for the folder `base`, in a
|
|
115
|
+
* repository that ignores letter case or not.
|
|
116
|
+
*/
|
|
117
|
+
function ignoreLayer(base, file, text, ignoreCase) {
|
|
118
|
+
return { base, file, text, ignoreCase, matcher: ignore({ ignorecase: ignoreCase }).add(text) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* An input that names no existing path, read as a pattern relative to `base`.
|
|
122
|
+
* The literal folders at the start of each alternative are located as a path
|
|
123
|
+
* is, so the pattern can match paths from the workspace root. Undefined for an
|
|
124
|
+
* input without pattern characters.
|
|
125
|
+
*/
|
|
126
|
+
async function patternInput(input, root, base) {
|
|
127
|
+
if (!patternCharacters.test(input))
|
|
128
|
+
return undefined;
|
|
129
|
+
const result = { input, matchers: [], folders: [], matched: false };
|
|
130
|
+
for (const alternative of minimatch.braceExpand(input)) {
|
|
131
|
+
const parts = alternative.split("/");
|
|
132
|
+
let literal = 0;
|
|
133
|
+
while (literal < parts.length - 1 &&
|
|
134
|
+
!patternCharacters.test(parts[literal]) &&
|
|
135
|
+
!parts[literal].includes("\\"))
|
|
136
|
+
literal++;
|
|
137
|
+
const prefix = parts.slice(0, literal).join("/");
|
|
138
|
+
// A pattern that starts with `/` or with a drive and `/`, such as `C:/`,
|
|
139
|
+
// starts at that root: `C:` alone names the drive's current folder.
|
|
140
|
+
const start = literal && /^(?:[A-Za-z]:)?$/.test(prefix) ? `${prefix}/` : prefix || ".";
|
|
141
|
+
let folder;
|
|
142
|
+
try {
|
|
143
|
+
folder = await realpath(path.resolve(base, start));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const code = error.code;
|
|
147
|
+
// A folder that is not there holds nothing that could match.
|
|
148
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
149
|
+
continue;
|
|
150
|
+
throw pathError("Input", input, error);
|
|
151
|
+
}
|
|
152
|
+
const relative = path.relative(root, folder).split(path.sep).join("/");
|
|
153
|
+
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative))
|
|
154
|
+
throw new Error(`Input is outside the workspace: ${input}`);
|
|
155
|
+
const rest = parts.slice(literal).join("/");
|
|
156
|
+
result.folders.push(relative);
|
|
157
|
+
result.matchers.push(new Minimatch(relative ? `${relative.replace(/[\\*?[\](){}!+@]/g, "\\$&")}/${rest}` : rest, {
|
|
158
|
+
...patternOptions,
|
|
159
|
+
// The braces were expanded already; a folder's name may hold one.
|
|
160
|
+
nobrace: true,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The workspace's lookup of a path at or below one of the set's `unlisted`
|
|
167
|
+
* entries, on disk: a link that names such a path is checked there instead of
|
|
168
|
+
* being called missing. The workspace root still bounds what is read, so a
|
|
169
|
+
* path whose real location lies outside it, through a symbolic link or a
|
|
170
|
+
* junction, is reported as such and not read.
|
|
171
|
+
*/
|
|
172
|
+
export function unlistedLookup(set) {
|
|
173
|
+
const exact = new Set(set.unlisted);
|
|
174
|
+
// Obsidian matches paths without regard to case.
|
|
175
|
+
const folded = new Map(set.unlisted.map((name) => [name.toLowerCase(), name]));
|
|
176
|
+
return (name, fold) => {
|
|
177
|
+
if (!set.unlisted.length)
|
|
178
|
+
return undefined;
|
|
179
|
+
const parts = name.split("/");
|
|
180
|
+
for (let count = 1; count <= parts.length; count++) {
|
|
181
|
+
const prefix = parts.slice(0, count).join("/");
|
|
182
|
+
const entry = fold
|
|
183
|
+
? folded.get(prefix.toLowerCase())
|
|
184
|
+
: exact.has(prefix)
|
|
185
|
+
? prefix
|
|
186
|
+
: undefined;
|
|
187
|
+
if (entry !== undefined)
|
|
188
|
+
return onDisk(set.root, [entry, ...parts.slice(count)].join("/"));
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** What is at the path `name` below `root`, following links as far as the root allows. */
|
|
194
|
+
function onDisk(root, name) {
|
|
195
|
+
const parts = name.split("/");
|
|
196
|
+
// Each step on the way, so that what stands in the way can be named.
|
|
197
|
+
for (let count = 1; count <= parts.length; count++) {
|
|
198
|
+
const step = parts.slice(0, count).join("/");
|
|
199
|
+
let actual;
|
|
200
|
+
try {
|
|
201
|
+
if (!lstatSync(path.join(root, step)).isSymbolicLink())
|
|
202
|
+
continue;
|
|
203
|
+
actual = realpathSync.native(path.join(root, step));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const code = error.code;
|
|
207
|
+
// A path through something that is not there, or a link that leads nowhere.
|
|
208
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
|
|
209
|
+
return undefined;
|
|
210
|
+
if (refusal(error))
|
|
211
|
+
return { type: "unreadable", at: count > 1 ? parts.slice(0, count - 1).join("/") : step };
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
const relative = path.relative(root, actual);
|
|
215
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
216
|
+
return { type: "outside", through: step };
|
|
217
|
+
}
|
|
218
|
+
let info;
|
|
219
|
+
try {
|
|
220
|
+
info = statSync(path.join(root, name));
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const code = error.code;
|
|
224
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
|
|
225
|
+
return undefined;
|
|
226
|
+
if (refusal(error))
|
|
227
|
+
return { type: "unreadable", at: name };
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
if (info.isDirectory())
|
|
231
|
+
return { type: "directory" };
|
|
232
|
+
const full = path.join(root, name);
|
|
233
|
+
let cached;
|
|
234
|
+
return {
|
|
235
|
+
type: "file",
|
|
236
|
+
path: name,
|
|
237
|
+
source: isMarkdownName(name) ? () => (cached ??= decodeText(readFileSync(full), name)) : null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
9
240
|
/** The workspace's record of the vault in `directory`, and of its settings file if unusable. */
|
|
10
241
|
async function describeVault(root, part, directory) {
|
|
11
242
|
const setting = await lineBreakSetting(directory);
|
|
@@ -25,12 +256,7 @@ async function describeVault(root, part, directory) {
|
|
|
25
256
|
.join("/");
|
|
26
257
|
return {
|
|
27
258
|
vault,
|
|
28
|
-
skipped: {
|
|
29
|
-
path: file,
|
|
30
|
-
type: "file",
|
|
31
|
-
code: problem.code,
|
|
32
|
-
message: `Skipped ${problem.code === "INVALID" ? "unusable" : "unreadable"} settings file: ${file} (${problem.reason}). Obsidian's strictLineBreaks setting is not verified for that vault, so its documents are not reflowed.`,
|
|
33
|
-
},
|
|
259
|
+
skipped: skip({ path: file, type: "file", code: problem.code }, (show) => `Skipped ${problem.code === "INVALID" ? "unusable" : "unreadable"} settings file: ${show(file)} (${problem.reason}). Obsidian's strictLineBreaks setting is not verified for that vault, so its documents are not reflowed.`),
|
|
34
260
|
};
|
|
35
261
|
}
|
|
36
262
|
/**
|
|
@@ -54,30 +280,209 @@ export async function vaultFor(set, name) {
|
|
|
54
280
|
set.skipped.push(described.skipped);
|
|
55
281
|
return described.vault;
|
|
56
282
|
}
|
|
57
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Where Git's ignore rules for the workspace at `root` come from. They start at
|
|
285
|
+
* `top`, the top level of the repository that holds the root, which can lie
|
|
286
|
+
* above it; in each repository nested in the workspace, only its own rules
|
|
287
|
+
* apply. What a repository tracks is never ignored.
|
|
288
|
+
*/
|
|
289
|
+
function gitRules(root, top) {
|
|
290
|
+
/** The root, relative to the repository's top level. */
|
|
291
|
+
const lead = top ? path.relative(top, root).split(path.sep).join("/") : "";
|
|
292
|
+
/** A path relative to the root, relative to the repository's top level instead. */
|
|
293
|
+
const fromTop = (relative) => lead && relative ? `${lead}/${relative}` : lead || relative;
|
|
294
|
+
/** How messages name a file of rules: from the root, or in full outside the repository. */
|
|
295
|
+
function shown(file) {
|
|
296
|
+
const inside = path.relative(top ?? root, file);
|
|
297
|
+
if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside))
|
|
298
|
+
return file;
|
|
299
|
+
return path.relative(root, file).split(path.sep).join("/");
|
|
300
|
+
}
|
|
301
|
+
/** The file whose read failed, as messages name it. */
|
|
302
|
+
const failed = (error, fallback) => shown(error.path ?? fallback);
|
|
303
|
+
/**
|
|
304
|
+
* The tracked files, from the top level, and the folders that hold them,
|
|
305
|
+
* each kept as its repository compares letter case: as they are, or in lower
|
|
306
|
+
* case.
|
|
307
|
+
*/
|
|
308
|
+
const exact = { tracked: new Set(), holding: new Set() };
|
|
309
|
+
const folded = { tracked: new Set(), holding: new Set() };
|
|
310
|
+
/**
|
|
311
|
+
* The rules for the top level of the repository in `directory`, `base` from
|
|
312
|
+
* the outer repository's top level, with the lowest precedence first: the
|
|
313
|
+
* global excludes file that the repository's settings name, then its
|
|
314
|
+
* `info/exclude`; and whether its patterns ignore letter case. What the
|
|
315
|
+
* repository tracks is recorded on the way.
|
|
316
|
+
*/
|
|
317
|
+
async function repositoryLayers(directory, base) {
|
|
318
|
+
const settings = ignoreSettings(directory).then(async (found) => ({
|
|
319
|
+
...found,
|
|
320
|
+
text: found.excludesFile === undefined ? undefined : await optionalText(found.excludesFile),
|
|
321
|
+
}));
|
|
322
|
+
const listed = trackedFiles(directory);
|
|
323
|
+
let text;
|
|
324
|
+
let exclude;
|
|
325
|
+
try {
|
|
326
|
+
exclude = await excludesFile(directory);
|
|
327
|
+
text = exclude === undefined ? undefined : await optionalText(exclude);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
// Git runs in the repository's folder, which Windows does not let anyone
|
|
331
|
+
// remove until it is done, so discovery waits for it also after a failure.
|
|
332
|
+
await Promise.allSettled([settings, listed]);
|
|
333
|
+
}
|
|
334
|
+
const global = await settings;
|
|
335
|
+
// Where Git cannot say, case is compared as the file system usually does.
|
|
336
|
+
const ignoreCase = global.ignoreCase ?? caseInsensitive;
|
|
337
|
+
const sets = ignoreCase ? folded : exact;
|
|
338
|
+
for (const name of (await listed) ?? []) {
|
|
339
|
+
let at = base ? `${base}/${name}` : name;
|
|
340
|
+
if (ignoreCase)
|
|
341
|
+
at = at.toLowerCase();
|
|
342
|
+
sets.tracked.add(at);
|
|
343
|
+
for (at = path.posix.dirname(at); at !== "." && !sets.holding.has(at); at = path.posix.dirname(at))
|
|
344
|
+
sets.holding.add(at);
|
|
345
|
+
}
|
|
346
|
+
const layers = [];
|
|
347
|
+
if (global.text !== undefined)
|
|
348
|
+
layers.push(ignoreLayer(base, shown(global.excludesFile), global.text, ignoreCase));
|
|
349
|
+
if (text !== undefined)
|
|
350
|
+
layers.push(ignoreLayer(base, shown(exclude), text, ignoreCase));
|
|
351
|
+
return { layers, ignoreCase };
|
|
352
|
+
}
|
|
353
|
+
/** Whether a repository tracks the path `at`, from the top level, or a file in that folder. */
|
|
354
|
+
function isTracked(at) {
|
|
355
|
+
if (exact.tracked.has(at) || exact.holding.has(at))
|
|
356
|
+
return true;
|
|
357
|
+
if (!folded.tracked.size)
|
|
358
|
+
return false;
|
|
359
|
+
const lower = at.toLowerCase();
|
|
360
|
+
return folded.tracked.has(lower) || folded.holding.has(lower);
|
|
361
|
+
}
|
|
362
|
+
return { lead, fromTop, shown, failed, repositoryLayers, isTracked };
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The files of the workspace at `rootPath`, and the documents that `supplied`
|
|
366
|
+
* files, folders, and patterns select, or all of them when `supplied` is
|
|
367
|
+
* empty. Relative inputs are resolved from `base`.
|
|
368
|
+
*
|
|
369
|
+
* A folder or pattern selects the Markdown files in it or matching it, by
|
|
370
|
+
* name (`isMarkdownName`), that nothing ignores. A file named on its own is
|
|
371
|
+
* selected whatever its name: naming it is the request. When ignore rules
|
|
372
|
+
* leave out a named file or folder, `skipped` says so and why.
|
|
373
|
+
*
|
|
374
|
+
* `documents` are paths from the root of documents whose text a caller
|
|
375
|
+
* supplies, which need not exist. They are not selected here, but ignore rules
|
|
376
|
+
* leave them out as named files, as far as their folders exist. With
|
|
377
|
+
* `selectNone`, the caller selects nothing from disk, and an ignored root is
|
|
378
|
+
* not reported.
|
|
379
|
+
*/
|
|
380
|
+
export async function discover(rootPath, supplied, excluded, resolve = {}, base = process.cwd(), { documents = [], selectNone = false } = {}) {
|
|
58
381
|
const root = await realpath(rootPath);
|
|
382
|
+
let top;
|
|
383
|
+
try {
|
|
384
|
+
top = await repositoryTop(root);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const code = refusal(error);
|
|
388
|
+
if (!code)
|
|
389
|
+
throw error;
|
|
390
|
+
// Only the root itself can refuse it: each folder above leads to the root.
|
|
391
|
+
throw new Error(`Workspace root cannot be read: ${rootPath} (${refusalText(code)})`, {
|
|
392
|
+
cause: error,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const { lead, fromTop, shown, failed, repositoryLayers, isTracked } = gitRules(root, top);
|
|
396
|
+
// Compiled once: compiling a pattern costs more than matching it.
|
|
397
|
+
const ignorePatterns = excluded.map((pattern) => ({
|
|
398
|
+
pattern,
|
|
399
|
+
matcher: new Minimatch(pattern, { dot: true }),
|
|
400
|
+
}));
|
|
59
401
|
const files = {};
|
|
60
402
|
const directories = [];
|
|
403
|
+
const unlisted = [];
|
|
61
404
|
const selected = [];
|
|
405
|
+
/** The folders that select their Markdown files, relative to the root. */
|
|
62
406
|
const requests = [];
|
|
407
|
+
/** The paths named as inputs, by their own path and that of each folder above them. */
|
|
408
|
+
const named = new Map();
|
|
409
|
+
const namedInputs = new Map();
|
|
410
|
+
const patterns = [];
|
|
411
|
+
/** Each input as given, with a path from the root that it names or searches. */
|
|
412
|
+
const located = [];
|
|
63
413
|
/** The folders that are vaults, by the part of the workspace that each holds. */
|
|
64
414
|
const vaultFolders = new Map();
|
|
65
415
|
for (const input of supplied.length ? supplied : [root]) {
|
|
66
|
-
const absolute = path.resolve(input);
|
|
416
|
+
const absolute = path.resolve(base, input);
|
|
67
417
|
let resolved;
|
|
418
|
+
let directory;
|
|
68
419
|
try {
|
|
69
|
-
|
|
420
|
+
const info = await lstat(absolute);
|
|
421
|
+
if (info.isSymbolicLink())
|
|
70
422
|
throw new Error(`Refusing symbolic-link input: ${input}`);
|
|
423
|
+
directory = info.isDirectory();
|
|
71
424
|
resolved = await realpath(absolute);
|
|
72
425
|
}
|
|
73
426
|
catch (error) {
|
|
427
|
+
const code = error.code;
|
|
428
|
+
const pattern = code === "ENOENT" || code === "ENOTDIR" ? await patternInput(input, root, base) : undefined;
|
|
429
|
+
if (pattern) {
|
|
430
|
+
patterns.push(pattern);
|
|
431
|
+
for (const folder of pattern.folders)
|
|
432
|
+
located.push({ input, path: folder });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
74
435
|
throw pathError("Input", input, error);
|
|
75
436
|
}
|
|
76
437
|
const relative = path.relative(root, resolved).split(path.sep).join("/");
|
|
77
438
|
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative))
|
|
78
439
|
throw new Error(`Input is outside the workspace: ${input}`);
|
|
79
|
-
|
|
440
|
+
located.push({ input, path: relative });
|
|
441
|
+
if (directory)
|
|
442
|
+
requests.push(relative);
|
|
443
|
+
if (!relative || namedInputs.has(relative))
|
|
444
|
+
continue;
|
|
445
|
+
const request = { path: relative, directory };
|
|
446
|
+
namedInputs.set(relative, request);
|
|
447
|
+
for (let at = relative; at !== "."; at = path.posix.dirname(at)) {
|
|
448
|
+
const list = named.get(at);
|
|
449
|
+
if (list)
|
|
450
|
+
list.push(request);
|
|
451
|
+
else
|
|
452
|
+
named.set(at, [request]);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** The supplied documents, which ignore rules leave out as they do named files. */
|
|
456
|
+
const documentInputs = [];
|
|
457
|
+
for (const name of documents) {
|
|
458
|
+
const existing = namedInputs.get(name);
|
|
459
|
+
if (existing) {
|
|
460
|
+
if (!existing.directory)
|
|
461
|
+
documentInputs.push(existing);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const request = { path: name, directory: false, document: true };
|
|
465
|
+
namedInputs.set(name, request);
|
|
466
|
+
documentInputs.push(request);
|
|
467
|
+
for (let at = name; at !== "."; at = path.posix.dirname(at)) {
|
|
468
|
+
const list = named.get(at);
|
|
469
|
+
if (list)
|
|
470
|
+
list.push(request);
|
|
471
|
+
else
|
|
472
|
+
named.set(at, [request]);
|
|
473
|
+
}
|
|
80
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Record why the entry `name` leaves out the named inputs at or below it for
|
|
477
|
+
* which `applies` holds, unless something above it did already.
|
|
478
|
+
*/
|
|
479
|
+
function leaveNamed(name, why, applies = () => true) {
|
|
480
|
+
for (const request of named.get(name) ?? [])
|
|
481
|
+
if (applies(request))
|
|
482
|
+
request.reason ??= why(request);
|
|
483
|
+
}
|
|
484
|
+
/** Whether Git's rules can leave out a named input: Git ignores nothing that it tracks. */
|
|
485
|
+
const untracked = (input) => !isTracked(fromTop(input.path));
|
|
81
486
|
/** Directories left out, with the path whose read the system refused. */
|
|
82
487
|
const refused = [];
|
|
83
488
|
const reason = (item, subject) => refusalText(item.code) + (item.reading === subject ? "" : ` for ${item.reading}`);
|
|
@@ -94,7 +499,38 @@ export async function discover(rootPath, supplied, excluded, resolve = {}) {
|
|
|
94
499
|
throw new Error(`Workspace root cannot be read: ${rootPath} (${reason({ reading, code }, "")})`, { cause: error });
|
|
95
500
|
refused.push({ directory, reading, code });
|
|
96
501
|
}
|
|
97
|
-
|
|
502
|
+
/** The rules that apply at the root, from the repository's top level down. */
|
|
503
|
+
let rootLayers = [];
|
|
504
|
+
/** Whether the rules of the repository that holds the root ignore letter case. */
|
|
505
|
+
let rootIgnoreCase = caseInsensitive;
|
|
506
|
+
/** The folder at or above the root that Git ignores, from the top level, and the rule. */
|
|
507
|
+
let ignoredRoot;
|
|
508
|
+
if (top) {
|
|
509
|
+
try {
|
|
510
|
+
({ layers: rootLayers, ignoreCase: rootIgnoreCase } = await repositoryLayers(top, ""));
|
|
511
|
+
const parts = lead ? lead.split("/") : [];
|
|
512
|
+
for (let index = 0; index < parts.length && !ignoredRoot; index++) {
|
|
513
|
+
const at = parts.slice(0, index).join("/");
|
|
514
|
+
const file = path.join(top, ...parts.slice(0, index), ".gitignore");
|
|
515
|
+
const text = await optionalText(file);
|
|
516
|
+
if (text !== undefined)
|
|
517
|
+
rootLayers.push(ignoreLayer(at, shown(file), text, rootIgnoreCase));
|
|
518
|
+
const rule = gitIgnoreRule(ignoreScopes(at, rootLayers), parts[index], true);
|
|
519
|
+
if (rule)
|
|
520
|
+
ignoredRoot = { folder: parts.slice(0, index + 1).join("/"), ...rule };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
leaveOut("", failed(error, path.join(top, ".git")), error);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Visit a directory, whose repository's rules ignore letter case or not.
|
|
529
|
+
* Below a folder that Git's rules exclude, nothing can be included again, as
|
|
530
|
+
* in Git, except what Git tracks. Such a folder is visited for what it
|
|
531
|
+
* tracks, and otherwise only for link targets.
|
|
532
|
+
*/
|
|
533
|
+
async function walk(relative, inherited, ignoreCase, targetOnly = false, gitIgnoredAbove = false) {
|
|
98
534
|
const directory = path.join(root, relative);
|
|
99
535
|
const layers = [...inherited];
|
|
100
536
|
const rules = path.join(directory, ".gitignore");
|
|
@@ -103,9 +539,10 @@ export async function discover(rootPath, supplied, excluded, resolve = {}) {
|
|
|
103
539
|
// ignore rules nothing tells which of the directory's files may be touched.
|
|
104
540
|
let reading = relative;
|
|
105
541
|
try {
|
|
106
|
-
if (await exists(rules)) {
|
|
542
|
+
if (!gitIgnoredAbove && (await exists(rules))) {
|
|
107
543
|
reading = relative ? `${relative}/.gitignore` : ".gitignore";
|
|
108
|
-
|
|
544
|
+
const text = await readFile(rules, "utf8");
|
|
545
|
+
layers.push(ignoreLayer(fromTop(relative), reading, text, ignoreCase));
|
|
109
546
|
reading = relative;
|
|
110
547
|
}
|
|
111
548
|
entries = await readdir(directory, { withFileTypes: true });
|
|
@@ -118,12 +555,32 @@ export async function discover(rootPath, supplied, excluded, resolve = {}) {
|
|
|
118
555
|
const settings = entries.find((entry) => entry.name === ".obsidian");
|
|
119
556
|
if (settings?.isDirectory() || (settings?.isSymbolicLink() && (await isVault(directory))))
|
|
120
557
|
vaultFolders.set(relative, directory);
|
|
558
|
+
const scopes = gitIgnoredAbove ? [] : ignoreScopes(fromTop(relative), layers);
|
|
121
559
|
for (const entry of entries) {
|
|
122
|
-
if ([".git", ".obsidian", "node_modules", ".npm-cache", "dist", "coverage"].includes(entry.name) ||
|
|
123
|
-
entry.isSymbolicLink())
|
|
124
|
-
continue;
|
|
125
560
|
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
126
|
-
const
|
|
561
|
+
const rule = gitIgnoredAbove
|
|
562
|
+
? undefined
|
|
563
|
+
: gitIgnoreRule(scopes, entry.name, entry.isDirectory());
|
|
564
|
+
const gitExcluded = gitIgnoredAbove || rule !== undefined;
|
|
565
|
+
// Git ignores no file that it tracks, and no folder that holds one,
|
|
566
|
+
// though the folder's other files stay ignored.
|
|
567
|
+
const gitIgnored = gitExcluded && !isTracked(fromTop(name));
|
|
568
|
+
const skippedName = alwaysSkipped.has(entry.name);
|
|
569
|
+
if (skippedName || entry.isSymbolicLink()) {
|
|
570
|
+
if (skippedName)
|
|
571
|
+
leaveNamed(name, (input) => (show) => input.path === name
|
|
572
|
+
? `discovery always leaves out ${entry.name}`
|
|
573
|
+
: `in ${show(name)}, which discovery always leaves out`);
|
|
574
|
+
// Not entered, but a link can name what is in it or behind it, unless
|
|
575
|
+
// Git ignores it and so its files are no link targets.
|
|
576
|
+
if (!gitIgnored || resolve.gitIgnored === true)
|
|
577
|
+
unlisted.push(name);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (rule)
|
|
581
|
+
leaveNamed(name, (input) => (show) => input.path === name
|
|
582
|
+
? `matched by "${rule.pattern}" in ${showFile(show, rule.file)}`
|
|
583
|
+
: `in ${show(name)}, which "${rule.pattern}" in ${showFile(show, rule.file)} matches`, untracked);
|
|
127
584
|
if (gitIgnored && resolve.gitIgnored !== true)
|
|
128
585
|
continue;
|
|
129
586
|
const full = path.join(root, name);
|
|
@@ -138,35 +595,125 @@ export async function discover(rootPath, supplied, excluded, resolve = {}) {
|
|
|
138
595
|
leaveOut(name, name, error);
|
|
139
596
|
continue;
|
|
140
597
|
}
|
|
141
|
-
|
|
142
|
-
|
|
598
|
+
// A nested repository is another project, not one that a rule leaves
|
|
599
|
+
// alone: text supplied for one of its documents is read as its own.
|
|
600
|
+
if (nested)
|
|
601
|
+
leaveNamed(name, (input) => (show) => input.path === name ? "a nested repository" : `in ${show(name)}, a nested repository`, (input) => !input.document);
|
|
602
|
+
if (nested && resolve.nestedRepositories !== true)
|
|
603
|
+
continue;
|
|
604
|
+
// Inside a nested repository, only its own rules and settings apply, as in Git.
|
|
605
|
+
let inner = { layers, ignoreCase };
|
|
606
|
+
if (nested && !gitIgnored)
|
|
607
|
+
try {
|
|
608
|
+
inner = await repositoryLayers(full, fromTop(name));
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
leaveOut(name, failed(error, path.join(full, ".git")), error);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
await walk(name, inner.layers, inner.ignoreCase, targetOnly || gitIgnored || nested, nested && !gitIgnored ? false : gitExcluded);
|
|
143
615
|
}
|
|
144
616
|
else if (entry.isFile()) {
|
|
145
|
-
const
|
|
617
|
+
const request = named.get(name)?.find((input) => input.path === name && !input.directory);
|
|
618
|
+
const markdown = isMarkdownName(name);
|
|
146
619
|
let cached;
|
|
147
|
-
files[name] =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
620
|
+
files[name] =
|
|
621
|
+
markdown || request ? () => (cached ??= decodeText(readFileSync(full), name)) : null;
|
|
622
|
+
if (!(markdown || request) || targetOnly || gitIgnored)
|
|
623
|
+
continue;
|
|
624
|
+
const pattern = ignorePatterns.find((item) => item.matcher.match(name))?.pattern;
|
|
625
|
+
if (pattern !== undefined) {
|
|
626
|
+
if (request)
|
|
627
|
+
request.reason ??= () => `matched by the configuration's ignore pattern "${pattern}"`;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
let chosen = request !== undefined && !request.document;
|
|
631
|
+
if (markdown) {
|
|
632
|
+
chosen ||= requests.some((folder) => !folder || name === folder || name.startsWith(`${folder}/`));
|
|
633
|
+
for (const item of patterns)
|
|
634
|
+
if (item.matchers.some((matcher) => matcher.match(name)))
|
|
635
|
+
chosen = item.matched = true;
|
|
636
|
+
}
|
|
637
|
+
if (chosen)
|
|
153
638
|
selected.push(name);
|
|
154
639
|
}
|
|
155
640
|
}
|
|
641
|
+
// A supplied document that no file holds yet is left out by the rules of
|
|
642
|
+
// its folder as a file there would be.
|
|
643
|
+
for (const request of documentInputs) {
|
|
644
|
+
if (request.reason || request.path in files)
|
|
645
|
+
continue;
|
|
646
|
+
if (path.posix.dirname(request.path) !== (relative || "."))
|
|
647
|
+
continue;
|
|
648
|
+
const entry = path.posix.basename(request.path);
|
|
649
|
+
if (alwaysSkipped.has(entry))
|
|
650
|
+
request.reason = () => `discovery always leaves out ${entry}`;
|
|
651
|
+
const rule = gitIgnoredAbove ? undefined : gitIgnoreRule(scopes, entry, false);
|
|
652
|
+
if (rule && untracked(request))
|
|
653
|
+
request.reason ??= (show) => `matched by "${rule.pattern}" in ${showFile(show, rule.file)}`;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
await walk("", rootLayers, rootIgnoreCase, false, ignoredRoot !== undefined);
|
|
657
|
+
// The names that discovery always leaves out, and the configuration's
|
|
658
|
+
// patterns, apply to a supplied document's path also where no folder of it
|
|
659
|
+
// exists.
|
|
660
|
+
for (const request of documentInputs) {
|
|
661
|
+
if (request.reason)
|
|
662
|
+
continue;
|
|
663
|
+
const parts = request.path.split("/");
|
|
664
|
+
const at = parts.findIndex((part) => alwaysSkipped.has(part));
|
|
665
|
+
if (at >= 0) {
|
|
666
|
+
const folder = parts.slice(0, at + 1).join("/");
|
|
667
|
+
request.reason =
|
|
668
|
+
folder === request.path
|
|
669
|
+
? () => `discovery always leaves out ${parts[at]}`
|
|
670
|
+
: (show) => `in ${show(folder)}, which discovery always leaves out`;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const pattern = ignorePatterns.find((item) => item.matcher.match(request.path))?.pattern;
|
|
674
|
+
if (pattern !== undefined)
|
|
675
|
+
request.reason = () => `matched by the configuration's ignore pattern "${pattern}"`;
|
|
676
|
+
}
|
|
677
|
+
// Nothing that Git does not track is processed in a root that Git ignores.
|
|
678
|
+
let rootIgnored;
|
|
679
|
+
if (ignoredRoot) {
|
|
680
|
+
const { file, pattern } = ignoredRoot;
|
|
681
|
+
// The root itself, or a folder above it.
|
|
682
|
+
const folder = path.posix.relative(lead, ignoredRoot.folder);
|
|
683
|
+
const why = (show) => folder
|
|
684
|
+
? `in ${show(folder)}, which "${pattern}" in ${showFile(show, file)} matches`
|
|
685
|
+
: `matched by "${pattern}" in ${showFile(show, file)}`;
|
|
686
|
+
for (const input of namedInputs.values())
|
|
687
|
+
if (untracked(input))
|
|
688
|
+
input.reason ??= folder ? why : (show) => `in the workspace root, which is ${why(show)}`;
|
|
689
|
+
if (!namedInputs.size && !selectNone && !isTracked(lead))
|
|
690
|
+
rootIgnored = why;
|
|
156
691
|
}
|
|
157
|
-
await walk("", []);
|
|
158
692
|
// An input in a directory that was left out is an error: the user asked for it.
|
|
159
|
-
for (const
|
|
693
|
+
for (const { input, path: request } of located) {
|
|
160
694
|
const item = refused.find(({ directory }) => request === directory || request.startsWith(`${directory}/`));
|
|
161
695
|
if (item)
|
|
162
|
-
throw new Error(`Input cannot be read: ${
|
|
696
|
+
throw new Error(`Input cannot be read: ${input} (${reason(item, request)})`);
|
|
697
|
+
}
|
|
698
|
+
// A pattern stands for the documents it matches, so one that matches none is
|
|
699
|
+
// an error, as a path that does not exist is.
|
|
700
|
+
for (const item of patterns)
|
|
701
|
+
if (!item.matched)
|
|
702
|
+
throw new Error(`Input pattern matches no document: ${item.input}${item.input.includes("\\") ? " (patterns separate folders with /)" : ""}`);
|
|
703
|
+
const skipped = refused.map((item) => skip({ path: item.directory, type: "directory", code: item.code }, (show) => `Skipped unreadable directory: ${show(item.directory)} (${refusalText(item.code)}${item.reading === item.directory ? "" : ` for ${showFile(show, item.reading)}`}). Its files are not processed, and links into it cannot be checked.`));
|
|
704
|
+
if (rootIgnored) {
|
|
705
|
+
const why = rootIgnored;
|
|
706
|
+
skipped.push(skip({ path: ".", type: "directory", code: "IGNORED" }, (show) => `Skipped ignored workspace root: ${show(".") || "."} (${why(show)}). Its files are not processed.`));
|
|
707
|
+
}
|
|
708
|
+
// A named input that ignore rules leave out is not an error, but the user asked for it.
|
|
709
|
+
for (const input of namedInputs.values()) {
|
|
710
|
+
const why = input.reason;
|
|
711
|
+
if (!why)
|
|
712
|
+
continue;
|
|
713
|
+
const item = skip({ path: input.path, type: input.directory ? "directory" : "file", code: "IGNORED" }, (show) => `Skipped ignored input: ${show(input.path)} (${why(show)}). ${input.directory ? "Its files are" : "It is"} not processed.`);
|
|
714
|
+
ignoredInputs.add(item);
|
|
715
|
+
skipped.push(item);
|
|
163
716
|
}
|
|
164
|
-
const skipped = refused.map((item) => ({
|
|
165
|
-
path: item.directory,
|
|
166
|
-
type: "directory",
|
|
167
|
-
code: item.code,
|
|
168
|
-
message: `Skipped unreadable directory: ${item.directory} (${reason(item, item.directory)}). Its files are not processed, and links into it cannot be checked.`,
|
|
169
|
-
}));
|
|
170
717
|
// A root that is no vault itself can be a folder of one.
|
|
171
718
|
if (!vaultFolders.has("")) {
|
|
172
719
|
const outer = await enclosingVault(root);
|
|
@@ -185,37 +732,146 @@ export async function discover(rootPath, supplied, excluded, resolve = {}) {
|
|
|
185
732
|
root,
|
|
186
733
|
files,
|
|
187
734
|
directories,
|
|
735
|
+
unlisted,
|
|
188
736
|
skipped: skipped.sort(byPath),
|
|
189
737
|
selected: selected.sort(),
|
|
190
738
|
vaults: vaults.sort(byPath),
|
|
191
739
|
};
|
|
192
740
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
741
|
+
/**
|
|
742
|
+
* A file that `writeFormatted` would not write, or did not write completely,
|
|
743
|
+
* with the reason in words that do not name it.
|
|
744
|
+
*/
|
|
745
|
+
export class ReplacementRefused extends Error {
|
|
746
|
+
reason;
|
|
747
|
+
constructor(reason, message, options) {
|
|
748
|
+
super(message, options);
|
|
749
|
+
this.reason = reason;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Why `file` cannot be written, or undefined when nothing stands in the way
|
|
754
|
+
* that can be seen before writing: the file may not be written, or its folder
|
|
755
|
+
* does not let the trial file be created beside it. A read-only file is refused
|
|
756
|
+
* on every platform: the flag marks a file that is not to be edited.
|
|
757
|
+
*/
|
|
758
|
+
export async function replacementProblem(file) {
|
|
759
|
+
for (const [target, subject] of [
|
|
760
|
+
[file, "The file"],
|
|
761
|
+
[path.dirname(file), "The file's folder"],
|
|
762
|
+
]) {
|
|
763
|
+
try {
|
|
764
|
+
await access(target, constants.W_OK);
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
const text = systemErrorText(error);
|
|
768
|
+
if (!text)
|
|
769
|
+
throw error;
|
|
770
|
+
return `${subject} may not be written (${text})`;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return undefined;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* What went wrong when `writeFormatted` failed for a file, in words that do
|
|
777
|
+
* not name it, or undefined for an error that is a fault of the tool.
|
|
778
|
+
*/
|
|
779
|
+
export function replacementFailure(error) {
|
|
780
|
+
if (error instanceof ReplacementRefused)
|
|
781
|
+
return error.reason;
|
|
782
|
+
const text = systemErrorText(error);
|
|
783
|
+
return text ? `The file could not be written (${text}) and is unchanged` : undefined;
|
|
784
|
+
}
|
|
785
|
+
/** The refusal for a file whose bytes are no longer those that were read. */
|
|
786
|
+
function changedOnDisk(file, when) {
|
|
787
|
+
return new ReplacementRefused("The file changed on disk during formatting and was not written", `File changed ${when}: ${file}`);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Show that `size` more bytes fit on the disk of `folder`, by writing a file
|
|
791
|
+
* of that size there and removing it. It holds random bytes, not the
|
|
792
|
+
* document's text: a file left behind by a crash then reveals nothing, and a
|
|
793
|
+
* disk that compresses what it stores cannot make the trial smaller than the
|
|
794
|
+
* write. Common file systems set aside the room when the bytes are written, so
|
|
795
|
+
* the trial is not flushed to the disk; a file system that runs out of room only
|
|
796
|
+
* then makes the write itself fail, and the file's content is written back.
|
|
797
|
+
*/
|
|
798
|
+
async function trialWrite(folder, size) {
|
|
799
|
+
const trial = path.join(folder, `.mdtools-${randomUUID()}.tmp`);
|
|
800
|
+
const handle = await open(trial, "wx", 0o600);
|
|
201
801
|
try {
|
|
202
|
-
await handle.writeFile(
|
|
203
|
-
await handle.chmod(info.mode);
|
|
204
|
-
await handle.sync();
|
|
205
|
-
await handle.close();
|
|
206
|
-
const current = await lstat(file);
|
|
207
|
-
if (current.isSymbolicLink() ||
|
|
208
|
-
current.ino !== info.ino ||
|
|
209
|
-
(await readFile(file, "utf8")) !== before)
|
|
210
|
-
throw new Error(`File changed before replacement: ${file}`);
|
|
211
|
-
await rename(temporary, file);
|
|
802
|
+
await handle.writeFile(randomBytes(size));
|
|
212
803
|
}
|
|
213
804
|
finally {
|
|
214
805
|
await handle.close();
|
|
215
|
-
await unlink(
|
|
806
|
+
await unlink(trial).catch((error) => {
|
|
216
807
|
if (error.code !== "ENOENT")
|
|
217
808
|
throw error;
|
|
218
809
|
});
|
|
219
810
|
}
|
|
220
811
|
}
|
|
812
|
+
/** Make the open file hold exactly `content`. */
|
|
813
|
+
async function overwrite(handle, content) {
|
|
814
|
+
for (let at = 0; at < content.length;)
|
|
815
|
+
at += (await handle.write(content, at, content.length - at, at)).bytesWritten;
|
|
816
|
+
await handle.truncate(content.length);
|
|
817
|
+
await handle.sync();
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Write the formatted text `after` into `file`, whose text was read as
|
|
821
|
+
* `before`, if the file still holds exactly the bytes that were read. The file
|
|
822
|
+
* itself is written, not replaced by a new one, so that it keeps its
|
|
823
|
+
* permissions, attributes, owner, creation time, and hard links, which Node.js
|
|
824
|
+
* cannot all carry over to a new file. A trial write beside the file first shows that
|
|
825
|
+
* the new content fits on the disk. The file is then checked through the open
|
|
826
|
+
* file, just before writing. If writing fails part way, the bytes that were read
|
|
827
|
+
* are written back, and the reason says whether that worked. A file that already
|
|
828
|
+
* holds `after`, as the second name of a hard link written earlier in the batch
|
|
829
|
+
* does, is left as it is.
|
|
830
|
+
*/
|
|
831
|
+
export async function writeFormatted(file, before, after) {
|
|
832
|
+
const info = await lstat(file);
|
|
833
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
834
|
+
throw new ReplacementRefused("The file is not a regular file and was not written", `Refusing non-regular file: ${file}`);
|
|
835
|
+
// The text was decoded from valid UTF-8, so encoding it again gives the bytes
|
|
836
|
+
// that were read; comparing the text instead would miss a change to bytes that
|
|
837
|
+
// decode to the same text.
|
|
838
|
+
const expected = Buffer.from(before, "utf8");
|
|
839
|
+
const content = Buffer.from(after, "utf8");
|
|
840
|
+
const found = await readFile(file);
|
|
841
|
+
if (found.equals(content))
|
|
842
|
+
return;
|
|
843
|
+
if (!found.equals(expected))
|
|
844
|
+
throw changedOnDisk(file, "during formatting");
|
|
845
|
+
await trialWrite(path.dirname(file), content.length);
|
|
846
|
+
const handle = await open(file, "r+");
|
|
847
|
+
try {
|
|
848
|
+
// The path may lead to another file by now, such as through a link put in its place.
|
|
849
|
+
const current = await handle.stat();
|
|
850
|
+
if (current.ino !== info.ino || !(await handle.readFile()).equals(expected))
|
|
851
|
+
throw changedOnDisk(file, "before writing");
|
|
852
|
+
try {
|
|
853
|
+
await overwrite(handle, content);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
const text = systemErrorText(error);
|
|
857
|
+
if (!text)
|
|
858
|
+
throw error;
|
|
859
|
+
let undone;
|
|
860
|
+
try {
|
|
861
|
+
await overwrite(handle, expected);
|
|
862
|
+
}
|
|
863
|
+
catch (again) {
|
|
864
|
+
undone = systemErrorText(again);
|
|
865
|
+
if (!undone)
|
|
866
|
+
throw again;
|
|
867
|
+
}
|
|
868
|
+
throw new ReplacementRefused(undone === undefined
|
|
869
|
+
? `The file could not be written (${text}), and its content from before formatting was written back`
|
|
870
|
+
: `The file could not be written (${text}), and writing its content from before formatting back failed too (${undone}); it may be incomplete`, `File written in part: ${file}`, { cause: error });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
finally {
|
|
874
|
+
await handle.close();
|
|
875
|
+
}
|
|
876
|
+
}
|
|
221
877
|
//# sourceMappingURL=files.js.map
|