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.
Files changed (130) hide show
  1. package/CHANGELOG.md +577 -0
  2. package/CODE_OF_CONDUCT.md +30 -0
  3. package/CONTRIBUTING.md +189 -16
  4. package/README.md +100 -154
  5. package/SECURITY.md +15 -13
  6. package/config.schema.json +91 -0
  7. package/dist/cli/diff.d.ts +10 -0
  8. package/dist/cli/diff.js +247 -0
  9. package/dist/cli/diff.js.map +1 -0
  10. package/dist/cli/main.js +270 -150
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/config/load.js +67 -6
  13. package/dist/config/load.js.map +1 -1
  14. package/dist/config/problems.d.ts +7 -0
  15. package/dist/config/problems.js +208 -0
  16. package/dist/config/problems.js.map +1 -0
  17. package/dist/config/resolve.d.ts +42 -109
  18. package/dist/config/resolve.js +193 -52
  19. package/dist/config/resolve.js.map +1 -1
  20. package/dist/core/directives.d.ts +7 -0
  21. package/dist/core/directives.js +25 -13
  22. package/dist/core/directives.js.map +1 -1
  23. package/dist/core/engine.d.ts +42 -4
  24. package/dist/core/engine.js +518 -79
  25. package/dist/core/engine.js.map +1 -1
  26. package/dist/core/frozen.d.ts +6 -0
  27. package/dist/core/frozen.js +15 -0
  28. package/dist/core/frozen.js.map +1 -0
  29. package/dist/core/scope.d.ts +30 -0
  30. package/dist/core/scope.js +122 -0
  31. package/dist/core/scope.js.map +1 -0
  32. package/dist/core/types.d.ts +202 -23
  33. package/dist/core/types.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/node.d.ts +3 -0
  37. package/dist/node.js +4 -0
  38. package/dist/node.js.map +1 -0
  39. package/dist/rules/blocks.d.ts +34 -0
  40. package/dist/rules/blocks.js +550 -0
  41. package/dist/rules/blocks.js.map +1 -0
  42. package/dist/rules/content.d.ts +2 -0
  43. package/dist/rules/content.js +85 -0
  44. package/dist/rules/content.js.map +1 -0
  45. package/dist/rules/dialects.js +10 -6
  46. package/dist/rules/dialects.js.map +1 -1
  47. package/dist/rules/links.js +302 -6
  48. package/dist/rules/links.js.map +1 -1
  49. package/dist/rules/lists.d.ts +2 -0
  50. package/dist/rules/lists.js +924 -0
  51. package/dist/rules/lists.js.map +1 -0
  52. package/dist/rules/structure.js +127 -1
  53. package/dist/rules/structure.js.map +1 -1
  54. package/dist/rules/style.d.ts +8 -2
  55. package/dist/rules/style.js +1137 -165
  56. package/dist/rules/style.js.map +1 -1
  57. package/dist/syntax/block-identifier.d.ts +18 -0
  58. package/dist/syntax/block-identifier.js +24 -0
  59. package/dist/syntax/block-identifier.js.map +1 -0
  60. package/dist/syntax/obsidian.d.ts +29 -11
  61. package/dist/syntax/obsidian.js +9 -10
  62. package/dist/syntax/obsidian.js.map +1 -1
  63. package/dist/syntax/parse.d.ts +87 -3
  64. package/dist/syntax/parse.js +247 -19
  65. package/dist/syntax/parse.js.map +1 -1
  66. package/dist/workspace/access.d.ts +12 -3
  67. package/dist/workspace/access.js +33 -5
  68. package/dist/workspace/access.js.map +1 -1
  69. package/dist/workspace/discovery.d.ts +85 -0
  70. package/dist/workspace/discovery.js +155 -0
  71. package/dist/workspace/discovery.js.map +1 -0
  72. package/dist/workspace/encoding.d.ts +24 -0
  73. package/dist/workspace/encoding.js +113 -0
  74. package/dist/workspace/encoding.js.map +1 -0
  75. package/dist/workspace/files.d.ts +92 -6
  76. package/dist/workspace/files.js +715 -59
  77. package/dist/workspace/files.js.map +1 -1
  78. package/dist/workspace/git.d.ts +54 -0
  79. package/dist/workspace/git.js +198 -0
  80. package/dist/workspace/git.js.map +1 -0
  81. package/dist/workspace/index.d.ts +58 -3
  82. package/dist/workspace/index.js +136 -37
  83. package/dist/workspace/index.js.map +1 -1
  84. package/dist/workspace/selection.d.ts +2 -2
  85. package/dist/workspace/selection.js +3 -3
  86. package/dist/workspace/selection.js.map +1 -1
  87. package/dist/workspace/slug.js +8 -1
  88. package/dist/workspace/slug.js.map +1 -1
  89. package/dist/workspace/vault.js +3 -1
  90. package/dist/workspace/vault.js.map +1 -1
  91. package/docs/api.md +239 -0
  92. package/docs/benchmarks.md +140 -200
  93. package/docs/changelog.md +1 -0
  94. package/docs/cli.md +281 -89
  95. package/docs/configuration.md +193 -480
  96. package/docs/design.md +163 -284
  97. package/docs/dialects.md +169 -0
  98. package/docs/examples.md +151 -109
  99. package/docs/index.md +21 -37
  100. package/docs/integrations.md +144 -0
  101. package/docs/obsidian.md +91 -115
  102. package/docs/plugins.md +147 -212
  103. package/docs/quick-start.md +68 -74
  104. package/docs/rules.md +1237 -0
  105. package/docs/versions.md +56 -0
  106. package/examples/README.md +17 -14
  107. package/examples/automation/summarize-report.mjs +5 -2
  108. package/examples/library/lint-folder.mjs +5 -4
  109. package/examples/library/lint-workspace.mjs +32 -0
  110. package/examples/plugin/notes/release-checklist.md +2 -2
  111. package/examples/plugin/rules.mjs +17 -23
  112. package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
  113. package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
  114. package/examples/workspaces/repository-docs/after/README.md +2 -2
  115. package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
  116. package/examples/workspaces/repository-docs/before/README.md +2 -2
  117. package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
  118. package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
  119. package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
  120. package/package.json +38 -12
  121. package/docs/contributing.md +0 -218
  122. package/docs/releases/0.1.0-beta.1.md +0 -39
  123. package/docs/releases/0.1.0-beta.2.md +0 -83
  124. package/docs/releases/0.1.0-beta.3.md +0 -61
  125. package/docs/releases/0.1.0-beta.4.md +0 -50
  126. package/docs/releases/0.1.0-rc.1.md +0 -99
  127. package/docs/releases/0.2.0-rc.1.md +0 -141
  128. package/docs/releases/0.2.0-rc.2.md +0 -225
  129. package/docs/releases.md +0 -144
  130. package/docs/testing.md +0 -93
