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
package/dist/cli/main.js CHANGED
@@ -1,151 +1,184 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
3
- import { createTwoFilesPatch } from "diff";
4
- import { access, constants, readFile, realpath, stat } from "node:fs/promises";
2
+ import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
3
+ import { readFile, realpath } from "node:fs/promises";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import { format, lint, ruleRegistry } from "../core/engine.js";
8
7
  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";
8
+ import { dialectAliases, dialects, resolveConfig } from "../config/resolve.js";
9
+ import { isIgnoredInput, replacementFailure, replacementProblem, skippedMessage, writeFormatted, } from "../workspace/files.js";
10
+ import { assumedDialect, checkRoot, discoverFrom } from "../workspace/discovery.js";
11
+ import { enclosingVault } from "../workspace/vault.js";
12
+ import { refusal, refusalText } from "../workspace/access.js";
13
+ import { decodeText, NotUtf8Error } from "../workspace/encoding.js";
14
+ import { unifiedDiff } from "./diff.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")
56
+ .option("--no-warn-ignored", "Do not warn about named files and directories that are skipped as ignored")
27
57
  .option("--stdin-filepath <file>", "Filename context for stdin")
28
58
  .option("--max-warnings <count>", "Fail when warning count exceeds this nonnegative integer", (value) => {
29
59
  const number = Number(value);
30
60
  if (!Number.isInteger(number) || number < 0)
31
61
  throw new Error("--max-warnings must be a nonnegative integer.");
32
62
  return number;
33
- });
63
+ })
64
+ // Not an option: `run` answers it with the option that does what it asks for.
65
+ .addOption(new Option("--fix").hideHelp()));
34
66
  }
35
67
  /**
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.
68
+ * The configuration and workspace root that the options name. `--dialect` is
69
+ * applied to a copy, because an .mjs configuration may export a frozen object.
40
70
  */
41
- function assumedDialect(vault) {
42
- return vault ? "obsidian" : undefined;
71
+ async function workspace(flags) {
72
+ // Without this check the first thing to fail would be the search for a
73
+ // configuration file in the root, with a message about a file the user never named.
74
+ if (flags.root !== undefined)
75
+ await checkRoot(flags.root);
76
+ const loaded = await loadConfig(path.resolve(flags.root ?? "."), flags.config);
77
+ return {
78
+ ...loaded,
79
+ root: path.resolve(flags.root ?? loaded.root),
80
+ config: flags.dialect ? { ...loaded.config, dialect: flags.dialect } : loaded.config,
81
+ };
43
82
  }
44
83
  /**
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.
84
+ * The real path of `file`, which need not exist: the real path of the nearest
85
+ * directory above it that does, with the rest of `file` appended.
48
86
  */
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);
87
+ async function located(file) {
88
+ const rest = [];
89
+ for (let current = path.resolve(file);;) {
90
+ try {
91
+ return path.join(await realpath(current), ...rest);
92
+ }
93
+ catch {
94
+ const parent = path.dirname(current);
95
+ if (parent === current)
96
+ return path.resolve(file);
97
+ rest.unshift(path.basename(current));
98
+ current = parent;
99
+ }
60
100
  }
61
101
  }
102
+ /**
103
+ * The path of `file`, which need not exist, from the workspace root, with
104
+ * forward slashes, or undefined when it lies outside. Both are located through
105
+ * their real paths, as discovery locates files, so that a root reached through
106
+ * a link still holds the files below it.
107
+ */
108
+ async function workspacePath(root, file) {
109
+ const actual = await realpath(root).catch(() => root);
110
+ const name = path
111
+ .relative(actual, await located(file))
112
+ .split(path.sep)
113
+ .join("/");
114
+ return name === ".." || name.startsWith("../") || path.isAbsolute(name) ? undefined : name;
115
+ }
116
+ /** The library's diagnostics for a document that it could not process as configured. */
117
+ const failures = new Set(["engine/unsafe-format", "engine/rule-error"]);
62
118
  async function run(mode, inputs, flags) {
119
+ // ESLint and markdownlint-cli2 fix with --fix, Prettier with --write.
120
+ if (flags.fix)
121
+ throw new Error(mode === "lint"
122
+ ? "unknown option '--fix': lint never changes files; 'mdtools format --write' applies the fixes."
123
+ : "unknown option '--fix': format writes its changes with --write.");
63
124
  if ([flags.check, flags.diff, flags.write].filter(Boolean).length > 1)
64
125
  throw new Error("Choose only one of --check, --diff, and --write.");
65
126
  if (inputs.includes("-") && flags.exclude?.length)
66
127
  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);
