@jpeakai/prose-gates 0.1.1 → 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/README.md +37 -4
- package/dist/bin.js +8472 -539
- package/dist/index.js +8500 -540
- package/dist/testing.js +18321 -0
- package/examples/plugin-skills/README.md +60 -0
- package/examples/plugin-skills/index.mjs +14 -0
- package/examples/plugin-skills/package.json +12 -0
- package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
- package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
- package/examples/plugin-skills-typescript/README.md +42 -0
- package/examples/plugin-skills-typescript/index.ts +21 -0
- package/examples/plugin-skills-typescript/package.json +12 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
- package/examples/plugin-skills-typescript/tsconfig.json +14 -0
- package/package.json +10 -3
- package/src/bin.ts +2 -2
- package/src/check.ts +40 -13
- package/src/cli.ts +197 -28
- package/src/config.ts +119 -0
- package/src/errors.ts +191 -0
- package/src/fix/engine.ts +31 -9
- package/src/fix/promote.ts +14 -2
- package/src/fix/verify.ts +29 -0
- package/src/index.ts +38 -2
- package/src/interpunct.ts +48 -0
- package/src/model.ts +129 -12
- package/src/plugins/contract.ts +41 -0
- package/src/plugins/discover.ts +140 -0
- package/src/plugins/index.ts +103 -0
- package/src/plugins/load.ts +234 -0
- package/src/plugins/runtime.ts +15 -0
- package/src/project.ts +81 -0
- package/src/query.ts +131 -0
- package/src/rules/index.ts +31 -12
- package/src/rules/list-comma-labelled-run.ts +101 -0
- package/src/rules/list-inline-enumeration-markers.ts +127 -0
- package/src/rules/list-interpunct-joined-run.ts +68 -0
- package/src/rules/list-semicolon-delimited-run.ts +60 -0
- package/src/rules/list-stacked-interpunct-runs.ts +80 -0
- package/src/rules/punctuation-em-dash-in-prose.ts +155 -0
- package/src/rules/punctuation-interpunct-in-prose.ts +52 -0
- package/src/rules/registry.ts +133 -0
- package/src/rules/sentence-one-per-line.ts +99 -0
- package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +22 -20
- package/src/rules/types.ts +70 -25
- package/src/testing.ts +60 -0
- package/src/text.ts +50 -0
- package/src/fix/spans.ts +0 -75
- package/src/rules/interpunct.ts +0 -19
- package/src/rules/pg001-wrap.ts +0 -79
- package/src/rules/pg003-semicolon-list.ts +0 -68
- package/src/rules/pg004-em-dash.ts +0 -117
- package/src/rules/pg005-interpunct.ts +0 -43
- package/src/rules/pg006-interpunct-run.ts +0 -84
- package/src/rules/pg007-inline-enum.ts +0 -119
- package/src/rules/pg008-labelled-run.ts +0 -94
- package/src/rules/pg009-stacked-runs.ts +0 -79
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// sentence-one-per-line: a newline inside a paragraph whose preceding non-space character
|
|
2
|
+
// does not end a sentence is a mid-sentence wrap. The fix reflows the
|
|
3
|
+
// paragraph to one sentence per line.
|
|
4
|
+
|
|
5
|
+
import { isStacked, paragraphsOf } from "../interpunct.ts";
|
|
6
|
+
import type { ParagraphView } from "../model.ts";
|
|
7
|
+
import { proseParagraphs } from "../query.ts";
|
|
8
|
+
import { collapse, inRanges } from "../text.ts";
|
|
9
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
// A line break after one of these ends a sentence, so it is authored layout.
|
|
12
|
+
const TERMINALS = new Set([".", "!", "?", ":", ";"]);
|
|
13
|
+
|
|
14
|
+
// What may sit between the terminal and the break: trailing space, a carriage
|
|
15
|
+
// return, or the backslash form of a hard break.
|
|
16
|
+
const TRAILING = new Set([" ", "\t", "\r", "\\"]);
|
|
17
|
+
|
|
18
|
+
// A sentence ends at a terminal followed by space, and the next one opens with
|
|
19
|
+
// a capital or an opening delimiter. Anything else is an abbreviation.
|
|
20
|
+
const SENTENCE_BREAK = /[.!?]\s+(?=[A-Z`"'([])/g;
|
|
21
|
+
|
|
22
|
+
// Offsets within the paragraph where a line break interrupts a sentence.
|
|
23
|
+
const wrapsIn = (view: ParagraphView): number[] => {
|
|
24
|
+
const wraps: number[] = [];
|
|
25
|
+
for (let i = 0; i < view.raw.length; i++) {
|
|
26
|
+
if (view.raw[i] !== "\n") continue;
|
|
27
|
+
if (inRanges(view.startOffset + i, view.codeRanges)) continue;
|
|
28
|
+
let j = i - 1;
|
|
29
|
+
while (j >= 0 && TRAILING.has(view.raw.charAt(j))) j--;
|
|
30
|
+
if (j >= 0 && !TERMINALS.has(view.raw.charAt(j))) wraps.push(i);
|
|
31
|
+
}
|
|
32
|
+
return wraps;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The paragraph rewritten with one sentence per line. Code spans are masked
|
|
36
|
+
// character for character before the split, keeping whitespace, so offsets
|
|
37
|
+
// stay aligned and a terminal inside a code span never starts a new line.
|
|
38
|
+
const reflow = (view: ParagraphView): string => {
|
|
39
|
+
const indent = " ".repeat(view.startColumn - 1);
|
|
40
|
+
const eol = view.raw.includes("\r\n") ? "\r\n" : "\n";
|
|
41
|
+
const masked = [...view.raw]
|
|
42
|
+
.map((ch, i) => (inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch))
|
|
43
|
+
.join("");
|
|
44
|
+
const joined = collapse(view.raw);
|
|
45
|
+
const joinedMask = collapse(masked);
|
|
46
|
+
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
let from = 0;
|
|
49
|
+
for (const match of joinedMask.matchAll(SENTENCE_BREAK)) {
|
|
50
|
+
const at = match.index ?? 0;
|
|
51
|
+
lines.push(joined.slice(from, at + 1));
|
|
52
|
+
from = at + match[0].length;
|
|
53
|
+
}
|
|
54
|
+
lines.push(joined.slice(from));
|
|
55
|
+
return lines.map((line, i) => (i === 0 ? line.trim() : indent + line.trim())).join(eol);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
59
|
+
proseParagraphs(doc).flatMap((view) =>
|
|
60
|
+
wrapsIn(view).map((at) => ({
|
|
61
|
+
file,
|
|
62
|
+
line: view.line + view.raw.slice(0, at).split("\n").length - 1,
|
|
63
|
+
rule: RULE.WRAP,
|
|
64
|
+
message: "mid-sentence line wrap; write one sentence per line",
|
|
65
|
+
})),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const fix: Rule["fix"] = ({ doc, runs }) => {
|
|
69
|
+
const edits: Edit[] = [];
|
|
70
|
+
// Interpunct runs stacked on several lines are a nested list list-stacked-interpunct-runs either
|
|
71
|
+
// promotes or leaves for a human. Joining the lines would destroy the
|
|
72
|
+
// structure and hand list-interpunct-joined-run a flat run it would merge wrongly.
|
|
73
|
+
const stacked = new Set(paragraphsOf(doc, runs.filter(isStacked)));
|
|
74
|
+
for (const view of proseParagraphs(doc)) {
|
|
75
|
+
// A hard break is structure the author chose, so the paragraph is left
|
|
76
|
+
// exactly as written.
|
|
77
|
+
if (view.inBlockquote || view.hasBreak) continue;
|
|
78
|
+
if (stacked.has(view)) continue;
|
|
79
|
+
|
|
80
|
+
const text = reflow(view);
|
|
81
|
+
if (text === view.raw) continue;
|
|
82
|
+
edits.push({
|
|
83
|
+
rule: RULE.WRAP,
|
|
84
|
+
start: view.startOffset,
|
|
85
|
+
end: view.endOffset,
|
|
86
|
+
text,
|
|
87
|
+
expect: { kind: "same-tree" },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return edits;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const sentenceOnePerLine: Rule = {
|
|
94
|
+
id: RULE.WRAP,
|
|
95
|
+
category: "sentence",
|
|
96
|
+
summary: "mid-sentence line wrap (one sentence per line)",
|
|
97
|
+
check,
|
|
98
|
+
fix,
|
|
99
|
+
};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// sentence-word-budget-exceeded: sentences over the word budget are hard to unpack, especially for
|
|
2
2
|
// ESL readers and translators. Never autofixed: shortening a sentence is a
|
|
3
3
|
// rewrite, and a rewrite needs discretion. The message estimates the clauses
|
|
4
4
|
// in the sentence so the rewrite splits it rather than compressing it.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { proseSentences } from "../query.ts";
|
|
7
|
+
import { words } from "../text.ts";
|
|
8
|
+
import { RULE, type Rule } from "./types.ts";
|
|
8
9
|
|
|
9
10
|
// A clause boundary is a semicolon or colon with text after it, a comma
|
|
10
11
|
// before a coordinating conjunction, or a subordinating or relative word.
|
|
@@ -15,30 +16,31 @@ const CLAUSE_BOUNDARY =
|
|
|
15
16
|
|
|
16
17
|
export const countClauses = (sentence: string): number => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
|
|
17
18
|
|
|
18
|
-
const excerpt = (
|
|
19
|
+
const excerpt = (sentence: string): string => (sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence);
|
|
19
20
|
|
|
20
21
|
export const lengthMessage = (sentence: string, maxWords: number): string => {
|
|
21
|
-
const
|
|
22
|
+
const count = words(sentence);
|
|
22
23
|
const clauses = countClauses(sentence);
|
|
23
|
-
const target = Math.max(2, clauses, Math.ceil(
|
|
24
|
+
const target = Math.max(2, clauses, Math.ceil(count / maxWords));
|
|
24
25
|
const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
|
|
25
|
-
return `sentence has ${
|
|
26
|
+
return `sentence has ${count} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
const check: Rule["check"] = ({ doc, file, maxWords }) =>
|
|
30
|
+
proseSentences(doc)
|
|
31
|
+
.filter(({ sentence }) => words(sentence) > maxWords)
|
|
32
|
+
.map(({ view, sentence }) => ({
|
|
33
|
+
file,
|
|
34
|
+
line: view.line,
|
|
35
|
+
rule: RULE.LENGTH,
|
|
36
|
+
message: lengthMessage(sentence, maxWords),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// No fixer: shortening a sentence changes its words, per PRS-0006.
|
|
40
|
+
export const sentenceWordBudgetExceeded: Rule = {
|
|
29
41
|
id: RULE.LENGTH,
|
|
30
42
|
category: "sentence",
|
|
31
43
|
summary: "sentence longer than the word budget (default 25)",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
for (const p of doc.paragraphs) {
|
|
35
|
-
if (p.inTable) continue;
|
|
36
|
-
for (const s of sentences(p.prose)) {
|
|
37
|
-
if (s.split(/\s+/).length > maxWords) {
|
|
38
|
-
findings.push({ file, line: p.line, rule: RULE.LENGTH, message: lengthMessage(s, maxWords) });
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return findings;
|
|
43
|
-
},
|
|
44
|
+
options: { maxWords: "number" },
|
|
45
|
+
check,
|
|
44
46
|
};
|
package/src/rules/types.ts
CHANGED
|
@@ -2,21 +2,34 @@
|
|
|
2
2
|
// optional fixer that proposes edits. The fix engine, not the fixer, decides
|
|
3
3
|
// whether an edit is safe to keep.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { InterpunctRun } from "../interpunct.ts";
|
|
6
|
+
import type { DocModel, ParagraphView } from "../model.ts";
|
|
6
7
|
|
|
7
8
|
export const RULE = {
|
|
8
|
-
WRAP: "
|
|
9
|
-
LENGTH: "
|
|
10
|
-
SEMICOLON_LIST: "
|
|
11
|
-
EM_DASH: "
|
|
12
|
-
INTERPUNCT: "
|
|
13
|
-
INTERPUNCT_RUN: "
|
|
14
|
-
INLINE_ENUM: "
|
|
15
|
-
LABELLED_RUN: "
|
|
16
|
-
STACKED_RUNS: "
|
|
9
|
+
WRAP: "sentence-one-per-line",
|
|
10
|
+
LENGTH: "sentence-word-budget-exceeded",
|
|
11
|
+
SEMICOLON_LIST: "list-semicolon-delimited-run",
|
|
12
|
+
EM_DASH: "punctuation-em-dash-in-prose",
|
|
13
|
+
INTERPUNCT: "punctuation-interpunct-in-prose",
|
|
14
|
+
INTERPUNCT_RUN: "list-interpunct-joined-run",
|
|
15
|
+
INLINE_ENUM: "list-inline-enumeration-markers",
|
|
16
|
+
LABELLED_RUN: "list-comma-labelled-run",
|
|
17
|
+
STACKED_RUNS: "list-stacked-interpunct-runs",
|
|
17
18
|
} as const;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// A core id has no slash. A plugin id is always namespace/name, so the slash
|
|
21
|
+
// is what tells the two apart.
|
|
22
|
+
export type CoreRuleId = (typeof RULE)[keyof typeof RULE];
|
|
23
|
+
|
|
24
|
+
// A rule function may be synchronous or return a promise, so a rule can call a tool
|
|
25
|
+
// or a service. The engine awaits every call.
|
|
26
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
27
|
+
|
|
28
|
+
export type PluginRuleId = `${string}/${string}`;
|
|
29
|
+
|
|
30
|
+
export const isPluginRuleId = (id: string): id is PluginRuleId => id.includes("/");
|
|
31
|
+
|
|
32
|
+
export type RuleId = CoreRuleId | PluginRuleId;
|
|
20
33
|
|
|
21
34
|
export interface Finding {
|
|
22
35
|
file: string;
|
|
@@ -25,20 +38,46 @@ export interface Finding {
|
|
|
25
38
|
message: string;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
//
|
|
29
|
-
// Shared
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
// The data every rule shares, computed once per document and handed to it.
|
|
42
|
+
// Shared data is passed in rather than recomputed, so a check and a fix can
|
|
43
|
+
// never disagree about what the document holds. `runs` is every interpunct
|
|
44
|
+
// run. `reported` is the runs some enabled rule owns, so a rule that defers
|
|
45
|
+
// to another stops deferring when that rule is switched off.
|
|
46
|
+
export interface RuleContext {
|
|
47
|
+
doc: DocModel;
|
|
48
|
+
runs: InterpunctRun[];
|
|
49
|
+
reported: InterpunctRun[];
|
|
35
50
|
}
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
// What a rule may be configured with. A rule names each option it accepts
|
|
53
|
+
// and the type of its value, and the config is checked against that.
|
|
54
|
+
export type OptionType = "number" | "string" | "boolean" | "string[]";
|
|
55
|
+
|
|
56
|
+
export type OptionSpec = Readonly<Record<string, OptionType>>;
|
|
57
|
+
|
|
58
|
+
export type RuleOptions = Readonly<Record<string, unknown>>;
|
|
59
|
+
|
|
60
|
+
// Plain functions a plugin can use without importing this package, so a plugin
|
|
61
|
+
// never depends on resolving the host from its own location.
|
|
62
|
+
export interface TextHelpers {
|
|
63
|
+
words: (text: string) => number;
|
|
64
|
+
sentences: (text: string) => string[];
|
|
65
|
+
lengthMessage: (sentence: string, maxWords: number) => string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A check also names the file it reports against, and the sentence budget.
|
|
69
|
+
export interface CheckContext extends RuleContext {
|
|
39
70
|
file: string;
|
|
40
71
|
maxWords: number;
|
|
41
|
-
|
|
72
|
+
options: RuleOptions;
|
|
73
|
+
helpers: TextHelpers;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A fix needs nothing beyond the shared context and its own options. It
|
|
77
|
+
// proposes edits, and the engine alone decides which of them survive.
|
|
78
|
+
export interface FixContext extends RuleContext {
|
|
79
|
+
options: RuleOptions;
|
|
80
|
+
helpers: TextHelpers;
|
|
42
81
|
}
|
|
43
82
|
|
|
44
83
|
// What the engine must be able to prove about the document after an edit.
|
|
@@ -47,6 +86,9 @@ export type Expectation =
|
|
|
47
86
|
| { kind: "same-tree" }
|
|
48
87
|
// Glyphs swapped inside text: the tree keeps its shape and every word.
|
|
49
88
|
| { kind: "same-shape" }
|
|
89
|
+
// The frontmatter is written differently and means the same: the YAML parses to
|
|
90
|
+
// identical data before and after, and nothing outside it moves.
|
|
91
|
+
| { kind: "same-frontmatter-data" }
|
|
50
92
|
// One paragraph becomes the blocks parsed from `fragment`, and nothing else
|
|
51
93
|
// in the document moves.
|
|
52
94
|
| { kind: "replace-paragraph"; view: ParagraphView; fragment: string; listItems: number };
|
|
@@ -63,15 +105,18 @@ export interface Edit {
|
|
|
63
105
|
// sentence: how a sentence is laid out and how long it runs.
|
|
64
106
|
// list: a list hidden in running prose, promoted to a real markdown list.
|
|
65
107
|
// punctuation: a glyph that reads as generated text.
|
|
108
|
+
// A plugin may add a category of its own by declaring it.
|
|
66
109
|
export const CATEGORIES = ["sentence", "list", "punctuation"] as const;
|
|
67
110
|
|
|
68
111
|
export type Category = (typeof CATEGORIES)[number];
|
|
69
112
|
|
|
70
113
|
export interface Rule {
|
|
71
114
|
id: RuleId;
|
|
72
|
-
category:
|
|
115
|
+
category: string;
|
|
73
116
|
summary: string;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
117
|
+
// The options the rule accepts. Absent when it takes none.
|
|
118
|
+
options?: OptionSpec;
|
|
119
|
+
check: (ctx: CheckContext) => Awaitable<Finding[]>;
|
|
120
|
+
// Absent for rules whose fix needs discretion (sentence-word-budget-exceeded).
|
|
121
|
+
fix?: (ctx: FixContext) => Awaitable<Edit[]>;
|
|
77
122
|
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A test helper for plugin authors: run one plugin rule over real strings, with
|
|
2
|
+
// the same loader checks, the same context and the same verified fix engine as
|
|
3
|
+
// a real run. It is a separate entry point so a plugin's tests import it and
|
|
4
|
+
// production code never does.
|
|
5
|
+
|
|
6
|
+
import { checkMarkdown } from "./check.ts";
|
|
7
|
+
import { type FixReport, fixMarkdownReport } from "./fix/engine.ts";
|
|
8
|
+
import type { Plugin, PluginRule } from "./plugins/contract.ts";
|
|
9
|
+
import { parsePlugin, toRule } from "./plugins/load.ts";
|
|
10
|
+
import { BUILTIN_CATEGORIES, buildRegistry, type Registry } from "./rules/registry.ts";
|
|
11
|
+
import type { Finding, RuleOptions } from "./rules/types.ts";
|
|
12
|
+
|
|
13
|
+
export interface RunOptions {
|
|
14
|
+
// The rule's own options, as a config would give them.
|
|
15
|
+
options?: RuleOptions;
|
|
16
|
+
// The sentence budget, as --max-words would give it.
|
|
17
|
+
maxWords?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Wraps a local rule module, with its optional `categories` export, as the
|
|
21
|
+
// plugin the loader would build from the file under the `local` namespace.
|
|
22
|
+
export const localPlugin = (key: string, mod: { default: PluginRule; categories?: Record<string, string> }): Plugin =>
|
|
23
|
+
parsePlugin(
|
|
24
|
+
"local",
|
|
25
|
+
{
|
|
26
|
+
meta: { name: "local", namespace: "local", apiVersion: 1, categories: mod.categories },
|
|
27
|
+
rules: { [key]: mod.default },
|
|
28
|
+
},
|
|
29
|
+
new Set(),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const registryFor = (plugin: Plugin, key: string, options: RuleOptions): Registry => {
|
|
33
|
+
const checked = parsePlugin(plugin.meta.name, plugin, new Set());
|
|
34
|
+
const rule = checked.rules[key];
|
|
35
|
+
if (!rule)
|
|
36
|
+
throw new Error(
|
|
37
|
+
`plugin "${plugin.meta.name}" has no rule "${key}"; it has ${Object.keys(checked.rules).join(", ")}`,
|
|
38
|
+
);
|
|
39
|
+
const built = toRule(checked.meta.namespace, key, rule);
|
|
40
|
+
return buildRegistry({
|
|
41
|
+
rules: [built],
|
|
42
|
+
fixOrder: built.fix ? [built] : [],
|
|
43
|
+
categories: new Map([...BUILTIN_CATEGORIES, ...Object.entries(checked.meta.categories ?? {})]),
|
|
44
|
+
config: {
|
|
45
|
+
plugins: [],
|
|
46
|
+
pluginLoading: "strict",
|
|
47
|
+
file: null,
|
|
48
|
+
rules: new Map([[built.id, { severity: "error", options }]]),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The findings one rule of the plugin reports for a document.
|
|
54
|
+
export const checkRule = async (plugin: Plugin, key: string, src: string, run: RunOptions = {}): Promise<Finding[]> =>
|
|
55
|
+
checkMarkdown(src, "test.md", run.maxWords, registryFor(plugin, key, run.options ?? {}));
|
|
56
|
+
|
|
57
|
+
// The document after the rule's fixer has run to a fixpoint, and what was
|
|
58
|
+
// applied or refused. A fixer that loses a word throws, exactly as in a real run.
|
|
59
|
+
export const fixRule = async (plugin: Plugin, key: string, src: string, run: RunOptions = {}): Promise<FixReport> =>
|
|
60
|
+
fixMarkdownReport(src, registryFor(plugin, key, run.options ?? {}));
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Text and range arithmetic that knows nothing about markdown. Everything
|
|
2
|
+
// here is a pure function of its arguments, so it is the one layer that can
|
|
3
|
+
// be read without the document model in mind.
|
|
4
|
+
|
|
5
|
+
// A half-open span of absolute offsets into a source string. Every range in
|
|
6
|
+
// this package is [start, end), and is only valid for the source it was
|
|
7
|
+
// computed from.
|
|
8
|
+
export type Range = [number, number];
|
|
9
|
+
|
|
10
|
+
// The ends of a list the caller has already proved non-empty. Written once
|
|
11
|
+
// here so `noUncheckedIndexedAccess` costs the package two casts rather than
|
|
12
|
+
// one per rule.
|
|
13
|
+
export const first = <T>(xs: readonly T[]): T => xs[0] as T;
|
|
14
|
+
|
|
15
|
+
export const last = <T>(xs: readonly T[]): T => xs[xs.length - 1] as T;
|
|
16
|
+
|
|
17
|
+
export const within = (r: Range, span: Range): boolean => r[0] >= span[0] && r[1] <= span[1];
|
|
18
|
+
|
|
19
|
+
// Soft line breaks collapse to single spaces; nothing else in the slice moves.
|
|
20
|
+
export const collapse = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
|
|
21
|
+
|
|
22
|
+
// How many times a pattern matches. The pattern must be global: matchAll
|
|
23
|
+
// throws otherwise, which is the loud failure we want over a silent count
|
|
24
|
+
// of one.
|
|
25
|
+
export const countOf = (text: string, pattern: RegExp): number => [...text.matchAll(pattern)].length;
|
|
26
|
+
|
|
27
|
+
// The spans a run of cut points carves out of a sentence. Each span runs from
|
|
28
|
+
// the end of its own cut to the start of the next one, and the last runs to
|
|
29
|
+
// `end`. Every hidden-list fixer needs exactly this, so the fencepost is
|
|
30
|
+
// written once here rather than once per rule.
|
|
31
|
+
export const spansBetween = (cuts: Range[], end: number): Range[] =>
|
|
32
|
+
cuts.map((cut, i) => {
|
|
33
|
+
const next = cuts[i + 1];
|
|
34
|
+
return [cut[1], next ? next[0] : end];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Words of a sentence or an item, counted the one way for every rule.
|
|
38
|
+
export const words = (text: string): number => text.split(/\s+/).filter(Boolean).length;
|
|
39
|
+
|
|
40
|
+
// Whether an offset falls inside any of the ranges.
|
|
41
|
+
export const inRanges = (offset: number, ranges: Range[]): boolean =>
|
|
42
|
+
ranges.some(([a, b]) => offset >= a && offset < b);
|
|
43
|
+
|
|
44
|
+
// Prose split on terminals. A check reads sentences this way; a fixer needs
|
|
45
|
+
// the source offsets instead and asks `sentenceSpans` for them.
|
|
46
|
+
export const sentences = (text: string): string[] =>
|
|
47
|
+
text
|
|
48
|
+
.split(/(?<=[.!?])\s+/)
|
|
49
|
+
.map((s) => s.trim())
|
|
50
|
+
.filter((s) => s.length > 0);
|
package/src/fix/spans.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
// Source-offset helpers shared by fixers. Everything here works on absolute
|
|
2
|
-
// offsets into the document source, and only ever looks inside text the
|
|
3
|
-
// parser proved is prose.
|
|
4
|
-
|
|
5
|
-
import type { DirectText, ParagraphView, Range } from "../model.ts";
|
|
6
|
-
|
|
7
|
-
// Absolute offsets of every match of `pattern` inside a text node, or null
|
|
8
|
-
// when the source slice and the parsed value disagree (an entity like
|
|
9
|
-
// `·` or an escape). A fixer that cannot map a glyph back to the
|
|
10
|
-
// source refuses rather than guesses.
|
|
11
|
-
export const matchesIn = (src: string, text: DirectText, pattern: RegExp): Range[] | null => {
|
|
12
|
-
const slice = src.slice(text.start, text.end);
|
|
13
|
-
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
14
|
-
const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
|
|
15
|
-
const inValue = [...text.value.matchAll(new RegExp(pattern.source, flags))];
|
|
16
|
-
if (inSlice.length !== inValue.length) return null;
|
|
17
|
-
return inSlice.map((m) => [text.start + (m.index ?? 0), text.start + (m.index ?? 0) + m[0].length]);
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export const matchesInAll = (src: string, texts: DirectText[], pattern: RegExp): Range[] | null => {
|
|
21
|
-
const out: Range[] = [];
|
|
22
|
-
for (const t of texts) {
|
|
23
|
-
const found = matchesIn(src, t, pattern);
|
|
24
|
-
if (found === null) return null;
|
|
25
|
-
out.push(...found);
|
|
26
|
-
}
|
|
27
|
-
return out;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// Sentence boundaries a fixer trusts: a terminal followed by whitespace,
|
|
31
|
-
// inside a direct text node. Terminals inside links, emphasis or code never
|
|
32
|
-
// split a sentence. Each boundary is [end of terminal, start of next sentence].
|
|
33
|
-
const boundaries = (src: string, view: ParagraphView): Range[] => {
|
|
34
|
-
const out: Range[] = [];
|
|
35
|
-
for (const t of view.directTexts) {
|
|
36
|
-
const slice = src.slice(t.start, t.end);
|
|
37
|
-
for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
|
|
38
|
-
const at = t.start + (m.index ?? 0);
|
|
39
|
-
out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return out.sort((a, b) => a[0] - b[0]);
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// The sentence span that covers [from, to): from the start of the sentence
|
|
46
|
-
// holding `from` to the end of the sentence holding `to`.
|
|
47
|
-
export const sentenceSpan = (src: string, view: ParagraphView, from: number, to: number): Range => {
|
|
48
|
-
let start = view.startOffset;
|
|
49
|
-
let end = view.endOffset;
|
|
50
|
-
for (const [terminalEnd, nextStart] of boundaries(src, view)) {
|
|
51
|
-
if (nextStart <= from) start = nextStart;
|
|
52
|
-
if (terminalEnd >= to && end === view.endOffset) end = terminalEnd;
|
|
53
|
-
}
|
|
54
|
-
return [start, end];
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// Every sentence span of a paragraph, in order.
|
|
58
|
-
export const sentenceSpans = (src: string, view: ParagraphView): Range[] => {
|
|
59
|
-
const spans: Range[] = [];
|
|
60
|
-
let start = view.startOffset;
|
|
61
|
-
for (const [terminalEnd, nextStart] of boundaries(src, view)) {
|
|
62
|
-
spans.push([start, terminalEnd]);
|
|
63
|
-
start = nextStart;
|
|
64
|
-
}
|
|
65
|
-
spans.push([start, view.endOffset]);
|
|
66
|
-
return spans;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export const within = (r: Range, span: Range): boolean => r[0] >= span[0] && r[1] <= span[1];
|
|
70
|
-
|
|
71
|
-
// Soft line breaks collapse to single spaces; nothing else in the slice moves.
|
|
72
|
-
export const collapse = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
|
|
73
|
-
|
|
74
|
-
// A glyph the author escaped is literal text, never a separator to rewrite.
|
|
75
|
-
export const escaped = (src: string, offset: number): boolean => src[offset - 1] === "\\";
|
package/src/rules/interpunct.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
// Paragraphs whose prose joins a run of items with 2+ interpunct separators.
|
|
2
|
-
// PG006 and PG009 report the run, and PG005 stays quiet inside one, so the
|
|
3
|
-
// runs are computed once and passed to every check as data.
|
|
4
|
-
|
|
5
|
-
import type { DocModel } from "../model.ts";
|
|
6
|
-
import type { InterpunctRun } from "./types.ts";
|
|
7
|
-
|
|
8
|
-
export const interpunctRuns = (doc: DocModel): InterpunctRun[] => {
|
|
9
|
-
const runs: InterpunctRun[] = [];
|
|
10
|
-
for (const p of doc.paragraphs) {
|
|
11
|
-
if (p.inTable) continue;
|
|
12
|
-
const separators = (p.prose.match(/·/g) ?? []).length;
|
|
13
|
-
if (separators >= 2) {
|
|
14
|
-
const lines = p.raw.split("\n").filter((l) => l.includes("·")).length;
|
|
15
|
-
runs.push({ range: [p.startOffset, p.endOffset], line: p.line, separators, lines });
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return runs;
|
|
19
|
-
};
|
package/src/rules/pg001-wrap.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// PG001: a newline inside a paragraph whose preceding non-space character
|
|
2
|
-
// does not end a sentence is a mid-sentence wrap. The fix reflows the
|
|
3
|
-
// paragraph to one sentence per line.
|
|
4
|
-
|
|
5
|
-
import { type DocModel, inRanges } from "../model.ts";
|
|
6
|
-
import { interpunctRuns } from "./interpunct.ts";
|
|
7
|
-
import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
|
|
8
|
-
|
|
9
|
-
const TERMINALS = new Set([".", "!", "?", ":", ";"]);
|
|
10
|
-
|
|
11
|
-
const check: Rule["check"] = ({ doc, file }) => {
|
|
12
|
-
const findings: Finding[] = [];
|
|
13
|
-
for (const p of doc.paragraphs) {
|
|
14
|
-
if (p.inTable) continue;
|
|
15
|
-
for (let i = 0; i < p.raw.length; i++) {
|
|
16
|
-
if (p.raw[i] !== "\n") continue;
|
|
17
|
-
if (inRanges(p.startOffset + i, p.codeRanges)) continue;
|
|
18
|
-
let j = i - 1;
|
|
19
|
-
while (j >= 0 && (p.raw[j] === " " || p.raw[j] === "\t" || p.raw[j] === "\r" || p.raw[j] === "\\")) j--;
|
|
20
|
-
if (j >= 0 && !TERMINALS.has(p.raw[j] as string)) {
|
|
21
|
-
const line = p.line + p.raw.slice(0, i).split("\n").length - 1;
|
|
22
|
-
findings.push({ file, line, rule: RULE.WRAP, message: "mid-sentence line wrap; write one sentence per line" });
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return findings;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const fix = (doc: DocModel): Edit[] => {
|
|
30
|
-
const edits: Edit[] = [];
|
|
31
|
-
// Interpunct runs stacked on several lines are a nested list PG009 either
|
|
32
|
-
// promotes or leaves for a human. Joining the lines would destroy the
|
|
33
|
-
// structure and hand PG006 a flat run it would merge wrongly.
|
|
34
|
-
const stacked = interpunctRuns(doc)
|
|
35
|
-
.filter((r) => r.lines >= 2)
|
|
36
|
-
.map((r) => r.range[0]);
|
|
37
|
-
for (const p of doc.paragraphs) {
|
|
38
|
-
// A hard break is structure the author chose, so the paragraph is left
|
|
39
|
-
// exactly as written.
|
|
40
|
-
if (p.inTable || p.inBlockquote || p.hasBreak || stacked.includes(p.startOffset)) continue;
|
|
41
|
-
|
|
42
|
-
// Continuation lines keep the paragraph's own indent column, and the
|
|
43
|
-
// paragraph's own line ending.
|
|
44
|
-
const indent = " ".repeat(p.startColumn - 1);
|
|
45
|
-
const eol = p.raw.includes("\r\n") ? "\r\n" : "\n";
|
|
46
|
-
|
|
47
|
-
// Mask code spans character for character, keeping whitespace, so the
|
|
48
|
-
// same join applies to both strings and offsets stay aligned. A terminal
|
|
49
|
-
// inside a code span then never splits a sentence.
|
|
50
|
-
const masked = [...p.raw]
|
|
51
|
-
.map((ch, i) => (inRanges(p.startOffset + i, p.codeRanges) && !/\s/.test(ch) ? "x" : ch))
|
|
52
|
-
.join("");
|
|
53
|
-
const join = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
|
|
54
|
-
const joined = join(p.raw);
|
|
55
|
-
const joinedMask = join(masked);
|
|
56
|
-
|
|
57
|
-
const out: string[] = [];
|
|
58
|
-
let last = 0;
|
|
59
|
-
for (const m of joinedMask.matchAll(/[.!?]\s+(?=[A-Z`"'([])/g)) {
|
|
60
|
-
const at = m.index ?? 0;
|
|
61
|
-
out.push(joined.slice(last, at + 1));
|
|
62
|
-
last = at + m[0].length;
|
|
63
|
-
}
|
|
64
|
-
out.push(joined.slice(last));
|
|
65
|
-
const text = out.map((s, i) => (i === 0 ? s.trim() : indent + s.trim())).join(eol);
|
|
66
|
-
if (text !== p.raw) {
|
|
67
|
-
edits.push({ rule: RULE.WRAP, start: p.startOffset, end: p.endOffset, text, expect: { kind: "same-tree" } });
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return edits;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export const pg001: Rule = {
|
|
74
|
-
id: RULE.WRAP,
|
|
75
|
-
category: "sentence",
|
|
76
|
-
summary: "mid-sentence line wrap (one sentence per line)",
|
|
77
|
-
check,
|
|
78
|
-
fix,
|
|
79
|
-
};
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// PG003: a run of items joined by semicolons is a wall of text hiding a
|
|
2
|
-
// list. The fix needs a colon lead-in to know where the list starts, and
|
|
3
|
-
// refuses without one.
|
|
4
|
-
|
|
5
|
-
import { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
|
|
6
|
-
import { matchesInAll, sentenceSpans, within } from "../fix/spans.ts";
|
|
7
|
-
import { type DocModel, sentences } from "../model.ts";
|
|
8
|
-
import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
|
|
9
|
-
|
|
10
|
-
const check: Rule["check"] = ({ doc, file }) => {
|
|
11
|
-
const findings: Finding[] = [];
|
|
12
|
-
for (const p of doc.paragraphs) {
|
|
13
|
-
if (p.inTable) continue;
|
|
14
|
-
for (const s of sentences(p.prose)) {
|
|
15
|
-
const semis = (s.match(/;/g) ?? []).length;
|
|
16
|
-
if (semis >= 2) {
|
|
17
|
-
findings.push({
|
|
18
|
-
file,
|
|
19
|
-
line: p.line,
|
|
20
|
-
rule: RULE.SEMICOLON_LIST,
|
|
21
|
-
message: `semicolon-delimited list (${semis} ';'); promote to a bullet list`,
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return findings;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const fix = (doc: DocModel): Edit[] => {
|
|
30
|
-
const edits: Edit[] = [];
|
|
31
|
-
const { src } = doc;
|
|
32
|
-
for (const view of doc.paragraphs) {
|
|
33
|
-
const semis = matchesInAll(src, view.directTexts, /;/);
|
|
34
|
-
const colons = matchesInAll(src, view.directTexts, /:/);
|
|
35
|
-
if (semis === null || colons === null) continue;
|
|
36
|
-
for (const span of sentenceSpans(src, view)) {
|
|
37
|
-
const inside = semis.filter((r) => within(r, span));
|
|
38
|
-
if (inside.length < 2) continue;
|
|
39
|
-
const colon = colons.find((r) => within(r, span) && r[1] <= (inside[0] as [number, number])[0]);
|
|
40
|
-
if (!colon) continue;
|
|
41
|
-
const cuts = [colon, ...inside];
|
|
42
|
-
const items = cuts.map((cut, i) => {
|
|
43
|
-
const end = i + 1 < cuts.length ? (cuts[i + 1] as [number, number])[0] : span[1];
|
|
44
|
-
return { text: cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length }) };
|
|
45
|
-
});
|
|
46
|
-
const edit = promote({
|
|
47
|
-
rule: RULE.SEMICOLON_LIST,
|
|
48
|
-
src,
|
|
49
|
-
view,
|
|
50
|
-
span,
|
|
51
|
-
leadIn: cleanLeadIn(src.slice(span[0], colon[0])),
|
|
52
|
-
items,
|
|
53
|
-
ordered: false,
|
|
54
|
-
});
|
|
55
|
-
if (edit) edits.push(edit);
|
|
56
|
-
break; // one promotion per paragraph; the engine re-parses and comes back
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return edits;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export const pg003: Rule = {
|
|
63
|
-
id: RULE.SEMICOLON_LIST,
|
|
64
|
-
category: "list",
|
|
65
|
-
summary: "semicolon-delimited list (2+ ';' in one sentence)",
|
|
66
|
-
check,
|
|
67
|
-
fix,
|
|
68
|
-
};
|