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,63 +1,148 @@
1
1
  import { Ajv } from "ajv";
2
+ import { deepFreeze } from "./frozen.js";
2
3
  import { resolveConfig, setting } from "../config/resolve.js";
3
- import { parse, range } from "../syntax/parse.js";
4
+ import { optionErrors, shown } from "../config/problems.js";
5
+ import { codeSpanText, frontMatterReading, parse, range, splitByteOrderMark, startsWithMarker, } from "../syntax/parse.js";
4
6
  import { styleRules } from "../rules/style.js";
7
+ import { blockRules } from "../rules/blocks.js";
5
8
  import { dialectRules } from "../rules/dialects.js";
6
9
  import { structureRules } from "../rules/structure.js";
7
10
  import { linkRules } from "../rules/links.js";
11
+ import { contentRules } from "../rules/content.js";
12
+ import { listRules } from "../rules/lists.js";
8
13
  import { headingAnchorSource, headingAttributeSource } from "../workspace/heading-attributes.js";
9
14
  import { suppressions } from "./directives.js";
10
- export const builtInRules = {
15
+ import { checkRanges, covers, holds, moved, shifted, widen } from "./scope.js";
16
+ /**
17
+ * The built-in rules by rule ID. The table and the rules in it are frozen, so
18
+ * that no caller can change what another one runs.
19
+ */
20
+ export const builtInRules = deepFreeze({
11
21
  ...styleRules,
22
+ ...blockRules,
12
23
  ...dialectRules,
13
24
  ...linkRules,
14
25
  ...structureRules,
15
- };
26
+ ...contentRules,
27
+ ...listRules,
28
+ });
29
+ const phases = ["inline", "block", "document"];
30
+ /** What is wrong with a plugin's rule, or undefined when it has the shape of a `Rule`. */
31
+ function ruleProblem(rule) {
32
+ if (!rule || typeof rule !== "object")
33
+ return `it is ${shown(rule)}, not an object`;
34
+ const { check, kind, phase, description, schema } = rule;
35
+ if (typeof check !== "function")
36
+ return `its check is ${shown(check)}, not a function`;
37
+ if (kind !== "style" && kind !== "problem")
38
+ return `its kind is ${shown(kind)}, not "style" or "problem"`;
39
+ if (phase !== undefined && !phases.includes(phase))
40
+ return `its phase is ${shown(phase)}, not "inline", "block", or "document"`;
41
+ if (typeof description !== "string")
42
+ return `its description is ${shown(description)}, not a string`;
43
+ if (schema !== undefined &&
44
+ typeof schema !== "boolean" &&
45
+ (!schema || typeof schema !== "object" || Array.isArray(schema)))
46
+ return `its schema is ${shown(schema)}, not a JSON Schema`;
47
+ // Options are checked before each run, synchronously; an asynchronous
48
+ // check would come back after the run, too late to stop it.
49
+ if (schema && typeof schema === "object" && schema.$async === true)
50
+ return "its schema is asynchronous ($async), and options are checked synchronously";
51
+ return undefined;
52
+ }
53
+ /**
54
+ * The prefix of the engine's own diagnostics, such as `engine/rule-error`. A
55
+ * plugin of this name could report findings that pass for the engine's, which
56
+ * concern the whole document and make the CLI exit with code 2.
57
+ */
58
+ export const reservedPluginName = "engine";
59
+ /**
60
+ * The built-in rules and those of `plugins`, by rule ID, in a new table. A
61
+ * plugin name that is invalid, reserved, or used twice, a rule ID that exists
62
+ * already, and a plugin rule that does not have the shape of a `Rule` are
63
+ * errors that name them.
64
+ */
16
65
  export function ruleRegistry(plugins = []) {
17
66
  const rules = { ...builtInRules };
18
67
  const names = new Set();
19
68
  for (const plugin of plugins) {
69
+ if (plugin.name === reservedPluginName)
70
+ throw new Error(`Invalid plugin name: ${reservedPluginName} is reserved for the engine's own diagnostics`);
20
71
  if (!/^[a-z][a-z\d-]*$/.test(plugin.name) || names.has(plugin.name))
21
72
  throw new Error(`Invalid or duplicate plugin name: ${plugin.name}`);
22
73
  names.add(plugin.name);
23
74
  for (const [name, rule] of Object.entries(plugin.rules ?? {})) {
24
75
  const id = `${plugin.name}/${name}`;
25
- if (rules[id])
76
+ if (Object.hasOwn(rules, id))
26
77
  throw new Error(`Duplicate rule: ${id}`);
78
+ const problem = ruleProblem(rule);
79
+ if (problem)
80
+ throw new Error(`Invalid rule ${id}: ${problem}.`);
27
81
  rules[id] = rule;
28
82
  }
29
83
  }
30
84
  return rules;
31
85
  }