128
+ const { root, ...loaded } = await workspace(flags);
74
129
  const stdin = inputs.includes("-");
75
130
  if (stdin && (inputs.length !== 1 || flags.write))
76
131
  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;
132
+ /** The text read from stdin, by the name it is given in the workspace. */
133
+ let documents;
81
134
  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))
135
+ const name = await workspacePath(root, flags.stdinFilepath ?? path.join(root, "stdin.md"));
136
+ if (name === undefined)
87
137
  throw new Error("--stdin-filepath must be within the workspace.");
88
- let source = "";
89
- process.stdin.setEncoding("utf8");
138
+ const chunks = [];
90
139
  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);
140
+ chunks.push(chunk);
141
+ try {
142
+ documents = { [name]: decodeText(Buffer.concat(chunks), "stdin") };
143
+ }
144
+ catch (error) {
145
+ // There is no file to report it for, and nothing else to process.
146
+ if (error instanceof NotUtf8Error)
147
+ throw new Error(`stdin is not UTF-8 text (${error.reason})`, { cause: error });
148
+ throw error;
149
+ }
96
150
  }
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] : []));
151
+ // Paths on the command line are relative to the working directory, and no
152
+ // path selects the whole workspace; stdin is the only document then. Ignore
153
+ // rules apply to the path that `--stdin-filepath` names, as to a named file,
154
+ // and not to the default name.
155
+ const discovery = await discoverFrom({
156
+ root,
157
+ ...(stdin ? { inputs: [] } : inputs.length ? { inputs } : {}),
158
+ exclude: flags.exclude ?? [],
159
+ config: loaded.config,
160
+ plugins: loaded.plugins,
161
+ ...(documents ? { documents } : {}),
162
+ }, process.cwd(), flags.stdinFilepath !== undefined);
99
163
  const reports = [];
100
164
  const changes = [];
101
- // Selected files that the system refused to let the tool read.
165
+ // Selected files that the system refused to let the tool read, or that are not UTF-8 text.
102
166
  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];
