mdrefine 0.2.0-rc.2 → 0.2.0-rc.3

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 (127) hide show
  1. package/CHANGELOG.md +509 -0
  2. package/CODE_OF_CONDUCT.md +30 -0
  3. package/CONTRIBUTING.md +188 -16
  4. package/README.md +100 -154
  5. package/SECURITY.md +15 -13
  6. package/config.schema.json +91 -0
  7. package/dist/cli/main.js +261 -148
  8. package/dist/cli/main.js.map +1 -1
  9. package/dist/config/load.js +67 -6
  10. package/dist/config/load.js.map +1 -1
  11. package/dist/config/problems.d.ts +7 -0
  12. package/dist/config/problems.js +208 -0
  13. package/dist/config/problems.js.map +1 -0
  14. package/dist/config/resolve.d.ts +42 -109
  15. package/dist/config/resolve.js +193 -52
  16. package/dist/config/resolve.js.map +1 -1
  17. package/dist/core/directives.d.ts +7 -0
  18. package/dist/core/directives.js +25 -13
  19. package/dist/core/directives.js.map +1 -1
  20. package/dist/core/engine.d.ts +42 -4
  21. package/dist/core/engine.js +518 -79
  22. package/dist/core/engine.js.map +1 -1
  23. package/dist/core/frozen.d.ts +6 -0
  24. package/dist/core/frozen.js +15 -0
  25. package/dist/core/frozen.js.map +1 -0
  26. package/dist/core/scope.d.ts +30 -0
  27. package/dist/core/scope.js +122 -0
  28. package/dist/core/scope.js.map +1 -0
  29. package/dist/core/types.d.ts +202 -23
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/node.d.ts +3 -0
  34. package/dist/node.js +4 -0
  35. package/dist/node.js.map +1 -0
  36. package/dist/rules/blocks.d.ts +34 -0
  37. package/dist/rules/blocks.js +538 -0
  38. package/dist/rules/blocks.js.map +1 -0
  39. package/dist/rules/content.d.ts +2 -0
  40. package/dist/rules/content.js +85 -0
  41. package/dist/rules/content.js.map +1 -0
  42. package/dist/rules/dialects.js +10 -6
  43. package/dist/rules/dialects.js.map +1 -1
  44. package/dist/rules/links.js +302 -6
  45. package/dist/rules/links.js.map +1 -1
  46. package/dist/rules/lists.d.ts +2 -0
  47. package/dist/rules/lists.js +924 -0
  48. package/dist/rules/lists.js.map +1 -0
  49. package/dist/rules/structure.js +127 -1
  50. package/dist/rules/structure.js.map +1 -1
  51. package/dist/rules/style.d.ts +8 -2
  52. package/dist/rules/style.js +1118 -165
  53. package/dist/rules/style.js.map +1 -1
  54. package/dist/syntax/block-identifier.d.ts +18 -0
  55. package/dist/syntax/block-identifier.js +24 -0
  56. package/dist/syntax/block-identifier.js.map +1 -0
  57. package/dist/syntax/obsidian.d.ts +29 -11
  58. package/dist/syntax/obsidian.js +9 -10
  59. package/dist/syntax/obsidian.js.map +1 -1
  60. package/dist/syntax/parse.d.ts +74 -3
  61. package/dist/syntax/parse.js +227 -19
  62. package/dist/syntax/parse.js.map +1 -1
  63. package/dist/workspace/access.d.ts +12 -3
  64. package/dist/workspace/access.js +33 -5
  65. package/dist/workspace/access.js.map +1 -1
  66. package/dist/workspace/discovery.d.ts +80 -0
  67. package/dist/workspace/discovery.js +143 -0
  68. package/dist/workspace/discovery.js.map +1 -0
  69. package/dist/workspace/encoding.d.ts +24 -0
  70. package/dist/workspace/encoding.js +113 -0
  71. package/dist/workspace/encoding.js.map +1 -0
  72. package/dist/workspace/files.d.ts +76 -6
  73. package/dist/workspace/files.js +639 -59
  74. package/dist/workspace/files.js.map +1 -1
  75. package/dist/workspace/git.d.ts +54 -0
  76. package/dist/workspace/git.js +198 -0
  77. package/dist/workspace/git.js.map +1 -0
  78. package/dist/workspace/index.d.ts +58 -3
  79. package/dist/workspace/index.js +136 -37
  80. package/dist/workspace/index.js.map +1 -1
  81. package/dist/workspace/selection.d.ts +2 -2
  82. package/dist/workspace/selection.js +3 -3
  83. package/dist/workspace/selection.js.map +1 -1
  84. package/dist/workspace/slug.js +8 -1
  85. package/dist/workspace/slug.js.map +1 -1
  86. package/dist/workspace/vault.js +3 -1
  87. package/dist/workspace/vault.js.map +1 -1
  88. package/docs/api.md +237 -0
  89. package/docs/benchmarks.md +139 -199
  90. package/docs/changelog.md +1 -0
  91. package/docs/cli.md +271 -89
  92. package/docs/configuration.md +193 -480
  93. package/docs/design.md +158 -284
  94. package/docs/dialects.md +169 -0
  95. package/docs/examples.md +151 -109
  96. package/docs/index.md +21 -37
  97. package/docs/integrations.md +138 -0
  98. package/docs/obsidian.md +91 -115
  99. package/docs/plugins.md +147 -212
  100. package/docs/quick-start.md +68 -74
  101. package/docs/rules.md +1230 -0
  102. package/docs/versions.md +56 -0
  103. package/examples/README.md +17 -14
  104. package/examples/automation/summarize-report.mjs +5 -2
  105. package/examples/library/lint-folder.mjs +5 -4
  106. package/examples/library/lint-workspace.mjs +32 -0
  107. package/examples/plugin/notes/release-checklist.md +2 -2
  108. package/examples/plugin/rules.mjs +17 -23
  109. package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
  110. package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
  111. package/examples/workspaces/repository-docs/after/README.md +2 -2
  112. package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
  113. package/examples/workspaces/repository-docs/before/README.md +2 -2
  114. package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
  115. package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
  116. package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
  117. package/package.json +37 -11
  118. package/docs/contributing.md +0 -218
  119. package/docs/releases/0.1.0-beta.1.md +0 -39
  120. package/docs/releases/0.1.0-beta.2.md +0 -83
  121. package/docs/releases/0.1.0-beta.3.md +0 -61
  122. package/docs/releases/0.1.0-beta.4.md +0 -50
  123. package/docs/releases/0.1.0-rc.1.md +0 -99
  124. package/docs/releases/0.2.0-rc.1.md +0 -141
  125. package/docs/releases/0.2.0-rc.2.md +0 -225
  126. package/docs/releases.md +0 -144
  127. package/docs/testing.md +0 -93
