@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,127 @@
|
|
|
1
|
+
// list-inline-enumeration-markers: parenthesised enumerators inlined in one paragraph are a hidden
|
|
2
|
+
// list. Requiring the first two members of a family keeps citations and a
|
|
3
|
+
// lone "(a)" reference from firing. The fix only trusts a clean sequence:
|
|
4
|
+
// every marker in order, none skipped, none from another family.
|
|
5
|
+
|
|
6
|
+
import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
|
|
7
|
+
import type { DirectText } from "../model.ts";
|
|
8
|
+
import { proseParagraphs, sentenceSpan } from "../query.ts";
|
|
9
|
+
import { first, last, type Range } from "../text.ts";
|
|
10
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const ENUM_FAMILIES: Array<[string, string]> = [
|
|
13
|
+
["(a)", "(b)"],
|
|
14
|
+
["(1)", "(2)"],
|
|
15
|
+
["(i)", "(ii)"],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii"];
|
|
19
|
+
const LETTERS = [..."abcdefghijklmnopqrstuvwxyz"];
|
|
20
|
+
|
|
21
|
+
// The expected nth marker for the family the first marker opens.
|
|
22
|
+
const sequenceFor = (label: string): string[] | null => {
|
|
23
|
+
if (label === "a") return LETTERS;
|
|
24
|
+
if (label === "1") return Array.from({ length: 99 }, (_, i) => String(i + 1));
|
|
25
|
+
if (label === "i") return ROMAN;
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MARKER = /\(([a-z]|[ivx]+|\d{1,2})\)(?=\s)/g;
|
|
30
|
+
|
|
31
|
+
interface Marker {
|
|
32
|
+
range: Range;
|
|
33
|
+
label: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Markers that open an item: preceded by whitespace or the start of a direct
|
|
37
|
+
// text node, and followed by whitespace. The parsed value must agree with the
|
|
38
|
+
// source, or an escape is hiding somewhere and the fixer refuses.
|
|
39
|
+
const markersIn = (src: string, texts: DirectText[]): Marker[] | null => {
|
|
40
|
+
const opening = (s: string): RegExpMatchArray[] =>
|
|
41
|
+
[...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s.charAt((m.index ?? 0) - 1)));
|
|
42
|
+
|
|
43
|
+
const markers: Marker[] = [];
|
|
44
|
+
for (const text of texts) {
|
|
45
|
+
const inSlice = opening(src.slice(text.start, text.end));
|
|
46
|
+
if (inSlice.length !== opening(text.value).length) return null;
|
|
47
|
+
for (const match of inSlice) {
|
|
48
|
+
const start = text.start + (match.index ?? 0);
|
|
49
|
+
markers.push({ range: [start, start + match[0].length], label: match[1] ?? "" });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return markers;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// The first family whose opening two markers both appear, or undefined. One
|
|
56
|
+
// family per paragraph is enough to report it.
|
|
57
|
+
const familyIn = (prose: string): [string, string] | undefined =>
|
|
58
|
+
ENUM_FAMILIES.find(([opener, second]) => prose.includes(opener) && prose.includes(second));
|
|
59
|
+
|
|
60
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
61
|
+
proseParagraphs(doc).flatMap((view) => {
|
|
62
|
+
const family = familyIn(view.prose);
|
|
63
|
+
if (!family) return [];
|
|
64
|
+
const [opener, second] = family;
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
file,
|
|
68
|
+
line: view.line,
|
|
69
|
+
rule: RULE.INLINE_ENUM,
|
|
70
|
+
message: `enumeration ${opener} ${second} ... inlined in prose; promote to a bullet list`,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const fix: Rule["fix"] = ({ doc }) => {
|
|
76
|
+
const { src } = doc;
|
|
77
|
+
const edits: Edit[] = [];
|
|
78
|
+
for (const view of doc.paragraphs) {
|
|
79
|
+
const markers = markersIn(src, view.directTexts);
|
|
80
|
+
if (markers === null || markers.length < 2) continue;
|
|
81
|
+
const sequence = sequenceFor(first(markers).label);
|
|
82
|
+
if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) continue;
|
|
83
|
+
|
|
84
|
+
const opener = first(markers);
|
|
85
|
+
const closer = last(markers);
|
|
86
|
+
// Markers split across sentences are two references, not one list.
|
|
87
|
+
const between = view.directTexts.map((text) =>
|
|
88
|
+
src.slice(Math.max(text.start, opener.range[1]), Math.min(text.end, closer.range[0])),
|
|
89
|
+
);
|
|
90
|
+
if (between.some((slice) => /[.!?]\s/.test(slice))) continue;
|
|
91
|
+
|
|
92
|
+
const span = sentenceSpan(src, view, opener.range[0], closer.range[1]);
|
|
93
|
+
// Two markers joined only by a conjunction, with no colon to announce
|
|
94
|
+
// them, read as cross-references ("see (a) above and (b) below"). With
|
|
95
|
+
// exactly two markers the closer is also the second, so the text between
|
|
96
|
+
// them is the whole of the first item.
|
|
97
|
+
if (markers.length === 2) {
|
|
98
|
+
const announced = /:\s*$/.test(src.slice(span[0], opener.range[0]));
|
|
99
|
+
const separated = /[,;]\s*$/.test(src.slice(opener.range[1], closer.range[0]));
|
|
100
|
+
if (!announced && !separated) continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const edit = promote({
|
|
104
|
+
rule: RULE.INLINE_ENUM,
|
|
105
|
+
src,
|
|
106
|
+
view,
|
|
107
|
+
span,
|
|
108
|
+
leadIn: cleanLeadIn(src.slice(span[0], opener.range[0])),
|
|
109
|
+
items: itemsBetween(
|
|
110
|
+
src,
|
|
111
|
+
markers.map((marker) => marker.range),
|
|
112
|
+
span[1],
|
|
113
|
+
).map((text) => ({ text })),
|
|
114
|
+
ordered: true,
|
|
115
|
+
});
|
|
116
|
+
if (edit) edits.push(edit);
|
|
117
|
+
}
|
|
118
|
+
return edits;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const listInlineEnumerationMarkers: Rule = {
|
|
122
|
+
id: RULE.INLINE_ENUM,
|
|
123
|
+
category: "list",
|
|
124
|
+
summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
|
|
125
|
+
check,
|
|
126
|
+
fix,
|
|
127
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// list-interpunct-joined-run: a single-line interpunct-joined run is a disguised flat list. The
|
|
2
|
+
// fix promotes it to bullets, taking a colon before the run as the lead-in.
|
|
3
|
+
// Without a colon the run must be the whole paragraph, or there is no way to
|
|
4
|
+
// tell where the first item starts.
|
|
5
|
+
|
|
6
|
+
import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
|
|
7
|
+
import { GLYPH, isFlat, paragraphsOf, RUN_SEPARATOR } from "../interpunct.ts";
|
|
8
|
+
import { colonsBefore, colonsIn, sentenceSpan, splittableSeparators } from "../query.ts";
|
|
9
|
+
import { first, last, type Range, words } from "../text.ts";
|
|
10
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// A last item this much longer than every other is a sentence that happens
|
|
13
|
+
// to follow the list, not a member of it.
|
|
14
|
+
const tailIsProse = (items: string[]): boolean =>
|
|
15
|
+
words(last(items)) > Math.max(8, 3 * Math.max(...items.slice(0, -1).map(words)));
|
|
16
|
+
|
|
17
|
+
// Without a colon, a first item longer than the rest ("Built with Bun")
|
|
18
|
+
// carries a clause the list would strip of its meaning.
|
|
19
|
+
const headIsProse = (items: string[]): boolean => words(first(items)) > Math.max(...items.slice(1).map(words));
|
|
20
|
+
|
|
21
|
+
const check: Rule["check"] = ({ file, runs }) =>
|
|
22
|
+
runs.filter(isFlat).map((run) => ({
|
|
23
|
+
file,
|
|
24
|
+
line: run.line,
|
|
25
|
+
rule: RULE.INTERPUNCT_RUN,
|
|
26
|
+
message: `interpunct-joined inline list (${run.separators + 1} items); promote to a bullet list`,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const fix: Rule["fix"] = ({ doc, runs }) => {
|
|
30
|
+
const { src } = doc;
|
|
31
|
+
const edits: Edit[] = [];
|
|
32
|
+
for (const view of paragraphsOf(doc, runs.filter(isFlat))) {
|
|
33
|
+
const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
|
|
34
|
+
const colons = colonsIn(src, view);
|
|
35
|
+
if (separators === null || colons === null) continue;
|
|
36
|
+
|
|
37
|
+
const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
|
|
38
|
+
// The innermost colon before the first item, so a lead-in that itself
|
|
39
|
+
// contains a colon keeps only its final clause.
|
|
40
|
+
const colon = colonsBefore(colons, span, first(separators)[0]).pop();
|
|
41
|
+
// With no colon to announce it, the run has to be the whole paragraph.
|
|
42
|
+
if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset)) continue;
|
|
43
|
+
|
|
44
|
+
const cuts: Range[] = [colon ?? [span[0], span[0]], ...separators];
|
|
45
|
+
const items = itemsBetween(src, cuts, span[1]);
|
|
46
|
+
if (tailIsProse(items) || (!colon && headIsProse(items))) continue;
|
|
47
|
+
|
|
48
|
+
const edit = promote({
|
|
49
|
+
rule: RULE.INTERPUNCT_RUN,
|
|
50
|
+
src,
|
|
51
|
+
view,
|
|
52
|
+
span,
|
|
53
|
+
leadIn: colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
|
|
54
|
+
items: items.map((text) => ({ text })),
|
|
55
|
+
ordered: false,
|
|
56
|
+
});
|
|
57
|
+
if (edit) edits.push(edit);
|
|
58
|
+
}
|
|
59
|
+
return edits;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const listInterpunctJoinedRun: Rule = {
|
|
63
|
+
id: RULE.INTERPUNCT_RUN,
|
|
64
|
+
category: "list",
|
|
65
|
+
summary: "interpunct-joined inline list (2+ separators)",
|
|
66
|
+
check,
|
|
67
|
+
fix,
|
|
68
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// list-semicolon-delimited-run: 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 { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
|
|
6
|
+
import { colonsBefore, colonsIn, matchesInAll, proseSentences, sentenceSpans } from "../query.ts";
|
|
7
|
+
import { countOf, first, within } from "../text.ts";
|
|
8
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const SEMICOLON = /;/g;
|
|
11
|
+
|
|
12
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
13
|
+
proseSentences(doc)
|
|
14
|
+
.map(({ view, sentence }) => ({ view, semicolons: countOf(sentence, SEMICOLON) }))
|
|
15
|
+
.filter(({ semicolons }) => semicolons >= 2)
|
|
16
|
+
.map(({ view, semicolons }) => ({
|
|
17
|
+
file,
|
|
18
|
+
line: view.line,
|
|
19
|
+
rule: RULE.SEMICOLON_LIST,
|
|
20
|
+
message: `semicolon-delimited list (${semicolons} ';'); promote to a bullet list`,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const fix: Rule["fix"] = ({ doc }) => {
|
|
24
|
+
const { src } = doc;
|
|
25
|
+
const edits: Edit[] = [];
|
|
26
|
+
for (const view of doc.paragraphs) {
|
|
27
|
+
const semicolons = matchesInAll(src, view.directTexts, SEMICOLON);
|
|
28
|
+
const colons = colonsIn(src, view);
|
|
29
|
+
if (semicolons === null || colons === null) continue;
|
|
30
|
+
for (const span of sentenceSpans(src, view)) {
|
|
31
|
+
const inside = semicolons.filter((semicolon) => within(semicolon, span));
|
|
32
|
+
if (inside.length < 2) continue;
|
|
33
|
+
// The outermost colon before the first item opens the lead-in, so a
|
|
34
|
+
// "Note: the rules: a; b; c" keeps the whole of "Note: the rules".
|
|
35
|
+
const colon = colonsBefore(colons, span, first(inside)[0])[0];
|
|
36
|
+
if (!colon) continue;
|
|
37
|
+
|
|
38
|
+
const edit = promote({
|
|
39
|
+
rule: RULE.SEMICOLON_LIST,
|
|
40
|
+
src,
|
|
41
|
+
view,
|
|
42
|
+
span,
|
|
43
|
+
leadIn: cleanLeadIn(src.slice(span[0], colon[0])),
|
|
44
|
+
items: itemsBetween(src, [colon, ...inside], span[1]).map((text) => ({ text })),
|
|
45
|
+
ordered: false,
|
|
46
|
+
});
|
|
47
|
+
if (edit) edits.push(edit);
|
|
48
|
+
break; // one promotion per paragraph; the engine re-parses and comes back
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return edits;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const listSemicolonDelimitedRun: Rule = {
|
|
55
|
+
id: RULE.SEMICOLON_LIST,
|
|
56
|
+
category: "list",
|
|
57
|
+
summary: "semicolon-delimited list (2+ ';' in one sentence)",
|
|
58
|
+
check,
|
|
59
|
+
fix,
|
|
60
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// list-stacked-interpunct-runs: interpunct runs stacked on 2+ source lines of one paragraph are a
|
|
2
|
+
// two-level structure, so list-stacked-interpunct-runs replaces list-interpunct-joined-run for that paragraph. The fix
|
|
3
|
+
// needs every line to read `Label: a · b · c`, and builds one parent bullet
|
|
4
|
+
// per label with the items nested beneath it.
|
|
5
|
+
|
|
6
|
+
import { itemsBetween, type PromotedItem, promote } from "../fix/promote.ts";
|
|
7
|
+
import { GLYPH, isStacked, paragraphsOf, RUN_SEPARATOR } from "../interpunct.ts";
|
|
8
|
+
import type { ParagraphView } from "../model.ts";
|
|
9
|
+
import { colonsBefore, colonsIn, splittableSeparators } from "../query.ts";
|
|
10
|
+
import { collapse, first, type Range, within } from "../text.ts";
|
|
11
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
// The source lines of a paragraph, as absolute ranges.
|
|
14
|
+
const linesOf = (view: ParagraphView): Range[] => {
|
|
15
|
+
const lines: Range[] = [];
|
|
16
|
+
let at = view.startOffset;
|
|
17
|
+
for (const line of view.raw.split("\n")) {
|
|
18
|
+
lines.push([at, at + line.length]);
|
|
19
|
+
at += line.length + 1;
|
|
20
|
+
}
|
|
21
|
+
return lines;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// One line read as `Label: a · b · c`, or null when it is not that shape.
|
|
25
|
+
// Every line of the paragraph has to parse, or the fix has no nested list to
|
|
26
|
+
// build and leaves the whole paragraph reported.
|
|
27
|
+
const parentOf = (src: string, line: Range, separators: Range[], colons: Range[]): PromotedItem | null => {
|
|
28
|
+
const inside = separators.filter((separator) => within(separator, line));
|
|
29
|
+
if (inside.length === 0) return null;
|
|
30
|
+
const colon = colonsBefore(colons, line, first(inside)[0])[0];
|
|
31
|
+
if (!colon) return null;
|
|
32
|
+
return {
|
|
33
|
+
text: collapse(src.slice(line[0], colon[0])).trim(),
|
|
34
|
+
children: itemsBetween(src, [colon, ...inside], line[1]),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const check: Rule["check"] = ({ file, runs }) =>
|
|
39
|
+
runs.filter(isStacked).map((run) => ({
|
|
40
|
+
file,
|
|
41
|
+
line: run.line,
|
|
42
|
+
rule: RULE.STACKED_RUNS,
|
|
43
|
+
message: `interpunct runs stacked on ${run.lines} lines; promote to a nested list`,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const fix: Rule["fix"] = ({ doc, runs }) => {
|
|
47
|
+
const { src } = doc;
|
|
48
|
+
const edits: Edit[] = [];
|
|
49
|
+
for (const view of paragraphsOf(doc, runs.filter(isStacked))) {
|
|
50
|
+
const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
|
|
51
|
+
const colons = colonsIn(src, view);
|
|
52
|
+
if (separators === null || colons === null) continue;
|
|
53
|
+
|
|
54
|
+
const lines = linesOf(view);
|
|
55
|
+
const parents = lines
|
|
56
|
+
.map((line) => parentOf(src, line, separators, colons))
|
|
57
|
+
.filter((parent): parent is PromotedItem => parent !== null);
|
|
58
|
+
if (parents.length !== lines.length) continue;
|
|
59
|
+
|
|
60
|
+
const edit = promote({
|
|
61
|
+
rule: RULE.STACKED_RUNS,
|
|
62
|
+
src,
|
|
63
|
+
view,
|
|
64
|
+
span: [view.startOffset, view.endOffset],
|
|
65
|
+
leadIn: null,
|
|
66
|
+
items: parents,
|
|
67
|
+
ordered: false,
|
|
68
|
+
});
|
|
69
|
+
if (edit) edits.push(edit);
|
|
70
|
+
}
|
|
71
|
+
return edits;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const listStackedInterpunctRuns: Rule = {
|
|
75
|
+
id: RULE.STACKED_RUNS,
|
|
76
|
+
category: "list",
|
|
77
|
+
summary: "interpunct runs stacked on several lines (nested list)",
|
|
78
|
+
check,
|
|
79
|
+
fix,
|
|
80
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// punctuation-em-dash-in-prose: an em-dash in prose. Code is exempt by construction, since only
|
|
2
|
+
// text nodes are read. The fix is deliberately narrow: a pair of dashes
|
|
3
|
+
// around an aside becomes parentheses, a lone dash becomes a colon, and
|
|
4
|
+
// anything else stays reported for a human.
|
|
5
|
+
|
|
6
|
+
import type { Node } from "mdast";
|
|
7
|
+
import type { DirectText, DocModel } from "../model.ts";
|
|
8
|
+
import { matchesIn, textsContaining } from "../query.ts";
|
|
9
|
+
import { first, last, type Range } from "../text.ts";
|
|
10
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const EM_DASH = "—";
|
|
13
|
+
|
|
14
|
+
// Any horizontal space around the dash belongs to it, including no-break and
|
|
15
|
+
// zero-width spaces, or the swap strands one in front of the colon.
|
|
16
|
+
const DASH = /[\p{Zs}\t]*—[\p{Zs}\t]*/u;
|
|
17
|
+
|
|
18
|
+
// What an aside must not be: an enumeration marker in disguise.
|
|
19
|
+
const MARKER_LIKE = /^(?:[a-z]|[ivx]+|\d+)$/i;
|
|
20
|
+
|
|
21
|
+
interface Dash {
|
|
22
|
+
range: Range; // the dash with its surrounding spaces
|
|
23
|
+
text: DirectText; // the text node it sits in
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One paragraph, heading or table cell: the dashes it holds and the text
|
|
27
|
+
// nodes they came from. A block whose source and parsed value disagree is
|
|
28
|
+
// refused whole, so a swap never lands next to a glyph the fixer cannot see.
|
|
29
|
+
interface Block {
|
|
30
|
+
texts: DirectText[];
|
|
31
|
+
dashes: Dash[];
|
|
32
|
+
refused: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const blocksOf = (doc: DocModel): Block[] => {
|
|
36
|
+
const byBlock = new Map<Node, Block>();
|
|
37
|
+
for (const { block, start, end, value } of doc.texts) {
|
|
38
|
+
if (block === undefined || start === undefined || end === undefined) continue;
|
|
39
|
+
let entry = byBlock.get(block);
|
|
40
|
+
if (!entry) {
|
|
41
|
+
entry = { texts: [], dashes: [], refused: false };
|
|
42
|
+
byBlock.set(block, entry);
|
|
43
|
+
}
|
|
44
|
+
const text: DirectText = { value, start, end };
|
|
45
|
+
entry.texts.push(text);
|
|
46
|
+
const found = matchesIn(doc.src, text, DASH);
|
|
47
|
+
if (found === null) entry.refused = true;
|
|
48
|
+
else entry.dashes.push(...found.map((range) => ({ range, text })));
|
|
49
|
+
}
|
|
50
|
+
return [...byBlock.values()];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Absolute offsets of every match of `pattern` across a block's prose text.
|
|
54
|
+
const offsetsIn = (src: string, texts: DirectText[], pattern: RegExp): number[] =>
|
|
55
|
+
texts.flatMap((text) =>
|
|
56
|
+
[...src.slice(text.start, text.end).matchAll(pattern)].map((m) => text.start + (m.index ?? 0)),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// The dashes of one sentence, with the one fact about that sentence the
|
|
60
|
+
// rewrite needs. A pair only reads as an aside when both halves sit in the
|
|
61
|
+
// same sentence, which is why dashes are judged a sentence at a time.
|
|
62
|
+
interface Aside {
|
|
63
|
+
dashes: Dash[];
|
|
64
|
+
hasColon: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const asidesIn = (src: string, { texts, dashes }: Block): Aside[] => {
|
|
68
|
+
// Terminals and colons are read only from this block's prose text, so one
|
|
69
|
+
// inside a link, emphasis or code span never splits a sentence.
|
|
70
|
+
const terminals = offsetsIn(src, texts, /[.!?](?=\s)/g);
|
|
71
|
+
const colons = offsetsIn(src, texts, /:/g);
|
|
72
|
+
const sentenceOf = (offset: number): number => terminals.filter((terminal) => terminal < offset).length;
|
|
73
|
+
|
|
74
|
+
const bySentence = new Map<number, Dash[]>();
|
|
75
|
+
for (const dash of dashes) {
|
|
76
|
+
const sentence = sentenceOf(dash.range[0]);
|
|
77
|
+
const group = bySentence.get(sentence);
|
|
78
|
+
if (group) group.push(dash);
|
|
79
|
+
else bySentence.set(sentence, [dash]);
|
|
80
|
+
}
|
|
81
|
+
return [...bySentence].map(([sentence, group]) => ({
|
|
82
|
+
dashes: group,
|
|
83
|
+
hasColon: colons.some((colon) => sentenceOf(colon) === sentence),
|
|
84
|
+
}));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// A dash touching a line break, the edge of its text node, or punctuation
|
|
88
|
+
// that already ends or opens a clause has no clear neighbours to join.
|
|
89
|
+
const touchesEdge = (src: string, dash: Dash): boolean =>
|
|
90
|
+
dash.range[0] === dash.text.start ||
|
|
91
|
+
dash.range[1] === dash.text.end ||
|
|
92
|
+
/[\r\n.!?:;,(\\]/.test(src[dash.range[0] - 1] ?? "\n") ||
|
|
93
|
+
/[\r\n.!?:;,)]/.test(src[dash.range[1]] ?? "\n");
|
|
94
|
+
|
|
95
|
+
// What the dashes of one sentence become, or null when the shape is not one
|
|
96
|
+
// of the two the fix trusts.
|
|
97
|
+
const replacementFor = (src: string, { dashes, hasColon }: Aside): string | null => {
|
|
98
|
+
const opening = first(dashes);
|
|
99
|
+
const closing = last(dashes);
|
|
100
|
+
|
|
101
|
+
// A pair wraps an aside, so it becomes parentheses. An aside that reads as
|
|
102
|
+
// "(b)" would plant a list-inline-enumeration-markers marker and hide the word from the fingerprint.
|
|
103
|
+
if (dashes.length === 2) {
|
|
104
|
+
const inner = src.slice(opening.range[1], closing.range[0]);
|
|
105
|
+
return MARKER_LIKE.test(inner.trim()) ? null : ` (${inner}) `;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A lone dash joins two clauses, so it becomes a colon. A numeric range
|
|
109
|
+
// like 1—2 or 2020 — 2021 wants an en-dash instead, and a sentence that
|
|
110
|
+
// already has a colon would read as two labels.
|
|
111
|
+
if (dashes.length === 1) {
|
|
112
|
+
const numericRange = /\d/.test(src[opening.range[0] - 1] ?? "") && /\d/.test(src[opening.range[1]] ?? "");
|
|
113
|
+
return numericRange || hasColon ? null : ": ";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Three or more dashes in one sentence is a shape the fix does not claim
|
|
117
|
+
// to understand, so the finding stays for a human.
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const rewrite = (src: string, aside: Aside): Edit | null => {
|
|
122
|
+
if (aside.dashes.some((dash) => touchesEdge(src, dash))) return null;
|
|
123
|
+
const text = replacementFor(src, aside);
|
|
124
|
+
if (text === null) return null;
|
|
125
|
+
return {
|
|
126
|
+
rule: RULE.EM_DASH,
|
|
127
|
+
start: first(aside.dashes).range[0],
|
|
128
|
+
end: last(aside.dashes).range[1],
|
|
129
|
+
text,
|
|
130
|
+
expect: { kind: "same-shape" },
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
135
|
+
textsContaining(doc, EM_DASH).map((text) => ({
|
|
136
|
+
file,
|
|
137
|
+
line: text.line,
|
|
138
|
+
rule: RULE.EM_DASH,
|
|
139
|
+
message: "em-dash in prose; use comma, colon, parentheses, or two sentences",
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const fix: Rule["fix"] = ({ doc }) =>
|
|
143
|
+
blocksOf(doc)
|
|
144
|
+
.filter((block) => !block.refused && block.dashes.length > 0)
|
|
145
|
+
.flatMap((block) => asidesIn(doc.src, block))
|
|
146
|
+
.map((aside) => rewrite(doc.src, aside))
|
|
147
|
+
.filter((edit): edit is Edit => edit !== null);
|
|
148
|
+
|
|
149
|
+
export const punctuationEmDashInProse: Rule = {
|
|
150
|
+
id: RULE.EM_DASH,
|
|
151
|
+
category: "punctuation",
|
|
152
|
+
summary: "em-dash U+2014 in prose",
|
|
153
|
+
check,
|
|
154
|
+
fix,
|
|
155
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// punctuation-interpunct-in-prose: a stray interpunct in prose. One inside a reported run is left to
|
|
2
|
+
// list-interpunct-joined-run or list-stacked-interpunct-runs. The fix swaps a spaced interpunct for a comma; a glyph
|
|
3
|
+
// with no space on either side (a product name, a unit) is left alone.
|
|
4
|
+
|
|
5
|
+
import { GLYPH, INTERPUNCT } from "../interpunct.ts";
|
|
6
|
+
import { matchesIn, textsContaining } from "../query.ts";
|
|
7
|
+
import { inRanges } from "../text.ts";
|
|
8
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// Only a separator reads as a comma: the spaces on both sides are what make
|
|
11
|
+
// the glyph a joiner rather than part of a name. list-interpunct-joined-run and list-stacked-interpunct-runs split on a
|
|
12
|
+
// looser one, because a run has already proved itself a list.
|
|
13
|
+
const SEPARATOR = /[ \t]+·[ \t]+/;
|
|
14
|
+
|
|
15
|
+
const check: Rule["check"] = ({ doc, file, reported: runs }) => {
|
|
16
|
+
const reported = runs.map((run) => run.range);
|
|
17
|
+
return textsContaining(doc, INTERPUNCT)
|
|
18
|
+
.filter((text) => text.start === undefined || !inRanges(text.start, reported))
|
|
19
|
+
.map((text) => ({
|
|
20
|
+
file,
|
|
21
|
+
line: text.line,
|
|
22
|
+
rule: RULE.INTERPUNCT,
|
|
23
|
+
message: "interpunct in prose; use a comma, slash, or a list",
|
|
24
|
+
}));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const fix: Rule["fix"] = ({ doc, reported: runs }) => {
|
|
28
|
+
const reported = runs.map((run) => run.range);
|
|
29
|
+
const edits: Edit[] = [];
|
|
30
|
+
for (const { start, end, value } of textsContaining(doc, INTERPUNCT)) {
|
|
31
|
+
if (start === undefined || end === undefined) continue;
|
|
32
|
+
if (inRanges(start, reported)) continue;
|
|
33
|
+
const text = { value, start, end };
|
|
34
|
+
// Every glyph in the node must be a spaced separator. A mixed node holds
|
|
35
|
+
// at least one the fix would leave behind, so it refuses whole.
|
|
36
|
+
const glyphs = matchesIn(doc.src, text, GLYPH);
|
|
37
|
+
const separators = matchesIn(doc.src, text, SEPARATOR);
|
|
38
|
+
if (glyphs === null || separators === null || glyphs.length !== separators.length) continue;
|
|
39
|
+
for (const [from, to] of separators) {
|
|
40
|
+
edits.push({ rule: RULE.INTERPUNCT, start: from, end: to, text: ", ", expect: { kind: "same-shape" } });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return edits;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const punctuationInterpunctInProse: Rule = {
|
|
47
|
+
id: RULE.INTERPUNCT,
|
|
48
|
+
category: "punctuation",
|
|
49
|
+
summary: "interpunct U+00B7 in prose",
|
|
50
|
+
check,
|
|
51
|
+
fix,
|
|
52
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The registry: the rules a run actually uses. It starts from the built-in
|
|
2
|
+
// catalogue, adds whatever plugins loaded, then applies the config, so a rule
|
|
3
|
+
// that is off loses its check and its fixer together. Nothing else reads the
|
|
4
|
+
// catalogue directly, which is what lets a plugin rule behave as a built-in.
|
|
5
|
+
|
|
6
|
+
import type { Config } from "../config.ts";
|
|
7
|
+
import { RuleOptionError, UnknownRuleError } from "../errors.ts";
|
|
8
|
+
import { interpunctRuns, isFlat } from "../interpunct.ts";
|
|
9
|
+
import type { DocModel } from "../model.ts";
|
|
10
|
+
import { sentences, words } from "../text.ts";
|
|
11
|
+
import { FIX_ORDER, RULES } from "./index.ts";
|
|
12
|
+
import { lengthMessage } from "./sentence-word-budget-exceeded.ts";
|
|
13
|
+
import {
|
|
14
|
+
isPluginRuleId,
|
|
15
|
+
type OptionType,
|
|
16
|
+
RULE,
|
|
17
|
+
type Rule,
|
|
18
|
+
type RuleContext,
|
|
19
|
+
type RuleId,
|
|
20
|
+
type RuleOptions,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
|
|
23
|
+
export const BUILTIN_CATEGORIES: ReadonlyMap<string, string> = new Map([
|
|
24
|
+
["sentence", "How a sentence is laid out and how long it runs"],
|
|
25
|
+
["list", "A list hidden in running prose, promoted to a real markdown list"],
|
|
26
|
+
["punctuation", "A glyph that reads as generated text"],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const TEXT_HELPERS = { words, sentences, lengthMessage } as const;
|
|
30
|
+
|
|
31
|
+
// Where a group of rules came from, for the line each run prints.
|
|
32
|
+
export interface LoadedSource {
|
|
33
|
+
kind: "local" | "package" | "config";
|
|
34
|
+
spec: string;
|
|
35
|
+
namespace: string;
|
|
36
|
+
rules: RuleId[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Registry {
|
|
40
|
+
rules: Rule[];
|
|
41
|
+
fixOrder: Rule[];
|
|
42
|
+
enabled: ReadonlySet<RuleId>;
|
|
43
|
+
options: ReadonlyMap<RuleId, RuleOptions>;
|
|
44
|
+
categories: ReadonlyMap<string, string>;
|
|
45
|
+
sources: LoadedSource[];
|
|
46
|
+
// Config settings dropped because they name a plugin rule that did not load.
|
|
47
|
+
ignoredSettings: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RegistryInput {
|
|
51
|
+
rules: Rule[];
|
|
52
|
+
fixOrder: Rule[];
|
|
53
|
+
categories: ReadonlyMap<string, string>;
|
|
54
|
+
sources?: LoadedSource[];
|
|
55
|
+
config?: Config;
|
|
56
|
+
// A lenient run drops a setting for an unknown plugin rule instead of failing.
|
|
57
|
+
ignoreUnknownPluginIds?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const typeOf = (value: unknown): OptionType | null => {
|
|
61
|
+
if (typeof value === "number" && Number.isFinite(value)) return "number";
|
|
62
|
+
if (typeof value === "string") return "string";
|
|
63
|
+
if (typeof value === "boolean") return "boolean";
|
|
64
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) return "string[]";
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const checkOptions = (where: string, rule: Rule, options: RuleOptions): void => {
|
|
69
|
+
const accepted = Object.keys(rule.options ?? {});
|
|
70
|
+
for (const [name, value] of Object.entries(options)) {
|
|
71
|
+
const want = rule.options?.[name];
|
|
72
|
+
if (!want) {
|
|
73
|
+
const list = accepted.length > 0 ? `it accepts ${accepted.join(", ")}` : "it takes no options";
|
|
74
|
+
throw new RuleOptionError(where, rule.id, name, `rule "${rule.id}" has no option "${name}"; ${list}`);
|
|
75
|
+
}
|
|
76
|
+
if (typeOf(value) !== want) {
|
|
77
|
+
throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be a ${want}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if ("maxWords" in options && !((options.maxWords as number) >= 1)) {
|
|
81
|
+
throw new RuleOptionError(where, rule.id, "maxWords", `option "maxWords" of "${rule.id}" must be at least 1`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const buildRegistry = (input: RegistryInput): Registry => {
|
|
86
|
+
const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
|
|
87
|
+
const off = new Set<string>();
|
|
88
|
+
const options = new Map<RuleId, RuleOptions>();
|
|
89
|
+
const ignored: string[] = [];
|
|
90
|
+
const where = config?.file ?? "config";
|
|
91
|
+
for (const [id, setting] of config?.rules ?? []) {
|
|
92
|
+
const rule = rules.find((r) => r.id === id);
|
|
93
|
+
if (!rule) {
|
|
94
|
+
// A plugin that was skipped cannot have its rules configured, so a lenient
|
|
95
|
+
// run drops the setting and says so. A built-in id is never forgiven.
|
|
96
|
+
if (ignoreUnknownPluginIds && isPluginRuleId(id)) {
|
|
97
|
+
ignored.push(id);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
throw new UnknownRuleError(
|
|
101
|
+
where,
|
|
102
|
+
id,
|
|
103
|
+
rules.map((r) => r.id),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
checkOptions(where, rule, setting.options);
|
|
107
|
+
if (setting.severity === "off") off.add(id);
|
|
108
|
+
else options.set(rule.id, setting.options);
|
|
109
|
+
}
|
|
110
|
+
const active = rules.filter((r) => !off.has(r.id));
|
|
111
|
+
return {
|
|
112
|
+
rules: active,
|
|
113
|
+
fixOrder: fixOrder.filter((r) => !off.has(r.id)),
|
|
114
|
+
enabled: new Set(active.map((r) => r.id)),
|
|
115
|
+
options,
|
|
116
|
+
categories,
|
|
117
|
+
sources,
|
|
118
|
+
ignoredSettings: ignored,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const BUILTIN: Registry = buildRegistry({ rules: RULES, fixOrder: FIX_ORDER, categories: BUILTIN_CATEGORIES });
|
|
123
|
+
|
|
124
|
+
// The data every rule shares, derived once per document. Interpunct runs are
|
|
125
|
+
// read by several rules, so computing them here keeps the fix engine from
|
|
126
|
+
// rebuilding them once per rule on every pass of the fixpoint loop. A run is
|
|
127
|
+
// owned by the flat or the stacked list rule, and it counts as reported only
|
|
128
|
+
// while its owner is on.
|
|
129
|
+
export const ruleContext = (doc: DocModel, registry: Registry = BUILTIN): RuleContext => {
|
|
130
|
+
const runs = interpunctRuns(doc);
|
|
131
|
+
const reported = runs.filter((run) => registry.enabled.has(isFlat(run) ? RULE.INTERPUNCT_RUN : RULE.STACKED_RUNS));
|
|
132
|
+
return { doc, runs, reported };
|
|
133
|
+
};
|