@@ -1,11 +1,242 @@
1
- import { lstat, readdir, readFile, realpath, open, rename, unlink } from "node:fs/promises";
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
- export async function discover(rootPath, supplied, excluded, resolve = {}) {
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
- if ((await lstat(absolute)).isSymbolicLink())
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
- requests.push(relative);
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
- async function walk(relative, inherited, targetOnly = false) {
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
- layers.push({ base: relative, matcher: ignore().add(await readFile(rules, "utf8")) });
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 gitIgnored = layers.some((layer) => layer.matcher.ignores(name.slice(layer.base ? layer.base.length + 1 : 0) + (entry.isDirectory() ? "/" : "")));
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
- if (!nested || resolve.nestedRepositories === true)
142
- await walk(name, layers, targetOnly || gitIgnored || nested);
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 markdown = /\.md$/i.test(name);
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] = markdown ? () => (cached ??= readFileSync(full, "utf8")) : null;
148
- if (markdown &&
149
- !targetOnly &&
150
- !gitIgnored &&
151
- requests.some((request) => !request || name === request || name.startsWith(`${request}/`)) &&
152
- !excluded.some((pattern) => minimatch(name, pattern, { dot: true })))
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 [index, request] of requests.entries()) {
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: ${supplied[index]} (${reason(item, request)})`);
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
- export async function writeAtomic(file, before, after) {
194
- const info = await lstat(file);
195
- if (!info.isFile() || info.isSymbolicLink())
196
- throw new Error(`Refusing non-regular file: ${file}`);
197
- if ((await readFile(file, "utf8")) !== before)
198
- throw new Error(`File changed during formatting: ${file}`);
199
- const temporary = path.join(path.dirname(file), `.mdtools-${randomUUID()}.tmp`);
200
- const handle = await open(temporary, "wx", info.mode);
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(after, "utf8");
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(temporary).catch((error) => {
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