@jpeakai/prose-gates 0.1.0 → 0.1.2
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 +9 -5
- package/dist/bin.js +398 -410
- package/dist/index.js +398 -410
- package/package.json +1 -1
- package/src/check.ts +2 -3
- package/src/fix/engine.ts +4 -4
- package/src/fix/promote.ts +14 -2
- package/src/interpunct.ts +48 -0
- package/src/model.ts +1 -13
- package/src/query.ts +131 -0
- package/src/rules/index.ts +8 -1
- package/src/rules/pg001-wrap.ts +71 -51
- package/src/rules/pg002-length.ts +19 -18
- package/src/rules/pg003-semicolon-list.ts +27 -35
- package/src/rules/pg004-em-dash.ts +118 -80
- package/src/rules/pg005-interpunct.ts +34 -25
- package/src/rules/pg006-interpunct-run.ts +33 -49
- package/src/rules/pg007-inline-enum.ts +66 -58
- package/src/rules/pg008-labelled-run.ts +50 -43
- package/src/rules/pg009-stacked-runs.ts +48 -47
- package/src/rules/types.ts +15 -12
- package/src/text.ts +50 -0
- package/src/fix/spans.ts +0 -75
- package/src/rules/interpunct.ts +0 -19
|
@@ -3,115 +3,153 @@
|
|
|
3
3
|
// around an aside becomes parentheses, a lone dash becomes a colon, and
|
|
4
4
|
// anything else stays reported for a human.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import type {
|
|
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";
|
|
8
10
|
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
9
11
|
|
|
12
|
+
const EM_DASH = "—";
|
|
13
|
+
|
|
10
14
|
// Any horizontal space around the dash belongs to it, including no-break and
|
|
11
15
|
// zero-width spaces, or the swap strands one in front of the colon.
|
|
12
|
-
const DASH = /[\p{Zs}\t
|
|
16
|
+
const DASH = /[\p{Zs}\t]*—[\p{Zs}\t]*/u;
|
|
13
17
|
|
|
14
|
-
//
|
|
15
|
-
// marker and hide the word from the content fingerprint.
|
|
18
|
+
// What an aside must not be: an enumeration marker in disguise.
|
|
16
19
|
const MARKER_LIKE = /^(?:[a-z]|[ivx]+|\d+)$/i;
|
|
17
20
|
|
|
18
21
|
interface Dash {
|
|
19
22
|
range: Range; // the dash with its surrounding spaces
|
|
20
|
-
text:
|
|
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;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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);
|
|
32
47
|
if (found === null) entry.refused = true;
|
|
33
|
-
else entry.dashes.push(...found.map((range) => ({ range, text
|
|
48
|
+
else entry.dashes.push(...found.map((range) => ({ range, text })));
|
|
34
49
|
}
|
|
50
|
+
return [...byBlock.values()];
|
|
51
|
+
};
|
|
35
52
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const colons: number[] = [];
|
|
42
|
-
for (const t of texts) {
|
|
43
|
-
const slice = src.slice(t.start, t.end);
|
|
44
|
-
for (const m of slice.matchAll(/[.!?](?=\s)/g)) terminals.push((t.start as number) + (m.index ?? 0));
|
|
45
|
-
for (const m of slice.matchAll(/:/g)) colons.push((t.start as number) + (m.index ?? 0));
|
|
46
|
-
}
|
|
47
|
-
const sentenceOf = (offset: number): number => terminals.filter((x) => x < offset).length;
|
|
48
|
-
const groups = new Map<number, Dash[]>();
|
|
49
|
-
for (const d of dashes) {
|
|
50
|
-
const key = sentenceOf(d.range[0]);
|
|
51
|
-
groups.set(key, [...(groups.get(key) ?? []), d]);
|
|
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
|
+
);
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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]);
|
|
58
80
|
}
|
|
59
|
-
return
|
|
81
|
+
return [...bySentence].map(([sentence, group]) => ({
|
|
82
|
+
dashes: group,
|
|
83
|
+
hasColon: colons.some((colon) => sentenceOf(colon) === sentence),
|
|
84
|
+
}));
|
|
60
85
|
};
|
|
61
86
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (group.length === 2) {
|
|
82
|
-
const inner = src.slice(first.range[1], last.range[0]);
|
|
83
|
-
if (MARKER_LIKE.test(inner.trim())) return null;
|
|
84
|
-
text = ` (${inner}) `;
|
|
85
|
-
} else if (group.length === 1) {
|
|
86
|
-
// A numeric range like 1—2 or 2020 — 2021 wants an en-dash, not a colon,
|
|
87
|
-
// and a sentence that already has a colon would read as two labels.
|
|
88
|
-
if (/\d/.test(src[first.range[0] - 1] ?? "") && /\d/.test(src[first.range[1]] ?? "")) return null;
|
|
89
|
-
if (colons.some((c) => sentenceOf(c) === sentence)) return null;
|
|
90
|
-
text = ": ";
|
|
91
|
-
} else {
|
|
92
|
-
return null;
|
|
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 PG007 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}) `;
|
|
93
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;
|
|
94
125
|
return {
|
|
95
126
|
rule: RULE.EM_DASH,
|
|
96
|
-
start: first.range[0],
|
|
97
|
-
end: last.range[1],
|
|
127
|
+
start: first(aside.dashes).range[0],
|
|
128
|
+
end: last(aside.dashes).range[1],
|
|
98
129
|
text,
|
|
99
130
|
expect: { kind: "same-shape" },
|
|
100
131
|
};
|
|
101
132
|
};
|
|
102
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
|
+
|
|
103
149
|
export const pg004: Rule = {
|
|
104
150
|
id: RULE.EM_DASH,
|
|
105
151
|
category: "punctuation",
|
|
106
152
|
summary: "em-dash U+2014 in prose",
|
|
107
|
-
check
|
|
108
|
-
doc.texts
|
|
109
|
-
.filter((t) => t.value.includes("—"))
|
|
110
|
-
.map((t) => ({
|
|
111
|
-
file,
|
|
112
|
-
line: t.line,
|
|
113
|
-
rule: RULE.EM_DASH,
|
|
114
|
-
message: "em-dash in prose; use comma, colon, parentheses, or two sentences",
|
|
115
|
-
})),
|
|
153
|
+
check,
|
|
116
154
|
fix,
|
|
117
155
|
};
|
|
@@ -2,23 +2,42 @@
|
|
|
2
2
|
// PG006 or PG009. The fix swaps a spaced interpunct for a comma; a glyph
|
|
3
3
|
// with no space on either side (a product name, a unit) is left alone.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { GLYPH, INTERPUNCT } from "../interpunct.ts";
|
|
6
|
+
import { matchesIn, textsContaining } from "../query.ts";
|
|
7
|
+
import { inRanges } from "../text.ts";
|
|
8
8
|
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
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. PG006 and PG009 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, 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, runs }) => {
|
|
28
|
+
const reported = runs.map((run) => run.range);
|
|
12
29
|
const edits: Edit[] = [];
|
|
13
|
-
for (const
|
|
14
|
-
if (
|
|
15
|
-
if (inRanges(
|
|
16
|
-
const text = { value
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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" } });
|
|
22
41
|
}
|
|
23
42
|
}
|
|
24
43
|
return edits;
|
|
@@ -28,16 +47,6 @@ export const pg005: Rule = {
|
|
|
28
47
|
id: RULE.INTERPUNCT,
|
|
29
48
|
category: "punctuation",
|
|
30
49
|
summary: "interpunct U+00B7 in prose",
|
|
31
|
-
check
|
|
32
|
-
const suppressed = runs.map((r) => r.range);
|
|
33
|
-
return doc.texts
|
|
34
|
-
.filter((t) => t.value.includes("·") && !(t.offset != null && inRanges(t.offset, suppressed)))
|
|
35
|
-
.map((t) => ({
|
|
36
|
-
file,
|
|
37
|
-
line: t.line,
|
|
38
|
-
rule: RULE.INTERPUNCT,
|
|
39
|
-
message: "interpunct in prose; use a comma, slash, or a list",
|
|
40
|
-
}));
|
|
41
|
-
},
|
|
50
|
+
check,
|
|
42
51
|
fix,
|
|
43
52
|
};
|
|
@@ -3,54 +3,46 @@
|
|
|
3
3
|
// Without a colon the run must be the whole paragraph, or there is no way to
|
|
4
4
|
// tell where the first item starts.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
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
10
|
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
11
11
|
|
|
12
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return last > Math.max(8, 3 * others);
|
|
21
|
-
};
|
|
22
|
-
const headIsProse = (items: string[]): boolean => {
|
|
23
|
-
const first = words(items[0] ?? "");
|
|
24
|
-
return first > Math.max(...items.slice(1).map(words));
|
|
25
|
-
};
|
|
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));
|
|
26
20
|
|
|
27
|
-
const
|
|
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 }) => {
|
|
28
30
|
const { src } = doc;
|
|
29
31
|
const edits: Edit[] = [];
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
seps.some(([s]) => escaped(src, s))
|
|
41
|
-
)
|
|
42
|
-
continue;
|
|
43
|
-
const first = seps[0] as Range;
|
|
44
|
-
const last = seps[seps.length - 1] as Range;
|
|
45
|
-
const span = sentenceSpan(src, view, first[0], last[1]);
|
|
46
|
-
const colon = colons.filter((c) => within(c, span) && c[1] <= first[0]).pop();
|
|
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.
|
|
47
42
|
if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset)) continue;
|
|
48
43
|
|
|
49
|
-
const cuts: Range[] = [colon ?? [span[0], span[0]], ...
|
|
50
|
-
const items =
|
|
51
|
-
const end = i + 1 < cuts.length ? (cuts[i + 1] as Range)[0] : span[1];
|
|
52
|
-
return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
|
|
53
|
-
});
|
|
44
|
+
const cuts: Range[] = [colon ?? [span[0], span[0]], ...separators];
|
|
45
|
+
const items = itemsBetween(src, cuts, span[1]);
|
|
54
46
|
if (tailIsProse(items) || (!colon && headIsProse(items))) continue;
|
|
55
47
|
|
|
56
48
|
const edit = promote({
|
|
@@ -71,14 +63,6 @@ export const pg006: Rule = {
|
|
|
71
63
|
id: RULE.INTERPUNCT_RUN,
|
|
72
64
|
category: "list",
|
|
73
65
|
summary: "interpunct-joined inline list (2+ separators)",
|
|
74
|
-
check
|
|
75
|
-
runs
|
|
76
|
-
.filter((r) => r.lines < 2)
|
|
77
|
-
.map((r) => ({
|
|
78
|
-
file,
|
|
79
|
-
line: r.line,
|
|
80
|
-
rule: RULE.INTERPUNCT_RUN,
|
|
81
|
-
message: `interpunct-joined inline list (${r.separators + 1} items); promote to a bullet list`,
|
|
82
|
-
})),
|
|
66
|
+
check,
|
|
83
67
|
fix,
|
|
84
68
|
};
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// lone "(a)" reference from firing. The fix only trusts a clean sequence:
|
|
4
4
|
// every marker in order, none skipped, none from another family.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
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";
|
|
10
11
|
|
|
11
12
|
const ENUM_FAMILIES: Array<[string, string]> = [
|
|
12
13
|
["(a)", "(b)"],
|
|
@@ -18,10 +19,10 @@ const ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi"
|
|
|
18
19
|
const LETTERS = [..."abcdefghijklmnopqrstuvwxyz"];
|
|
19
20
|
|
|
20
21
|
// The expected nth marker for the family the first marker opens.
|
|
21
|
-
const sequenceFor = (
|
|
22
|
-
if (
|
|
23
|
-
if (
|
|
24
|
-
if (
|
|
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;
|
|
25
26
|
return null;
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -35,55 +36,81 @@ interface Marker {
|
|
|
35
36
|
// Markers that open an item: preceded by whitespace or the start of a direct
|
|
36
37
|
// text node, and followed by whitespace. The parsed value must agree with the
|
|
37
38
|
// source, or an escape is hiding somewhere and the fixer refuses.
|
|
38
|
-
const markersIn = (src: string, texts:
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const inSlice = opening(slice);
|
|
45
|
-
if (inSlice.length !== opening(
|
|
46
|
-
for (const
|
|
47
|
-
const start =
|
|
48
|
-
|
|
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] ?? "" });
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
|
-
return
|
|
52
|
+
return markers;
|
|
52
53
|
};
|
|
53
54
|
|
|
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 }) => {
|
|
55
76
|
const { src } = doc;
|
|
56
77
|
const edits: Edit[] = [];
|
|
57
78
|
for (const view of doc.paragraphs) {
|
|
58
79
|
const markers = markersIn(src, view.directTexts);
|
|
59
80
|
if (markers === null || markers.length < 2) continue;
|
|
60
|
-
const sequence = sequenceFor((markers
|
|
61
|
-
if (sequence === null || markers.some((
|
|
81
|
+
const sequence = sequenceFor(first(markers).label);
|
|
82
|
+
if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) continue;
|
|
62
83
|
|
|
63
|
-
const
|
|
64
|
-
const
|
|
84
|
+
const opener = first(markers);
|
|
85
|
+
const closer = last(markers);
|
|
65
86
|
// Markers split across sentences are two references, not one list.
|
|
66
|
-
const between = view.directTexts.map((
|
|
67
|
-
src.slice(Math.max(
|
|
87
|
+
const between = view.directTexts.map((text) =>
|
|
88
|
+
src.slice(Math.max(text.start, opener.range[1]), Math.min(text.end, closer.range[0])),
|
|
68
89
|
);
|
|
69
|
-
if (between.some((
|
|
70
|
-
|
|
90
|
+
if (between.some((slice) => /[.!?]\s/.test(slice))) continue;
|
|
91
|
+
|
|
92
|
+
const span = sentenceSpan(src, view, opener.range[0], closer.range[1]);
|
|
71
93
|
// Two markers joined only by a conjunction, with no colon to announce
|
|
72
|
-
// them, read as cross-references ("see (a) above and (b) below").
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (markers.length === 2
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
}
|
|
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
|
+
|
|
80
103
|
const edit = promote({
|
|
81
104
|
rule: RULE.INLINE_ENUM,
|
|
82
105
|
src,
|
|
83
106
|
view,
|
|
84
107
|
span,
|
|
85
|
-
leadIn: cleanLeadIn(src.slice(span[0],
|
|
86
|
-
items
|
|
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 })),
|
|
87
114
|
ordered: true,
|
|
88
115
|
});
|
|
89
116
|
if (edit) edits.push(edit);
|
|
@@ -91,25 +118,6 @@ const fix = (doc: DocModel): Edit[] => {
|
|
|
91
118
|
return edits;
|
|
92
119
|
};
|
|
93
120
|
|
|
94
|
-
const check: Rule["check"] = ({ doc, file }) => {
|
|
95
|
-
const findings: Finding[] = [];
|
|
96
|
-
for (const p of doc.paragraphs) {
|
|
97
|
-
if (p.inTable) continue;
|
|
98
|
-
for (const [first, second] of ENUM_FAMILIES) {
|
|
99
|
-
if (p.prose.includes(first) && p.prose.includes(second)) {
|
|
100
|
-
findings.push({
|
|
101
|
-
file,
|
|
102
|
-
line: p.line,
|
|
103
|
-
rule: RULE.INLINE_ENUM,
|
|
104
|
-
message: `enumeration ${first} ${second} ... inlined in prose; promote to a bullet list`,
|
|
105
|
-
});
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return findings;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
121
|
export const pg007: Rule = {
|
|
114
122
|
id: RULE.INLINE_ENUM,
|
|
115
123
|
category: "list",
|