167
+ for (const name of discovery.selected) {
168
+ const options = discovery.options(name);
141
169
  let source;
142
170
  try {
143
- source = typeof value === "function" ? value() : value;
171
+ source = discovery.read(name);
144
172
  }
145
173
  catch (error) {
146
174
  // Report the file and carry on with the others; any other error is a fault.
147
175
  const code = refusal(error);
148
- if (!code)
176
+ const problem = code
177
+ ? `could not be read (${refusalText(code)})`
178
+ : error instanceof NotUtf8Error
179
+ ? `is not UTF-8 text (${error.reason})`
180
+ : undefined;
181
+ if (!problem)
149
182
  throw error;
150
183
  unread.push(name);
151
184
  reports.push({
@@ -154,7 +187,7 @@ async function run(mode, inputs, flags) {
154
187
  diagnostics: [
155
188
  {
156
189
  start: 0,
157
- message: `The file could not be read (${refusalText(code)}) and was not ${mode === "lint" ? "linted" : "formatted"}.`,
190
+ message: `The file ${problem} and was not ${mode === "lint" ? "linted" : "formatted"}.`,
158
191
  rule: "engine/unreadable-file",
159
192
  severity: "error",
160
193
  line: 1,
@@ -164,8 +197,6 @@ async function run(mode, inputs, flags) {
164
197
  });
165
198
  continue;
166
199
  }
167
- if (source === null)
168
- continue;
169
200
  if (mode === "lint")
170
201
  reports.push({ path: name, diagnostics: lint(source, options) });
171
202
  else {
@@ -180,89 +211,178 @@ async function run(mode, inputs, flags) {
180
211
  changes.push({ file: name, before: source, after: result.output });
181
212
  }
182
213
  }
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.
214
+ // A document that could not be read, had a rule refused or failing on it, or
215
+ // could not be written is a failure to operate, reported for its own file,
216
+ // and the run ends with status 2. Files do not depend on one another's
217
+ // content, so every other changed document is still written, and so is the
218
+ // result of one with a refused or failing rule, which the engine checked.
219
+ const failed = unread.length > 0 ||
220
+ reports.some((report) => report.diagnostics.some((item) => failures.has(item.rule)));
221
+ // Changed files that could not be written, each reported for its file.
222
+ const unwritten = [];
223
+ const refuse = (file, message) => {
224
+ unwritten.push(file);
225
+ reports
226
+ .find((report) => report.path === file)
227
+ .diagnostics.unshift({
228
+ start: 0,
229
+ message: `${message}.`,
230
+ rule: "engine/unwritable-file",
231
+ severity: "error",
232
+ line: 1,
233
+ column: 1,
234
+ });
235
+ };
236
+ // The files that were written, in the order of the report.
237
+ const written = [];
238
+ if (flags.write)
239
+ for (const change of changes) {
240
+ const file = path.join(discovery.root, change.file);
241
+ // A file that may not be written, such as a read-only one, is left alone.
242
+ const problem = await replacementProblem(file);
243
+ if (problem) {
244
+ refuse(change.file, `${problem}; the file is unchanged`);
245
+ continue;
246
+ }
247
+ try {
248
+ await writeFormatted(file, change.before, change.after);
249
+ written.push(change.file);
250
+ }
251
+ catch (error) {
252
+ const failure = replacementFailure(error);
253
+ if (!failure)
254
+ throw error;
255
+ refuse(change.file, failure);
256
+ }
257
+ }
258
+ // As in ESLint, --quiet leaves warnings out of the report and of what counts them.
259
+ if (flags.quiet)
260
+ for (const report of reports)
261
+ report.diagnostics = report.diagnostics.filter((item) => item.severity !== "warn");
185
262
  const diagnostics = reports.flatMap((report) => report.diagnostics);
186
263
  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
264
  if (flags.json)
193
265
  process.stdout.write(JSON.stringify({
194
266
  version: manifest.version,
195
267
  mode,
268
+ // The files' paths are relative to it, for programs to join.
269
+ root,
196
270
  files: reports,
197
- written: flags.write === true && !unsafe,
198
- ...(set.skipped.length ? { skipped: set.skipped } : {}),
271
+ written,
272
+ ...(discovery.skipped.length ? { skipped: discovery.skipped } : {}),
199
273
  }, null, 2) + "\n");
200
274
  else {
275
+ // Text names documents from the current directory, as Prettier does, so
276
+ // that a terminal or an editor opens them from where the command ran.
277
+ const here = await realpath(process.cwd()).catch(() => process.cwd());
278
+ const shown = (name) => path.relative(here, path.join(discovery.root, name)).split(path.sep).join("/");
201
279
  // 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`);
280
+ // A caller that passes every changed file can leave out those about ignored ones.
281
+ for (const item of discovery.skipped)
282
+ if (flags.warnIgnored !== false || !isIgnoredInput(item))
283
+ process.stderr.write(`mdtools: warning: ${skippedMessage(item, shown)}\n`);
284
+ // An ignored document from stdin comes back unchanged, as in Prettier, so
285
+ // that an editor that replaces its text with the output loses nothing.
204
286
  if (stdin && mode === "format" && !flags.check && !flags.diff)
205
- process.stdout.write(reports[0]?.output ?? "");
287
+ process.stdout.write(reports[0]?.output ?? Object.values(documents ?? {})[0] ?? "");
206
288
  else if (mode === "format" && !flags.check && !flags.write)
207
289
  for (const change of changes)
208
- process.stdout.write(createTwoFilesPatch(`a/${change.file}`, `b/${change.file}`, change.before, change.after));
290
+ process.stdout.write(unifiedDiff(`a/${shown(change.file)}`, `b/${shown(change.file)}`, change.before, change.after));
209
291
  for (const report of reports)
210
292
  for (const item of report.diagnostics)
211
- process.stderr.write(`${report.path}:${item.line}:${item.column}: ${item.severity} ${item.rule}: ${item.message}\n`);
293
+ process.stderr.write(`${shown(report.path)}:${item.line}:${item.column}: ${item.severity} ${item.rule}: ${item.message}\n`);
294
+ // The files to fix when a check fails, as Prettier's --check lists them.
295
+ if (mode === "format" && flags.check && !stdin)
296
+ for (const change of changes)
297
+ process.stderr.write(`${shown(change.file)}\n`);
212
298
  if (!stdin) {
213
299
  // Lint changes nothing, so its summary counts what it reported instead.
214
300
  const outcome = mode === "lint"
215
301
  ? `${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`);
302
+ : flags.write
303
+ ? `${written.length} written`
304
+ : `${changes.length} would change`;
305
+ 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
306
  }
219
307
  }
220
308
  const errors = count("error") > 0;
221
309
  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;
310
+ process.exitCode =
311
+ failed || unwritten.length > 0
312
+ ? 2
313
+ : errors || tooManyWarnings || (flags.check && changes.length > 0)
314
+ ? 1
315
+ : 0;
227
316
  }
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")
317
+ common(program
318
+ .command("lint")
319
+ .argument("[paths...]", "Files, directories, or glob patterns to select, or - for stdin"))
320
+ .description("Report the findings of the enabled rules; never writes")
321
+ .action((inputs, flags) => run("lint", inputs, flags));
322
+ common(program
323
+ .command("format")
324
+ .argument("[paths...]", "Files, directories, or glob patterns to select, or - for stdin"))
325
+ .description("Format documents: print a diff (the default), check, or write")
326
+ .option("--check", "Fail if formatting changes would be needed, and name the files")
231
327
  .option("--diff", "Preview the complete diff (default for file inputs)")
232
328
  .option("--write", "Write validated formatting changes")
233
329
  .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.
330
+ // The options belong to `config`, which reads them before or after `explain`;
331
+ // `explain` repeats them so that its help lists them.
332
+ workspaceOptions(workspaceOptions(program.command("config").description("Inspect the configuration"))
333
+ .command("explain")
334
+ .description("Print, as JSON, the configuration in effect for one document")
335
+ .argument("<file>", "The document, which need not exist")).action(async (file, _flags, command) => {
336
+ const flags = command.optsWithGlobals();
337
+ const { root, config, plugins, file: source } = await workspace(flags);
338
+ const name = await workspacePath(root, file);
339
+ if (name === undefined)
340
+ throw new Error(`Input is outside the workspace: ${file}`);
341
+ // The search for the document's vault starts from the root's real path too.
247
342
  const actual = await realpath(root).catch(() => root);
248
343
  process.stdout.write(JSON.stringify({
249
344
  root,
250
- file: loaded.file ?? null,
251
- effective: resolveConfig(loaded.config, name, loaded.plugins, assumedDialect(await enclosingVault(path.join(actual, name)))),
345
+ config: source ?? null,
346
+ path: name,
347
+ effective: resolveConfig({
348
+ config,
349
+ path: name,
350
+ plugins,
351
+ defaultDialect: assumedDialect(await enclosingVault(path.join(actual, name))),
352
+ }),
252
353
  }, null, 2) + "\n");
253
354
  });
254
355
  program
255
356
  .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`);
357
+ .description("List the built-in rules and those of the configuration's plugins")
358
+ .option(...configOption)
359
+ .option("--json", "Print the list as JSON")
360
+ .action(async (flags) => {
361
+ const { plugins } = await loadConfig(path.resolve("."), flags.config);
362
+ const rules = Object.entries(ruleRegistry(plugins)).map(([id, rule]) => ({
363
+ id,
364
+ kind: rule.kind,
365
+ description: rule.description,
366
+ }));
367
+ if (flags.json)
368
+ process.stdout.write(JSON.stringify({ rules }, null, 2) + "\n");
369
+ else
370
+ for (const rule of rules)
371
+ // One line per rule, even when a plugin's description has line breaks or tabs.
372
+ process.stdout.write(`${rule.id}\t${rule.kind}\t${rule.description.replace(/\s+/g, " ")}\n`);
260
373
  });
261
374
  try {
262
375
  await program.parseAsync();
263
376
  }
264
377
  catch (error) {
265
- process.stderr.write(`mdtools: ${error instanceof Error ? error.message : String(error)}\n`);
266
- process.exitCode = 2;
378
+ // Commander has printed the help, the version, or what is wrong with the
379
+ // command line. Help and the version succeed; a usage error ends with code 2,
380
+ // as in ESLint and Prettier, so that it does not pass for findings.
381
+ if (error instanceof CommanderError)
382
+ process.exitCode = error.exitCode === 0 ? 0 : 2;
383
+ else {
384
+ process.stderr.write(`mdtools: ${error instanceof Error ? error.message : String(error)}\n`);
385
+ process.exitCode = 2;
386
+ }
267
387
  }
268
388
  //# sourceMappingURL=main.js.map