package/dist/cli/main.js CHANGED
@@ -1,151 +1,181 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
3
3
  import { createTwoFilesPatch } from "diff";
4
- import { access, constants, readFile, realpath, stat } from "node:fs/promises";
4
+ import { readFile, realpath } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { format, lint, ruleRegistry } from "../core/engine.js";
8
8
  import { loadConfig } from "../config/load.js";
9
- import { resolveConfig } from "../config/resolve.js";
10
- import { discover, vaultFor, writeAtomic } from "../workspace/files.js";
11
- import { enclosingVault, vaultOf } from "../workspace/vault.js";
12
- import { excludeSelection } from "../workspace/selection.js";
13
- import { createWorkspace } from "../workspace/index.js";
14
- import { pathError, refusal, refusalText } from "../workspace/access.js";
9
+ import { dialectAliases, dialects, resolveConfig } from "../config/resolve.js";
10
+ import { replacementFailure, replacementProblem, skippedMessage, writeFormatted, } from "../workspace/files.js";
11
+ import { assumedDialect, checkRoot, discoverFrom } from "../workspace/discovery.js";
12
+ import { enclosingVault } from "../workspace/vault.js";
13
+ import { refusal, refusalText } from "../workspace/access.js";
14
+ import { decodeText, NotUtf8Error } from "../workspace/encoding.js";
15
15
  const manifest = JSON.parse(await readFile(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8"));
16
+ // Subcommands inherit these settings, so they come before the commands.
16
17
  const program = new Command()
17
18
  .name("mdtools")
18
19
  .description("Lint and format Markdown with explicit dialects and configurable rules.")
19
- .version(manifest.version);
20
- function common(command) {
20
+ .version(manifest.version, "-V, --version", "Print the version")
21
+ .helpOption("-h, --help", "Print help for the command")
22
+ .helpCommand("help [command]", "Print help for a command")
23
+ // Commander starts its messages with "error: ", the tool with its name.
24
+ .configureOutput({
25
+ outputError: (text, write) => write(`mdtools: ${text.replace(/^error: /, "")}`),
26
+ })
27
+ // Throw instead of exiting, so that a usage error can end with code 2.
28
+ .exitOverride();
29
+ const configOption = [
30
+ "--config <file>",
31
+ "Use this JSON, JSONC, or JavaScript configuration file instead of searching for one",
32
+ ];
33
+ /** The dialects, each with its other names, as in "forgejo (alias: codeberg)". */
34
+ const dialectList = dialects
35
+ .map((dialect) => {
36
+ const aliases = Object.entries(dialectAliases).flatMap(([name, of]) => of === dialect ? [name] : []);
37
+ return aliases.length ? `${dialect} (alias: ${aliases.join(", ")})` : dialect;
38
+ })
39
+ .join(", ");
40
+ /** The options that choose the configuration, the workspace, and the dialect. */
41
+ function workspaceOptions(command) {
21
42
  return command
22
- .option("--config <file>", "Explicit JSON, JSONC, or .mjs configuration")
23
- .option("--root <directory>", "Workspace root (default: configuration directory or cwd)")
24
- .option("--dialect <dialect>", "commonmark, github, forgejo (alias: codeberg), gitea, or obsidian (when none is named: github, or obsidian inside a vault)")
25
- .option("--exclude <path>", "Exclude an exact file or directory (repeatable)", (value, previous) => [...previous, value], [])
43
+ .option(...configOption)
44
+ .option("--root <directory>", "Workspace root (default: the configuration file's directory; without one, the Git repository or vault where the search stopped, else the current directory)")
45
+ .option("--dialect <dialect>", `${dialectList} (when none is named: github, or obsidian inside a vault)`, (value) => {
46
+ if (!Object.hasOwn(dialectAliases, value) && !dialects.includes(value))
47
+ throw new InvalidArgumentError(`The dialects are ${dialectList}.`);
48
+ return value;
49
+ });
50
+ }
51
+ function common(command) {
52
+ return (workspaceOptions(command)
53
+ .option("--exclude <path>", "Exclude an exact file or directory (repeatable)", (value, previous = []) => [...previous, value])
26
54
  .option("--json", "Machine-readable output")
55
+ .option("--quiet", "Report errors only: leave warnings out of the report and its counts")
27
56
  .option("--stdin-filepath <file>", "Filename context for stdin")
28
57
  .option("--max-warnings <count>", "Fail when warning count exceeds this nonnegative integer", (value) => {
29
58
  const number = Number(value);
30
59
  if (!Number.isInteger(number) || number < 0)
31
60
  throw new Error("--max-warnings must be a nonnegative integer.");
32
61
  return number;
33
- });
62
+ })
63
+ // Not an option: `run` answers it with the option that does what it asks for.
64
+ .addOption(new Option("--fix").hideHelp()));
34
65
  }
35
66
  /**
36
- * The dialect to assume for a document when nothing names one, or undefined for
37
- * the library's own default. An Obsidian vault shows a single line break as a
38
- * line break and holds wikilinks, both of which any other dialect lets reflow
39
- * change, so a document that a vault holds is read as a note.
67
+ * The configuration and workspace root that the options name. `--dialect` is
68
+ * applied to a copy, because an .mjs configuration may export a frozen object.
40
69
  */
41
- function assumedDialect(vault) {
42
- return vault ? "obsidian" : undefined;
70
+ async function workspace(flags) {
71
+ // Without this check the first thing to fail would be the search for a
72
+ // configuration file in the root, with a message about a file the user never named.
73
+ if (flags.root !== undefined)
74
+ await checkRoot(flags.root);
75
+ const loaded = await loadConfig(path.resolve(flags.root ?? "."), flags.config);
76
+ return {
77
+ ...loaded,
78
+ root: path.resolve(flags.root ?? loaded.root),
79
+ config: flags.dialect ? { ...loaded.config, dialect: flags.dialect } : loaded.config,
80
+ };
43
81
  }
44
82
  /**
45
- * Stops when the folder given as `--root` cannot serve as a workspace root.
46
- * Without this check the first thing to fail would be the search for a
47
- * configuration file in it, with a message about a file the user never named.
83
+ * The real path of `file`, which need not exist: the real path of the nearest
84
+ * directory above it that does, with the rest of `file` appended.
48
85
  */
49
- async function checkRoot(given) {
50
- if (given === undefined)
51
- return;
52
- const root = path.resolve(given);
53
- try {
54
- if (!(await stat(root)).isDirectory())
55
- throw new Error(`Workspace root is not a directory: ${root}`);
56
- await access(root, constants.R_OK | constants.X_OK);
57
- }
58
- catch (error) {
59
- throw pathError("Workspace root", root, error);
86
+ async function located(file) {
87
+ const rest = [];
88
+ for (let current = path.resolve(file);;) {
89
+ try {
90
+ return path.join(await realpath(current), ...rest);
91
+ }
92
+ catch {
93
+ const parent = path.dirname(current);
94
+ if (parent === current)
95
+ return path.resolve(file);
96
+ rest.unshift(path.basename(current));
97
+ current = parent;
98
+ }
60
99
  }
61
100
  }
101
+ /**
102
+ * The path of `file`, which need not exist, from the workspace root, with
103
+ * forward slashes, or undefined when it lies outside. Both are located through
104
+ * their real paths, as discovery locates files, so that a root reached through
105
+ * a link still holds the files below it.
106
+ */
107
+ async function workspacePath(root, file) {
108
+ const actual = await realpath(root).catch(() => root);
109
+ const name = path
110
+ .relative(actual, await located(file))
111
+ .split(path.sep)
112
+ .join("/");
113
+ return name === ".." || name.startsWith("../") || path.isAbsolute(name) ? undefined : name;
114
+ }
115
+ /** The library's diagnostics for a document that it could not process as configured. */
116
+ const failures = new Set(["engine/unsafe-format", "engine/rule-error"]);
62
117
  async function run(mode, inputs, flags) {
118
+ // ESLint and markdownlint-cli2 fix with --fix, Prettier with --write.
119
+ if (flags.fix)
120
+ throw new Error(mode === "lint"
121
+ ? "unknown option '--fix': lint never changes files; 'mdtools format --write' applies the fixes."
122
+ : "unknown option '--fix': format writes its changes with --write.");
63
123
  if ([flags.check, flags.diff, flags.write].filter(Boolean).length > 1)
64
124
  throw new Error("Choose only one of --check, --diff, and --write.");
65
125
  if (inputs.includes("-") && flags.exclude?.length)
66
126
  throw new Error("--exclude cannot be combined with stdin.");
67
- await checkRoot(flags.root);
68
- const loaded = await loadConfig(path.resolve(flags.root ?? "."), flags.config);
69
- const root = path.resolve(flags.root ?? loaded.root);
70
- if (flags.dialect)
71
- loaded.config.dialect = flags.dialect;
72
- // Which files to leave out does not depend on a document's dialect.
73
- const resolved = resolveConfig(loaded.config, "document.md", loaded.plugins);
127
+ const { root, ...loaded } = await workspace(flags);
74
128
  const stdin = inputs.includes("-");
75
129
  if (stdin && (inputs.length !== 1 || flags.write))
76
130
  throw new Error("stdin must be the only input and cannot be combined with --write.");
77
- const set = await discover(root, stdin ? [] : inputs, resolved.ignore, resolved.resolve);
78
- set.selected = await excludeSelection(set.root, set.selected, flags.exclude ?? []);
79
- /** The vault of the document read from stdin, found by a search of its own. */
80
- let named;
131
+ /** The text read from stdin, by the name it is given in the workspace. */
132
+ let documents;
81
133
  if (stdin) {
82
- const name = path
83
- .relative(root, path.resolve(flags.stdinFilepath ?? path.join(root, "stdin.md")))
84
- .split(path.sep)
85
- .join("/");
86
- if (name.startsWith("../") || path.isAbsolute(name))
134
+ const name = await workspacePath(root, flags.stdinFilepath ?? path.join(root, "stdin.md"));
135
+ if (name === undefined)
87
136
  throw new Error("--stdin-filepath must be within the workspace.");
88
- let source = "";
89
- process.stdin.setEncoding("utf8");
137
+ const chunks = [];
90
138
  for await (const chunk of process.stdin)
91
- source += String(chunk);
92
- set.files[name] = source;
93
- set.selected = [name];
94
- // The name can lead into a folder that discovery did not visit.
95
- named = await vaultFor(set, name);
139
+ chunks.push(chunk);
140
+ try {
141
+ documents = { [name]: decodeText(Buffer.concat(chunks), "stdin") };
142
+ }
143
+ catch (error) {
144
+ // There is no file to report it for, and nothing else to process.
145
+ if (error instanceof NotUtf8Error)
146
+ throw new Error(`stdin is not UTF-8 text (${error.reason})`, { cause: error });
147
+ throw error;
148
+ }
96
149
  }
97
- // Links into a directory that could not be read are reported as unchecked, not as missing.
98
- const unreadable = set.skipped.flatMap((item) => (item.type === "directory" ? [item.path] : []));
150
+ // Paths on the command line are relative to the working directory, and no
151
+ // path selects the whole workspace; stdin is the only document then.
152
+ const discovery = await discoverFrom({
153
+ root,
154
+ ...(stdin ? { inputs: [] } : inputs.length ? { inputs } : {}),
155
+ exclude: flags.exclude ?? [],
156
+ config: loaded.config,
157
+ plugins: loaded.plugins,
158
+ ...(documents ? { documents } : {}),
159
+ }, process.cwd());
99
160
  const reports = [];
100
161
  const changes = [];
101
- // Selected files that the system refused to let the tool read.
162
+ // Selected files that the system refused to let the tool read, or that are not UTF-8 text.
102
163
  const unread = [];
103
- // The vault that the root is a folder of, if it is one: links may lead out of sight.
104
- const enclosing = set.vaults.find((item) => item.rootInVault);
105
- // One index per active dialect; build lazily for mixed documentation workspaces.
106
- const indexes = new Map();
107
- // The same index with the line-break setting of each vault that has documents in it.
108
- const views = new Map();
109
- for (const name of set.selected) {
110
- const vault = stdin ? named : vaultOf(set.vaults, name);
111
- const assumed = assumedDialect(vault);
112
- const config = resolveConfig(loaded.config, name, loaded.plugins, assumed);
113
- let workspace = indexes.get(config.dialect);
114
- if (!workspace)
115
- indexes.set(config.dialect, (workspace = createWorkspace(set.files, {
116
- dialect: config.dialect,
117
- directories: set.directories,
118
- unreadable,
119
- // The vault at or above the root holds the whole workspace and needs no entry.
120
- vaults: set.vaults.flatMap((item) => (item.path ? [item.path] : [])),
121
- ...(enclosing?.rootInVault ? { rootInVault: enclosing.rootInVault } : {}),
122
- })));
123
- // Whether a note may be reflowed is a setting of its own vault.
124
- if (vault?.strictLineBreaks !== undefined) {
125
- let ofDialect = views.get(config.dialect);
126
- if (!ofDialect)
127
- views.set(config.dialect, (ofDialect = new Map()));
128
- const index = workspace;
129
- workspace = ofDialect.get(vault);
130
- if (!workspace)
131
- ofDialect.set(vault, (workspace = { ...index, strictLineBreaks: vault.strictLineBreaks }));
132
- }
133
- const options = {
134
- path: name,
135
- config: loaded.config,
136
- plugins: loaded.plugins,
137
- workspace,
138
- ...(assumed ? { defaultDialect: assumed } : {}),
139
- };
140
- const value = set.files[name];
164
+ for (const name of discovery.selected) {
165
+ const options = discovery.options(name);
141
166
  let source;
142
167
  try {
143
- source = typeof value === "function" ? value() : value;
168
+ source = discovery.read(name);
144
169
  }
145
170
  catch (error) {
146
171
  // Report the file and carry on with the others; any other error is a fault.
147
172
  const code = refusal(error);
148
- if (!code)
173
+ const problem = code
174
+ ? `could not be read (${refusalText(code)})`
175
+ : error instanceof NotUtf8Error
176
+ ? `is not UTF-8 text (${error.reason})`
177
+ : undefined;
178
+ if (!problem)
149
179
  throw error;
150
180
  unread.push(name);
151
181
  reports.push({
@@ -154,7 +184,7 @@ async function run(mode, inputs, flags) {
154
184
  diagnostics: [
155
185
  {
156
186
  start: 0,
157
- message: `The file could not be read (${refusalText(code)}) and was not ${mode === "lint" ? "linted" : "formatted"}.`,
187
+ message: `The file ${problem} and was not ${mode === "lint" ? "linted" : "formatted"}.`,
158
188
  rule: "engine/unreadable-file",
159
189
  severity: "error",
160
190
  line: 1,
@@ -164,8 +194,6 @@ async function run(mode, inputs, flags) {
164
194
  });
165
195
  continue;
166
196
  }
167
- if (source === null)
168
- continue;
169
197
  if (mode === "lint")
170
198
  reports.push({ path: name, diagnostics: lint(source, options) });
171
199
  else {
@@ -180,89 +208,174 @@ async function run(mode, inputs, flags) {
180
208
  changes.push({ file: name, before: source, after: result.output });
181
209
  }
182
210
  }
183
- // A file that could not be formatted safely, or not be read at all, is a failure
184
- // to operate: nothing of the batch is written, and the run ends with status 2.
211
+ // A document that could not be read, had a rule refused or failing on it, or
212
+ // could not be written is a failure to operate, reported for its own file,
213
+ // and the run ends with status 2. Files do not depend on one another's
214
+ // content, so every other changed document is still written, and so is the
215
+ // result of one with a refused or failing rule, which the engine checked.
216
+ const failed = unread.length > 0 ||
217
+ reports.some((report) => report.diagnostics.some((item) => failures.has(item.rule)));
218
+ // Changed files that could not be written, each reported for its file.
219
+ const unwritten = [];
220
+ const refuse = (file, message) => {
221
+ unwritten.push(file);
222
+ reports
223
+ .find((report) => report.path === file)
224
+ .diagnostics.unshift({
225
+ start: 0,
226
+ message: `${message}.`,
227
+ rule: "engine/unwritable-file",
228
+ severity: "error",
229
+ line: 1,
230
+ column: 1,
231
+ });
232
+ };
233
+ // The files that were written, in the order of the report.
234
+ const written = [];
235
+ if (flags.write)
236
+ for (const change of changes) {
237
+ const file = path.join(discovery.root, change.file);
238
+ // A file that may not be written, such as a read-only one, is left alone.
239
+ const problem = await replacementProblem(file);
240
+ if (problem) {
241
+ refuse(change.file, `${problem}; the file is unchanged`);
242
+ continue;
243
+ }
244
+ try {
245
+ await writeFormatted(file, change.before, change.after);
246
+ written.push(change.file);
247
+ }
248
+ catch (error) {
249
+ const failure = replacementFailure(error);
250
+ if (!failure)
251
+ throw error;
252
+ refuse(change.file, failure);
253
+ }
254
+ }
255
+ // As in ESLint, --quiet leaves warnings out of the report and of what counts them.
256
+ if (flags.quiet)
257
+ for (const report of reports)
258
+ report.diagnostics = report.diagnostics.filter((item) => item.severity !== "warn");
185
259
  const diagnostics = reports.flatMap((report) => report.diagnostics);
186
260
  const count = (severity) => diagnostics.filter((item) => item.severity === severity).length;
187
- const unsafe = unread.length > 0 ||
188
- reports.some((report) => report.diagnostics.some((item) => item.rule === "engine/unsafe-format"));
189
- if (flags.write && !unsafe)
190
- for (const change of changes)
191
- await writeAtomic(path.join(set.root, change.file), change.before, change.after);
192
261
  if (flags.json)
193
262
  process.stdout.write(JSON.stringify({
194
263
  version: manifest.version,
195
264
  mode,
265
+ // The files' paths are relative to it, for programs to join.
266
+ root,
196
267
  files: reports,
197
- written: flags.write === true && !unsafe,
198
- ...(set.skipped.length ? { skipped: set.skipped } : {}),
268
+ written,
269
+ ...(discovery.skipped.length ? { skipped: discovery.skipped } : {}),
199
270
  }, null, 2) + "\n");
200
271
  else {
272
+ // Text names documents from the current directory, as Prettier does, so
273
+ // that a terminal or an editor opens them from where the command ran.
274
+ const here = await realpath(process.cwd()).catch(() => process.cwd());
275
+ const shown = (name) => path.relative(here, path.join(discovery.root, name)).split(path.sep).join("/");
201
276
  // Warnings about the run, not about a document: they do not change the exit status.
202
- for (const item of set.skipped)
203
- process.stderr.write(`mdtools: warning: ${item.message}\n`);
277
+ for (const item of discovery.skipped)
278
+ process.stderr.write(`mdtools: warning: ${skippedMessage(item, shown)}\n`);
204
279
  if (stdin && mode === "format" && !flags.check && !flags.diff)
205
280
  process.stdout.write(reports[0]?.output ?? "");
206
281
  else if (mode === "format" && !flags.check && !flags.write)
207
282
  for (const change of changes)
208
- process.stdout.write(createTwoFilesPatch(`a/${change.file}`, `b/${change.file}`, change.before, change.after));
283
+ process.stdout.write(createTwoFilesPatch(`a/${shown(change.file)}`, `b/${shown(change.file)}`, change.before, change.after));
209
284
  for (const report of reports)
210
285
  for (const item of report.diagnostics)
211
- process.stderr.write(`${report.path}:${item.line}:${item.column}: ${item.severity} ${item.rule}: ${item.message}\n`);
286
+ process.stderr.write(`${shown(report.path)}:${item.line}:${item.column}: ${item.severity} ${item.rule}: ${item.message}\n`);
287
+ // The files to fix when a check fails, as Prettier's --check lists them.
288
+ if (mode === "format" && flags.check && !stdin)
289
+ for (const change of changes)
290
+ process.stderr.write(`${shown(change.file)}\n`);
212
291
  if (!stdin) {
213
292
  // Lint changes nothing, so its summary counts what it reported instead.
214
293
  const outcome = mode === "lint"
215
294
  ? `${count("error")} error(s), ${count("warn")} warning(s)`
216
- : `${changes.length} ${flags.write && !unsafe ? "written" : "would change"}`;
217
- process.stderr.write(`${set.selected.length - unread.length} file(s) ${mode === "lint" ? "linted" : "processed"}; ${outcome}${unread.length ? `; ${unread.length} could not be read` : ""}.\n`);
295
+ : flags.write
296
+ ? `${written.length} written`
297
+ : `${changes.length} would change`;
298
+ process.stderr.write(`${discovery.selected.length - unread.length} file(s) ${mode === "lint" ? "linted" : "processed"}; ${outcome}${unread.length ? `; ${unread.length} could not be read` : ""}${unwritten.length ? `; ${unwritten.length} could not be written` : ""}.\n`);
218
299
  }
219
300
  }
220
301
  const errors = count("error") > 0;
221
302
  const tooManyWarnings = flags.maxWarnings !== undefined && count("warn") > flags.maxWarnings;
222
- process.exitCode = unsafe
223
- ? 2
224
- : errors || tooManyWarnings || (flags.check && changes.length > 0)
225
- ? 1
226
- : 0;
303
+ process.exitCode =
304
+ failed || unwritten.length > 0
305
+ ? 2
306
+ : errors || tooManyWarnings || (flags.check && changes.length > 0)
307
+ ? 1
308
+ : 0;
227
309
  }
228
- common(program.command("lint").argument("[paths...]", "Markdown files/directories, or - for stdin")).action((inputs, flags) => run("lint", inputs, flags));
229
- common(program.command("format").argument("[paths...]", "Markdown files/directories, or - for stdin"))
230
- .option("--check", "Fail if formatting changes would be needed")
310
+ common(program
311
+ .command("lint")
312
+ .argument("[paths...]", "Files, directories, or glob patterns to select, or - for stdin"))
313
+ .description("Report the findings of the enabled rules; never writes")
314
+ .action((inputs, flags) => run("lint", inputs, flags));
315
+ common(program
316
+ .command("format")
317
+ .argument("[paths...]", "Files, directories, or glob patterns to select, or - for stdin"))
318
+ .description("Format documents: print a diff (the default), check, or write")
319
+ .option("--check", "Fail if formatting changes would be needed, and name the files")
231
320
  .option("--diff", "Preview the complete diff (default for file inputs)")
232
321
  .option("--write", "Write validated formatting changes")
233
322
  .action((inputs, flags) => run("format", inputs, flags));
234
- common(program.command("config").description("Inspect effective configuration"))
235
- .command("explain <file>")
236
- .action(async (file, _flags, command) => {
237
- const flags = command.parent.opts();
238
- await checkRoot(flags.root);
239
- const loaded = await loadConfig(path.resolve(flags.root ?? "."), flags.config);
240
- if (flags.dialect)
241
- loaded.config.dialect = flags.dialect;
242
- const root = path.resolve(flags.root ?? loaded.root);
243
- const name = path.relative(root, path.resolve(file)).split(path.sep).join("/");
244
- // The file need not exist. It is located through the root's real path, as
245
- // discovery locates it, so that a root reached through a link is searched
246
- // upward from where it really is.
323
+ // The options belong to `config`, which reads them before or after `explain`;
324
+ // `explain` repeats them so that its help lists them.
325
+ workspaceOptions(workspaceOptions(program.command("config").description("Inspect the configuration"))
326
+ .command("explain")
327
+ .description("Print, as JSON, the configuration in effect for one document")
328
+ .argument("<file>", "The document, which need not exist")).action(async (file, _flags, command) => {
329
+ const flags = command.optsWithGlobals();
330
+ const { root, config, plugins, file: source } = await workspace(flags);
331
+ const name = await workspacePath(root, file);
332
+ if (name === undefined)
333
+ throw new Error(`Input is outside the workspace: ${file}`);
334
+ // The search for the document's vault starts from the root's real path too.
247
335
  const actual = await realpath(root).catch(() => root);
248
336
  process.stdout.write(JSON.stringify({
249
337
  root,
250
- file: loaded.file ?? null,
251
- effective: resolveConfig(loaded.config, name, loaded.plugins, assumedDialect(await enclosingVault(path.join(actual, name)))),
338
+ config: source ?? null,
339
+ path: name,
340
+ effective: resolveConfig({
341
+ config,
342
+ path: name,
343
+ plugins,
344
+ defaultDialect: assumedDialect(await enclosingVault(path.join(actual, name))),
345
+ }),
252
346
  }, null, 2) + "\n");
253
347
  });
254
348
  program
255
349
  .command("rules")
256
- .description("List built-in rules")
257
- .action(() => {
258
- for (const [name, rule] of Object.entries(ruleRegistry()))
259
- process.stdout.write(`${name}\t${rule.kind}\t${rule.description}\n`);
350
+ .description("List the built-in rules and those of the configuration's plugins")
351
+ .option(...configOption)
352
+ .option("--json", "Print the list as JSON")
353
+ .action(async (flags) => {
354
+ const { plugins } = await loadConfig(path.resolve("."), flags.config);
355
+ const rules = Object.entries(ruleRegistry(plugins)).map(([id, rule]) => ({
356
+ id,
357
+ kind: rule.kind,
358
+ description: rule.description,
359
+ }));
360
+ if (flags.json)
361
+ process.stdout.write(JSON.stringify({ rules }, null, 2) + "\n");
362
+ else
363
+ for (const rule of rules)
364
+ // One line per rule, even when a plugin's description has line breaks or tabs.
365
+ process.stdout.write(`${rule.id}\t${rule.kind}\t${rule.description.replace(/\s+/g, " ")}\n`);
260
366
  });
261
367
  try {
262
368
  await program.parseAsync();
263
369
  }
264
370
  catch (error) {
265
- process.stderr.write(`mdtools: ${error instanceof Error ? error.message : String(error)}\n`);
266
- process.exitCode = 2;
371
+ // Commander has printed the help, the version, or what is wrong with the
372
+ // command line. Help and the version succeed; a usage error ends with code 2,
373
+ // as in ESLint and Prettier, so that it does not pass for findings.
374
+ if (error instanceof CommanderError)
375
+ process.exitCode = error.exitCode === 0 ? 0 : 2;
376
+ else {
377
+ process.stderr.write(`mdtools: ${error instanceof Error ? error.message : String(error)}\n`);
378
+ process.exitCode = 2;
379
+ }
267
380
  }
268
381
  //# sourceMappingURL=main.js.map