mdrefine 0.2.0-rc.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +577 -0
- package/CODE_OF_CONDUCT.md +30 -0
- package/CONTRIBUTING.md +189 -16
- package/README.md +100 -154
- package/SECURITY.md +15 -13
- package/config.schema.json +91 -0
- package/dist/cli/diff.d.ts +10 -0
- package/dist/cli/diff.js +247 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/main.js +270 -150
- package/dist/cli/main.js.map +1 -1
- package/dist/config/load.js +67 -6
- package/dist/config/load.js.map +1 -1
- package/dist/config/problems.d.ts +7 -0
- package/dist/config/problems.js +208 -0
- package/dist/config/problems.js.map +1 -0
- package/dist/config/resolve.d.ts +42 -109
- package/dist/config/resolve.js +193 -52
- package/dist/config/resolve.js.map +1 -1
- package/dist/core/directives.d.ts +7 -0
- package/dist/core/directives.js +25 -13
- package/dist/core/directives.js.map +1 -1
- package/dist/core/engine.d.ts +42 -4
- package/dist/core/engine.js +518 -79
- package/dist/core/engine.js.map +1 -1
- package/dist/core/frozen.d.ts +6 -0
- package/dist/core/frozen.js +15 -0
- package/dist/core/frozen.js.map +1 -0
- package/dist/core/scope.d.ts +30 -0
- package/dist/core/scope.js +122 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/core/types.d.ts +202 -23
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +3 -0
- package/dist/node.js +4 -0
- package/dist/node.js.map +1 -0
- package/dist/rules/blocks.d.ts +34 -0
- package/dist/rules/blocks.js +550 -0
- package/dist/rules/blocks.js.map +1 -0
- package/dist/rules/content.d.ts +2 -0
- package/dist/rules/content.js +85 -0
- package/dist/rules/content.js.map +1 -0
- package/dist/rules/dialects.js +10 -6
- package/dist/rules/dialects.js.map +1 -1
- package/dist/rules/links.js +302 -6
- package/dist/rules/links.js.map +1 -1
- package/dist/rules/lists.d.ts +2 -0
- package/dist/rules/lists.js +924 -0
- package/dist/rules/lists.js.map +1 -0
- package/dist/rules/structure.js +127 -1
- package/dist/rules/structure.js.map +1 -1
- package/dist/rules/style.d.ts +8 -2
- package/dist/rules/style.js +1137 -165
- package/dist/rules/style.js.map +1 -1
- package/dist/syntax/block-identifier.d.ts +18 -0
- package/dist/syntax/block-identifier.js +24 -0
- package/dist/syntax/block-identifier.js.map +1 -0
- package/dist/syntax/obsidian.d.ts +29 -11
- package/dist/syntax/obsidian.js +9 -10
- package/dist/syntax/obsidian.js.map +1 -1
- package/dist/syntax/parse.d.ts +87 -3
- package/dist/syntax/parse.js +247 -19
- package/dist/syntax/parse.js.map +1 -1
- package/dist/workspace/access.d.ts +12 -3
- package/dist/workspace/access.js +33 -5
- package/dist/workspace/access.js.map +1 -1
- package/dist/workspace/discovery.d.ts +85 -0
- package/dist/workspace/discovery.js +155 -0
- package/dist/workspace/discovery.js.map +1 -0
- package/dist/workspace/encoding.d.ts +24 -0
- package/dist/workspace/encoding.js +113 -0
- package/dist/workspace/encoding.js.map +1 -0
- package/dist/workspace/files.d.ts +92 -6
- package/dist/workspace/files.js +715 -59
- package/dist/workspace/files.js.map +1 -1
- package/dist/workspace/git.d.ts +54 -0
- package/dist/workspace/git.js +198 -0
- package/dist/workspace/git.js.map +1 -0
- package/dist/workspace/index.d.ts +58 -3
- package/dist/workspace/index.js +136 -37
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/selection.d.ts +2 -2
- package/dist/workspace/selection.js +3 -3
- package/dist/workspace/selection.js.map +1 -1
- package/dist/workspace/slug.js +8 -1
- package/dist/workspace/slug.js.map +1 -1
- package/dist/workspace/vault.js +3 -1
- package/dist/workspace/vault.js.map +1 -1
- package/docs/api.md +239 -0
- package/docs/benchmarks.md +140 -200
- package/docs/changelog.md +1 -0
- package/docs/cli.md +281 -89
- package/docs/configuration.md +193 -480
- package/docs/design.md +163 -284
- package/docs/dialects.md +169 -0
- package/docs/examples.md +151 -109
- package/docs/index.md +21 -37
- package/docs/integrations.md +144 -0
- package/docs/obsidian.md +91 -115
- package/docs/plugins.md +147 -212
- package/docs/quick-start.md +68 -74
- package/docs/rules.md +1237 -0
- package/docs/versions.md +56 -0
- package/examples/README.md +17 -14
- package/examples/automation/summarize-report.mjs +5 -2
- package/examples/library/lint-folder.mjs +5 -4
- package/examples/library/lint-workspace.mjs +32 -0
- package/examples/plugin/notes/release-checklist.md +2 -2
- package/examples/plugin/rules.mjs +17 -23
- package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
- package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
- package/examples/workspaces/repository-docs/after/README.md +2 -2
- package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
- package/examples/workspaces/repository-docs/before/README.md +2 -2
- package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
- package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
- package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
- package/package.json +38 -12
- package/docs/contributing.md +0 -218
- package/docs/releases/0.1.0-beta.1.md +0 -39
- package/docs/releases/0.1.0-beta.2.md +0 -83
- package/docs/releases/0.1.0-beta.3.md +0 -61
- package/docs/releases/0.1.0-beta.4.md +0 -50
- package/docs/releases/0.1.0-rc.1.md +0 -99
- package/docs/releases/0.2.0-rc.1.md +0 -141
- package/docs/releases/0.2.0-rc.2.md +0 -225
- package/docs/releases.md +0 -144
- package/docs/testing.md +0 -93
package/dist/core/engine.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
if (!
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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}: ${
|
|
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 (
|
|
60
|
-
starts.push(
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
169
|
-
if (
|
|
170
|
-
// CommonMark merges
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
685
|
+
return unchanged(`Formatting by ${listed(applied)} oscillates; the document was left unchanged.`);
|
|
240
686
|
seen.add(output);
|
|
241
687
|
}
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|