86
+ /** Checks the options of built-in rules, whose schemas have no `$id` that could collide. */
32
87
  const ruleValidator = new Ajv({ allErrors: true });
33
88
  const builtInSchemas = new Set(Object.values(builtInRules).map((rule) => rule.schema));
89
+ /**
90
+ * Each option schema compiled once, however many documents use it. A plugin
91
+ * schema gets a validator of its own, because distinct plugins may
92
+ * legitimately reuse an `$id` with different schemas.
93
+ */
94
+ const compiledSchemas = new WeakMap();
95
+ function optionsValidator(name, schema) {
96
+ // Ajv keeps compiled boolean schemas itself.
97
+ if (typeof schema !== "object")
98
+ return ruleValidator.compile(schema);
99
+ let validate = compiledSchemas.get(schema);
100
+ if (!validate) {
101
+ try {
102
+ validate = (builtInSchemas.has(schema) ? ruleValidator : new Ajv({ allErrors: true })).compile(schema);
103
+ }
104
+ catch (error) {
105
+ throw new Error(`Invalid rule ${name}: its schema cannot be compiled: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
106
+ }
107
+ // A schema can become asynchronous through one that it refers to.
108
+ if (validate.$async === true)
109
+ throw new Error(`Invalid rule ${name}: its schema is asynchronous ($async), and options are checked synchronously`);
110
+ compiledSchemas.set(schema, validate);
111
+ }
112
+ return validate;
113
+ }
34
114
  function prepare(options) {
35
- const config = resolveConfig(options.config, options.path, options.plugins, options.defaultDialect);
115
+ // Only these options, so that resolveConfig's check for the prereleases' form sees no others.
116
+ const { config: given, path, plugins, defaultDialect } = options;
117
+ const config = resolveConfig({ config: given, path, plugins, defaultDialect });
36
118
  const rules = ruleRegistry(options.plugins);
37
- let pluginValidator;
38
119
  for (const [name, value] of Object.entries(config.rules)) {
39
- const rule = rules[name];
40
- if (!rule)
120
+ // Names such as `toString` are found on every object; only defined rules count.
121
+ if (!Object.hasOwn(rules, name))
41
122
  throw new Error(`Unknown rule: ${name}`);
123
+ const rule = rules[name];
42
124
  const enabled = setting(value);
43
- if (rule.schema && enabled.severity !== "off") {
44
- // Plugin schema IDs are scoped to this call; distinct plugin instances
45
- // may legitimately reuse the same ID with different schemas.
46
- const validator = builtInSchemas.has(rule.schema)
47
- ? ruleValidator
48
- : (pluginValidator ??= new Ajv({ allErrors: true }));
49
- const validate = validator.compile(rule.schema);
125
+ // A schema of `false` means that the rule takes no options. A setting that
126
+ // gives none is checked as empty options, which `false` would refuse too.
127
+ if (rule.schema === false && enabled.severity !== "off") {
128
+ if (Object.keys(enabled.options).length)
129
+ throw new Error(`Invalid options for ${name}: the rule takes no options`);
130
+ }
131
+ else if (rule.schema !== undefined && enabled.severity !== "off") {
132
+ const validate = optionsValidator(name, rule.schema);
50
133
  if (!validate(enabled.options))
51
- throw new Error(`Invalid options for ${name}: ${validator.errorsText(validate.errors)}`);
134
+ throw new Error(`Invalid options for ${name}: ${optionErrors(validate.errors ?? [], enabled.options)}`);
52
135
  }
53
136
  }
54
137
  return { config, rules };
55
138
  }
139
+ /** CommonMark's line endings. */
140
+ const lineBreaks = /\r\n|\r|\n/g;
56
141
  /** Offsets where each line starts, computed once per source so findings locate in O(log lines). */
57
142
  function lineStarts(source) {
58
143
  const starts = [0];
59
- for (let at = source.indexOf("\n"); at >= 0; at = source.indexOf("\n", at + 1))
60
- starts.push(at + 1);
144
+ for (const match of source.matchAll(lineBreaks))
145
+ starts.push(match.index + match[0].length);
61
146
  return starts;
62
147
  }
63
148
  function diagnostic(starts, rule, severity, finding) {
@@ -73,7 +158,93 @@ function diagnostic(starts, rule, severity, finding) {
73
158
  return { ...finding, rule, severity, line: low + 1, column: finding.start - starts[low] + 1 };
74
159
  }
75
160
  const sortDiagnostics = (a, b) => a.start - b.start || a.rule.localeCompare(b.rule);
76
- function inspect(document, config, rules, workspace, include = () => true) {
161
+ /** Diagnostics of the engine itself; they concern the whole document. */
162
+ const fromEngine = (item) => item.rule.startsWith("engine/");
163
+ /** Why `value` is not an offset from `from` to `to`, or undefined when it is one. */
164
+ function offsetProblem(what, value, from, to) {
165
+ return Number.isInteger(value) && value >= from && value <= to
166
+ ? undefined
167
+ : `${what} is ${shown(value)}, not an offset from ${from} to ${to}`;
168
+ }
169
+ /**
170
+ * What is wrong with the findings that a rule returned for a source of
171
+ * `length` UTF-16 units, as the rest of a sentence about the rule, or
172
+ * undefined when every finding can be reported and every edit applied.
173
+ */
174
+ function findingsProblem(findings, length) {
175
+ if (!Array.isArray(findings))
176
+ return `returned ${shown(findings)} instead of a list of findings`;
177
+ for (const finding of findings) {
178
+ if (!finding || typeof finding !== "object")
179
+ return `returned a finding that is ${shown(finding)}, not an object`;
180
+ const { message, start, end, edit } = finding;
181
+ const problem = (typeof message === "string"
182
+ ? undefined
183
+ : `whose message is ${shown(message)}, not a string`) ??
184
+ offsetProblem("whose start", start, 0, length) ??
185
+ (end === undefined ? undefined : offsetProblem("whose end", end, start, length)) ??
186
+ (edit === undefined || (edit && typeof edit === "object")
187
+ ? undefined
188
+ : `whose edit is ${shown(edit)}, not an object`);
189
+ if (problem)
190
+ return `returned a finding ${problem}`;
191
+ if (edit === undefined)
192
+ continue;
193
+ const { start: from, end: to, text } = edit;
194
+ const invalid = offsetProblem("whose start", from, 0, length) ??
195
+ offsetProblem("whose end", to, from, length) ??
196
+ (typeof text === "string" ? undefined : `whose text is ${shown(text)}, not a string`);
197
+ if (invalid)
198
+ return `returned an edit ${invalid}`;
199
+ }
200
+ return undefined;
201
+ }
202
+ function host(workspace) {
203
+ const errors = new Set();
204
+ return workspace ? { workspace: guarded(workspace, errors), errors } : { errors };
205
+ }
206
+ /**
207
+ * `workspace` as rules see it. What its `resolve` throws is added to `errors`:
208
+ * such an error is the host's, such as a file that could not be read, and not
209
+ * the rule's. Rules still see it as it was thrown.
210
+ */
211
+ function guarded(workspace, errors) {
212
+ const resolve = (...query) => {
213
+ try {
214
+ return workspace.resolve(...query);
215
+ }
216
+ catch (error) {
217
+ errors.add(error);
218
+ throw error;
219
+ }
220
+ };
221
+ // Other members, such as a host's own, reach the workspace itself.
222
+ return new Proxy(workspace, {
223
+ get(target, key) {
224
+ if (key === "resolve")
225
+ return resolve;
226
+ const value = Reflect.get(target, key);
227
+ return typeof value === "function" ? value.bind(target) : value;
228
+ },
229
+ });
230
+ }
231
+ /** A thrown value as text, without letting it throw again. */
232
+ function failure(error) {
233
+ try {
234
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
235
+ }
236
+ catch {
237
+ return "a value that cannot be shown";
238
+ }
239
+ }
240
+ /**
241
+ * The diagnostics of the enabled rules that `include` selects. A rule that
242
+ * throws, or returns something that is not a list of valid findings, is
243
+ * skipped for the document and reported as `engine/rule-error` instead. An
244
+ * error of the workspace itself, such as a file that could not be read, is
245
+ * not the rule's and is thrown on.
246
+ */
247
+ function inspect(document, config, rules, { workspace, errors: hostErrors }, include = () => true) {
77
248
  const result = [];
78
249
  const suppressed = suppressions(document);
79
250
  const starts = lineStarts(document.source);
@@ -82,23 +253,99 @@ function inspect(document, config, rules, workspace, include = () => true) {
82
253
  const rule = rules[name];
83
254
  if (enabled.severity === "off" || !include(rule))
84
255
  continue;
85
- const findings = rule.check({
86
- document,
87
- options: enabled.options,
88
- ...(workspace ? { workspace } : {}),
89
- });
256
+ let findings = [];
257
+ let problem;
258
+ try {
259
+ const returned = rule.check({
260
+ document,
261
+ options: enabled.options,
262
+ ...(workspace ? { workspace } : {}),
263
+ });
264
+ problem = findingsProblem(returned, document.source.length);
265
+ if (!problem)
266
+ findings = returned;
267
+ }
268
+ catch (error) {
269
+ if (hostErrors.has(error))
270
+ throw error;
271
+ problem = `failed (${failure(error)})`;
272
+ }
273
+ if (problem) {
274
+ result.push(diagnostic([0], "engine/rule-error", "error", {
275
+ start: 0,
276
+ message: `Rule ${name} ${problem}; the rule was skipped for this document.`,
277
+ }));
278
+ continue;
279
+ }
90
280
  for (const finding of findings)
91
281
  if (!suppressed(name, finding))
92
282
  result.push(diagnostic(starts, name, enabled.severity, finding));
93
283
  }
94
284
  return result.sort(sortDiagnostics);
95
285
  }
286
+ /**
287
+ * Locate diagnostics of a document that `parse` read without the byte order
288
+ * marks it starts with in the text as given, so that their edits apply to that
289
+ * text. Lines and columns stay as they are: the marks are invisible.
290
+ */
291
+ function located(diagnostics, mark) {
292
+ if (!mark)
293
+ return diagnostics;
294
+ const by = mark.length;
295
+ return diagnostics.map((item) => ({
296
+ ...item,
297
+ start: item.start + by,
298
+ ...(item.end !== undefined ? { end: item.end + by } : {}),
299
+ ...(item.edit
300
+ ? { edit: { ...item.edit, start: item.edit.start + by, end: item.edit.end + by } }
301
+ : {}),
302
+ }));
303
+ }
304
+ /**
305
+ * The diagnostics of the enabled rules for `source`, sorted by position. A rule
306
+ * that fails is skipped and reported as `engine/rule-error`. An invalid
307
+ * configuration, an unknown rule, invalid rule options, an invalid plugin, and
308
+ * an error that the workspace throws are thrown.
309
+ */
96
310
  export function lint(source, options = {}) {
311
+ if (options.ranges)
312
+ checkRanges(options.ranges, source);
97
313
  const { config, rules } = prepare(options);
98
- const document = parse(source, config.dialect, options.path, options.plugins);
99
- return inspect(document, config, rules, options.workspace);
314
+ const [mark, text] = splitByteOrderMark(source);
315
+ const document = parse(text, config.dialect, options.path, options.plugins);
316
+ const diagnostics = inspect(document, config, rules, host(options.workspace));
317
+ // Ranges count the marks, and the document's positions do not.
318
+ const scope = options.ranges && widen(document.tree, document.source, shifted(options.ranges, -mark.length));
319
+ return located(scope
320
+ ? diagnostics.filter((item) => fromEngine(item) || covers(scope, item.start))
321
+ : diagnostics, mark);
100
322
  }
323
+ /**
324
+ * `source` with `edits` applied, which are `{ start, end, text }` replacements
325
+ * at UTF-16 offsets into it. Repeats of one edit count once; an edit outside
326
+ * the source, or one that overlaps another, is an error.
327
+ */
101
328
  export function applyEdits(source, edits) {
329
+ return splice(source, acceptedEdits(source, edits));
330
+ }
331
+ /** `source` with `edits`, which are sorted and do not overlap, applied. */
332
+ function splice(source, edits) {
333
+ // Assemble the result in one pass; rebuilding the string per edit is quadratic.
334
+ const parts = [];
335
+ let cursor = 0;
336
+ for (const edit of edits) {
337
+ parts.push(source.slice(cursor, edit.start), edit.text);
338
+ cursor = edit.end;
339
+ }
340
+ parts.push(source.slice(cursor));
341
+ return parts.join("");
342
+ }
343
+ /**
344
+ * The edits that `applyEdits` applies, sorted by position and without repeats
345
+ * of the same edit. An edit outside the source, or overlapping another one, is
346
+ * an error.
347
+ */
348
+ function acceptedEdits(source, edits) {
102
349
  const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
103
350
  let previous;
104
351
  const accepted = [];
@@ -119,18 +366,23 @@ export function applyEdits(source, edits) {
119
366
  accepted.push(edit);
120
367
  previous = edit;
121
368
  }
122
- // Assemble the result in one pass; rebuilding the string per edit is quadratic.
123
- const parts = [];
124
- let cursor = 0;
125
- for (const edit of accepted) {
126
- parts.push(source.slice(cursor, edit.start), edit.text);
127
- cursor = edit.end;
128
- }
129
- parts.push(source.slice(cursor));
130
- return parts.join("");
369
+ return accepted;
131
370
  }
132
- /** Compare semantic trees, with link identity supplied by the selected renderer's resolver. */
371
+ /**
372
+ * The meaning of a parsed document as text, which `format` compares before and
373
+ * after edits to refuse a change of meaning: its syntax tree without positions,
374
+ * with runs of whitespace in text alike, and with each link's destination as
375
+ * the workspace resolves it, so that two spellings of one target are the same.
376
+ */
133
377
  export function semanticFingerprint(document, workspace) {
378
+ return JSON.stringify(semanticTree(document, workspace));
379
+ }
380
+ /**
381
+ * The document's meaning as plain data whose keys are in sorted order. With
382
+ * `origins`, each object of the result that stands for a node of the document
383
+ * is mapped to that node.
384
+ */
385
+ function semanticTree(document, workspace, origins) {
134
386
  const canonicalUrl = (url, wiki = false) => {
135
387
  const resolution = workspace?.resolve(document.path, url, document.dialect, wiki);
136
388
  // A target that is uncertain, because a directory could not be read, is not
@@ -142,14 +394,16 @@ export function semanticFingerprint(document, workspace) {
142
394
  // Keys are emitted in sorted order so the JSON text is canonical without a replacer.
143
395
  function normalize(node, calloutHeader = false) {
144
396
  if (node.type === "wikiLink") {
145
- if (node.embed)
146
- return { label: node.label, type: "embed", url: canonicalUrl(node.target ?? "", true) };
147
- return {
148
- children: [{ type: "text", value: node.label ?? node.target }],
149
- title: null,
150
- type: "link",
151
- url: canonicalUrl(node.target ?? "", true),
152
- };
397
+ const link = node.embed
398
+ ? { label: node.label, type: "embed", url: canonicalUrl(node.target, true) }
399
+ : {
400
+ children: [{ type: "text", value: node.label ?? node.target }],
401
+ title: null,
402
+ type: "link",
403
+ url: canonicalUrl(node.target, true),
404
+ };
405
+ origins?.set(link, node);
406
+ return link;
153
407
  }
154
408
  const result = {};
155
409
  // Forgejo and Gitea read a heading's trailing `{...}` block as attributes,
@@ -163,11 +417,13 @@ export function semanticFingerprint(document, workspace) {
163
417
  if (anchors !== undefined)
164
418
  result.anchors = anchors;
165
419
  }
166
- if (calloutHeader && document.dialect === "obsidian" && node.type === "paragraph") {
420
+ if (calloutHeader && document.dialect !== "commonmark" && node.type === "paragraph") {
167
421
  const [start, end] = range(node);
168
- const header = document.source.slice(start, end).split(/\r?\n/, 1)[0];
169
- if (/^\[![\w-]+\]/.test(header)) {
170
- // CommonMark merges title and body into one paragraph. Preserve their boundary too.
422
+ const header = document.source.slice(start, end).split(/\r\n|\r|\n/, 1)[0];
423
+ if (startsWithMarker(header, node)) {
424
+ // CommonMark merges an Obsidian callout's title, or the line of a
425
+ // GitHub alert's marker, and the body into one paragraph. Preserve
426
+ // their boundary too, also for a marker written as `\[!NOTE]`.
171
427
  const title = header.replace(/^\[!([\w-]+)\]/, (_, marker) => `[!${marker.toLowerCase()}]`);
172
428
  result.calloutTitle = normalize(parse(title, document.dialect, document.path).tree);
173
429
  }
@@ -188,70 +444,253 @@ export function semanticFingerprint(document, workspace) {
188
444
  result[key] = text;
189
445
  }
190
446
  else if (key === "value" && node.type === "inlineCode")
191
- result[key] = node.value.replace(/\r\n|\r|\n/g, " ");
447
+ // A code span is compared by the text that GitHub shows, which its
448
+ // value in the tree does not hold where it spans lines.
449
+ result[key] = codeSpanText(node);
450
+ // An image's description renders as plain text, and a reference's label
451
+ // matches its definition with whitespace collapsed.
452
+ else if ((key === "alt" || key === "label") && typeof value === "string")
453
+ result[key] = value.replace(/[ \t\r\n]+/g, " ");
192
454
  else
193
455
  result[key] = value;
194
456
  }
457
+ origins?.set(result, node);
195
458
  return result;
196
459
  }
197
- return JSON.stringify(normalize(document.tree));
460
+ const result = normalize(document.tree);
461
+ // Where front matter would end inside a code block, an HTML block, or
462
+ // display math, the document is read without it, and an edit has to keep
463
+ // the meaning of both readings.
464
+ const reading = frontMatterReading(document.tree);
465
+ if (reading)
466
+ result.frontMatterReading = normalize(reading);
467
+ return result;
198
468
  }
469
+ const isBranch = (value) => typeof value === "object" && value !== null && Array.isArray(value.children);
470
+ /**
471
+ * Where the meaning of `after` first differs from that of `before`, as an
472
+ * offset into `before`'s source: the start of the first node, in document
473
+ * order, whose meaning is not the same, or the end of the node that `after`
474
+ * gives more content.
475
+ */
476
+ function divergence(before, after, workspace) {
477
+ const origins = new Map();
478
+ let a = semanticTree(before, workspace, origins);
479
+ let b = semanticTree(after, workspace);
480
+ // Where only the reading with front matter differs, the place is in that reading.
481
+ const reading = (value) => value.frontMatterReading;
482
+ if (isBranch(a) &&
483
+ isBranch(b) &&
484
+ isBranch(reading(a)) &&
485
+ isBranch(reading(b)) &&
486
+ JSON.stringify(a.children) === JSON.stringify(b.children)) {
487
+ a = reading(a);
488
+ b = reading(b);
489
+ }
490
+ let offset = 0;
491
+ for (;;) {
492
+ const node = origins.get(a);
493
+ // A node made up for the comparison, or one without a position, is placed at its parent.
494
+ offset = node?.position?.start.offset ?? offset;
495
+ if (!isBranch(a) || !isBranch(b))
496
+ return offset;
497
+ const own = (value) => JSON.stringify({ ...value, children: undefined });
498
+ if (own(a) !== own(b))
499
+ return offset;
500
+ const { children } = b;
501
+ const index = a.children.findIndex((child, at) => JSON.stringify(child) !== JSON.stringify(children[at]));
502
+ if (index < 0)
503
+ return node?.position?.end.offset ?? offset;
504
+ a = a.children[index];
505
+ b = children[index];
506
+ }
507
+ }
508
+ /**
509
+ * Format a document: apply the edits of the enabled style rules, in passes
510
+ * until nothing changes, leaving out the edits of a rule that would change the
511
+ * document's meaning. Byte order marks that it starts with stay in the output
512
+ * and are not part of what the rules see. Errors are thrown as by `lint`.
513
+ */
199
514
  export function format(source, options = {}) {
515
+ if (options.ranges)
516
+ checkRanges(options.ranges, source);
517
+ const [mark, text] = splitByteOrderMark(source);
518
+ // Ranges count the marks, and the document's positions do not.
519
+ const result = formatText(text, options, options.ranges && shifted(options.ranges, -mark.length));
520
+ return {
521
+ output: mark + result.output,
522
+ changed: result.changed,
523
+ diagnostics: located(result.diagnostics, mark),
524
+ ...(result.scope ? { scope: shifted(result.scope, mark.length) } : {}),
525
+ };
526
+ }
527
+ const byPosition = (a, b) => a.start - b.start || a.end - b.end;
528
+ /**
529
+ * The proposals to apply together, in the order of the source. An edit that
530
+ * overlaps one before it, or repeats it, is left out; the next pass, which
531
+ * runs the rules again on the changed text, reconsiders it.
532
+ */
533
+ function compatible(proposals) {
534
+ const result = [];
535
+ let previous;
536
+ for (const edit of [...proposals].sort(byPosition)) {
537
+ if (previous && (edit.start < previous.end || edit.start === previous.start))
538
+ continue;
539
+ result.push(edit);
540
+ previous = edit;
541
+ }
542
+ return result;
543
+ }
544
+ /** Rule IDs as a list in prose. */
545
+ function listed(names) {
546
+ const items = [...names];
547
+ return items.length < 3
548
+ ? items.join(" and ")
549
+ : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
550
+ }
551
+ /**
552
+ * Format a document without byte order marks, limited to `ranges` in its
553
+ * positions. Each phase applies the edits of its rules together when the
554
+ * result means what the original document means. Otherwise it adds the rules'
555
+ * edits one rule at a time and leaves out the edits of each rule that would
556
+ * change the meaning; the result reports each such rule with the place where
557
+ * the meaning would first differ. Passes repeat until one changes nothing;
558
+ * formatting that does not settle leaves the whole document unchanged.
559
+ */
560
+ function formatText(source, options, ranges) {
200
561
  const { config, rules } = prepare(options);
562
+ // What the workspace throws stops formatting, as it stops `lint`.
563
+ const hosted = host(options.workspace);
564
+ const { workspace } = hosted;
201
565
  let output = source;
202
566
  let document = parse(source, config.dialect, options.path, options.plugins);
567
+ // Where edits may apply, in the positions of the current output.
568
+ const initial = ranges && widen(document.tree, source, ranges);
569
+ let scope = initial;
570
+ // Engine findings concern the whole document and are always reported.
571
+ const reported = (items) => scope ? items.filter((item) => fromEngine(item) || covers(scope, item.start)) : items;
572
+ const unchanged = (message) => ({
573
+ output: source,
574
+ changed: false,
575
+ diagnostics: [diagnostic([0], "engine/unsafe-format", "error", { start: 0, message })],
576
+ ...(initial ? { scope: initial } : {}),
577
+ });
203
578
  // The original fingerprint is only needed once an edit produces a candidate.
204
579
  let fingerprint;
580
+ /** `candidate` parsed, and whether it means what the original document means. */
581
+ const check = (candidate) => {
582
+ const parsed = parse(candidate, config.dialect, options.path, options.plugins);
583
+ // Every document accepted so far means what the original means, so the current one serves.
584
+ fingerprint ??= semanticFingerprint(document, workspace);
585
+ return {
586
+ document: parsed,
587
+ safe: semanticFingerprint(parsed, workspace) === fingerprint,
588
+ };
589
+ };
205
590
  const seen = new Set([source]);
591
+ /** The rules whose edits changed the document in the latest pass. */
592
+ let applied = new Set();
206
593
  try {
207
594
  for (let pass = 0; pass < 8; pass++) {
208
595
  const before = output;
209
596
  const findings = [];
210
- for (const phase of ["inline", "block", "document"]) {
211
- const diagnostics = inspect(document, config, rules, options.workspace, (rule) => rule.kind === "style" && (rule.phase ?? "inline") === phase);
212
- findings.push(...diagnostics);
213
- const edits = diagnostics.flatMap((item) => (item.edit ? [item.edit] : []));
214
- if (!edits.length)
215
- continue;
216
- const candidate = applyEdits(output, edits);
217
- if (candidate === output)
218
- continue;
219
- const candidateDocument = parse(candidate, config.dialect, options.path, options.plugins);
220
- fingerprint ??= semanticFingerprint(document, options.workspace);
221
- if (semanticFingerprint(candidateDocument, options.workspace) !== fingerprint)
222
- throw new Error(`Formatting in the ${phase} phase would change parsed meaning; the document was left unchanged.`);
223
- output = candidate;
597
+ applied = new Set();
598
+ /** The rules whose edits this pass left out, with the document before and after them. */
599
+ const refused = [];
600
+ const accept = (edits, text, parsed) => {
601
+ output = text;
224
602
  // Rules consume the document without mutating it. Reuse the validated
225
603
  // tree until another phase changes its source, including final diagnostics.
226
- document = candidateDocument;
604
+ document = parsed;
605
+ if (scope)
606
+ scope = moved(scope, edits);
607
+ for (const edit of edits)
608
+ applied.add(edit.rule);
609
+ };
610
+ for (const phase of phases) {
611
+ const diagnostics = inspect(document, config, rules, hosted, (rule) => rule.kind === "style" && (rule.phase ?? "inline") === phase);
612
+ findings.push(...diagnostics);
613
+ // With ranges, an edit that reaches outside them is not applied. One
614
+ // that changes nothing need not be tried, and must not hold others back.
615
+ const proposals = [];
616
+ for (const { edit, rule } of diagnostics)
617
+ if (edit &&
618
+ (!scope || holds(scope, edit)) &&
619
+ output.slice(edit.start, edit.end) !== edit.text)
620
+ proposals.push({ start: edit.start, end: edit.end, text: edit.text, rule });
621
+ // The rules of this phase whose edits would change the meaning.
622
+ const excluded = new Set();
623
+ for (;;) {
624
+ const edits = compatible(proposals.filter((edit) => !excluded.has(edit.rule)));
625
+ if (!edits.length)
626
+ break;
627
+ const candidate = splice(output, edits);
628
+ if (candidate === output)
629
+ break;
630
+ const tried = check(candidate);
631
+ if (tried.safe) {
632
+ accept(edits, candidate, tried.document);
633
+ break;
634
+ }
635
+ // Taken only when a phase fails, so the usual path parses each candidate once.
636
+ let kept = [];
637
+ let keptOutput = output;
638
+ let keptDocument = document;
639
+ for (const rule of new Set(edits.map((edit) => edit.rule))) {
640
+ const trial = [...kept, ...edits.filter((edit) => edit.rule === rule)].sort(byPosition);
641
+ // All the edits together are already known to fail.
642
+ const whole = trial.length === edits.length;
643
+ const text = whole ? candidate : splice(output, trial);
644
+ const result = whole ? tried : check(text);
645
+ if (result.safe) {
646
+ kept = trial;
647
+ keptOutput = text;
648
+ keptDocument = result.document;
649
+ }
650
+ else {
651
+ excluded.add(rule);
652
+ refused.push({ rule, before: document, after: result.document });
653
+ }
654
+ }
655
+ if (keptOutput !== output) {
656
+ accept(kept, keptOutput, keptDocument);
657
+ break;
658
+ }
659
+ // Nothing was kept: the edits that overlapped refused ones get their turn.
660
+ }
227
661
  }
228
662
  if (output === before) {
229
663
  // No phase changed the document, so every style finding of this pass
230
664
  // describes the final document; only the non-style rules still need to run.
231
- const remaining = inspect(document, config, rules, options.workspace, (rule) => rule.kind !== "style");
665
+ const remaining = inspect(document, config, rules, hosted, (rule) => rule.kind !== "style");
666
+ // A refusal of this pass was tried on the final text, unless one
667
+ // phase undid what an earlier one did.
668
+ const starts = refused.length ? lineStarts(output) : [];
669
+ const refusals = refused.map(({ rule, before: tried, after }) => {
670
+ const start = tried.source === output ? divergence(tried, after, workspace) : 0;
671
+ const { line } = diagnostic(starts, rule, "error", { start, message: "" });
672
+ return diagnostic(starts, "engine/unsafe-format", "error", {
673
+ start,
674
+ message: `${rule} would change the parsed meaning at line ${line}; none of its edits were applied.`,
675
+ });
676
+ });
232
677
  return {
233
678
  output,
234
679
  changed: output !== source,
235
- diagnostics: [...findings, ...remaining].sort(sortDiagnostics),
680
+ diagnostics: reported([...findings, ...remaining, ...refusals]).sort(sortDiagnostics),
681
+ ...(scope ? { scope } : {}),
236
682
  };
237
683
  }
238
684
  if (seen.has(output))
239
- throw new Error("Formatting rules oscillate; the document was left unchanged.");
685
+ return unchanged(`Formatting by ${listed(applied)} oscillates; the document was left unchanged.`);
240
686
  seen.add(output);
241
687
  }
242
- throw new Error("Formatting did not converge in eight passes; the document was left unchanged.");
688
+ return unchanged(`Formatting by ${listed(applied)} did not converge in eight passes; the document was left unchanged.`);
243
689
  }
244
690
  catch (error) {
245
- return {
246
- output: source,
247
- changed: false,
248
- diagnostics: [
249
- diagnostic([0], "engine/unsafe-format", "error", {
250
- start: 0,
251
- message: error instanceof Error ? error.message : String(error),
252
- }),
253
- ],
254
- };
691
+ if (hosted.errors.has(error))
692
+ throw error;
693
+ return unchanged(error instanceof Error ? error.message : String(error));
255
694
  }
256
695
  }
257
696
  //# sourceMappingURL=engine.js.map