@jpeakai/prose-gates 0.1.1 → 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.
@@ -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 { matchesIn } from "../fix/spans.ts";
7
- import type { DocModel, Range, TextView } from "../model.ts";
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\u200b]*—[\p{Zs}\t\u200b]*/u;
16
+ const DASH = /[\p{Zs}\t​]*—[\p{Zs}\t​]*/u;
13
17
 
14
- // An aside that reads as an enumeration marker, "(b)", would plant a PG007
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: TextView;
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 fix = (doc: DocModel): Edit[] => {
24
- const { src } = doc;
25
- const byBlock = new Map<unknown, { texts: TextView[]; dashes: Dash[]; refused: boolean }>();
26
- for (const t of doc.texts) {
27
- if (t.block === undefined || t.start === undefined || t.end === undefined) continue;
28
- const entry = byBlock.get(t.block) ?? { texts: [], dashes: [], refused: false };
29
- byBlock.set(t.block, entry);
30
- entry.texts.push(t);
31
- const found = matchesIn(src, { value: t.value, start: t.start, end: t.end }, DASH);
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: t })));
48
+ else entry.dashes.push(...found.map((range) => ({ range, text })));
34
49
  }
50
+ return [...byBlock.values()];
51
+ };
35
52
 
36
- const edits: Edit[] = [];
37
- for (const { texts, dashes, refused } of byBlock.values()) {
38
- if (refused || dashes.length === 0) continue;
39
- // Sentence terminals and colons, read only from prose text in this block.
40
- const terminals: number[] = [];
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
- for (const [sentence, group] of groups) {
55
- const edit = rewrite(src, group, sentence, sentenceOf, colons);
56
- if (edit) edits.push(edit);
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 edits;
81
+ return [...bySentence].map(([sentence, group]) => ({
82
+ dashes: group,
83
+ hasColon: colons.some((colon) => sentenceOf(colon) === sentence),
84
+ }));
60
85
  };
61
86
 
62
- const rewrite = (
63
- src: string,
64
- group: Dash[],
65
- sentence: number,
66
- sentenceOf: (offset: number) => number,
67
- colons: number[],
68
- ): Edit | null => {
69
- const first = group[0] as Dash;
70
- const last = group[group.length - 1] as Dash;
71
- // A dash touching a line break, the edge of its text node, or punctuation
72
- // that already ends or opens a clause has no clear neighbours to join.
73
- const touchesEdge = (d: Dash): boolean =>
74
- d.range[0] === d.text.start ||
75
- d.range[1] === d.text.end ||
76
- /[\r\n.!?:;,(\\]/.test(src[d.range[0] - 1] ?? "\n") ||
77
- /[\r\n.!?:;,)]/.test(src[d.range[1]] ?? "\n");
78
- if (group.some(touchesEdge)) return null;
79
-
80
- let text: string;
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: ({ doc, file }) =>
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 { matchesIn } from "../fix/spans.ts";
6
- import { type DocModel, inRanges } from "../model.ts";
7
- import { interpunctRuns } from "./interpunct.ts";
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
- const fix = (doc: DocModel): Edit[] => {
11
- const runs = interpunctRuns(doc).map((r) => r.range);
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 t of doc.texts) {
14
- if (t.start === undefined || t.end === undefined || !t.value.includes("·")) continue;
15
- if (inRanges(t.start, runs)) continue;
16
- const text = { value: t.value, start: t.start, end: t.end };
17
- const glyphs = matchesIn(doc.src, text, /·/);
18
- const spaced = matchesIn(doc.src, text, /[ \t]+·[ \t]+/);
19
- if (glyphs === null || spaced === null || glyphs.length !== spaced.length) continue;
20
- for (const [start, end] of spaced) {
21
- edits.push({ rule: RULE.INTERPUNCT, start, end, text: ", ", expect: { kind: "same-shape" } });
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: ({ doc, file, runs }) => {
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 { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
7
- import { escaped, matchesInAll, sentenceSpan, within } from "../fix/spans.ts";
8
- import type { DocModel, Range } from "../model.ts";
9
- import { interpunctRuns } from "./interpunct.ts";
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. Without a colon, a first item
14
- // longer than the rest ("Built with Bun") carries a clause the list would
15
- // strip of its meaning.
16
- const words = (s: string): number => s.split(/\s+/).filter(Boolean).length;
17
- const tailIsProse = (items: string[]): boolean => {
18
- const last = words(items[items.length - 1] ?? "");
19
- const others = Math.max(...items.slice(0, -1).map(words));
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 fix = (doc: DocModel): Edit[] => {
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 runs = interpunctRuns(doc).filter((r) => r.lines === 1);
31
- for (const view of doc.paragraphs) {
32
- if (!runs.some((r) => r.range[0] === view.startOffset)) continue;
33
- const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
34
- const colons = matchesInAll(src, view.directTexts, /:/);
35
- // Every separator the check counted must be one a fixer can split on.
36
- if (
37
- seps === null ||
38
- colons === null ||
39
- seps.length !== (view.prose.match(/·/g) ?? []).length ||
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]], ...seps];
50
- const items = cuts.map((cut, i) => {
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: ({ file, runs }) =>
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 { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
7
- import { sentenceSpan } from "../fix/spans.ts";
8
- import type { DocModel, Range } from "../model.ts";
9
- import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
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 = (first: string): string[] | null => {
22
- if (first === "a") return LETTERS;
23
- if (first === "1") return Array.from({ length: 99 }, (_, i) => String(i + 1));
24
- if (first === "i") return ROMAN;
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: DocModel["paragraphs"][number]["directTexts"]): Marker[] | null => {
39
- const out: Marker[] = [];
40
- for (const t of texts) {
41
- const slice = src.slice(t.start, t.end);
42
- const opening = (s: string): RegExpMatchArray[] =>
43
- [...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s[(m.index ?? 0) - 1] as string));
44
- const inSlice = opening(slice);
45
- if (inSlice.length !== opening(t.value).length) return null;
46
- for (const m of inSlice) {
47
- const start = t.start + (m.index ?? 0);
48
- out.push({ range: [start, start + m[0].length], label: m[1] as string });
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 out;
52
+ return markers;
52
53
  };
53
54
 
54
- const fix = (doc: DocModel): Edit[] => {
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[0] as Marker).label);
61
- if (sequence === null || markers.some((m, i) => m.label !== sequence[i])) continue;
81
+ const sequence = sequenceFor(first(markers).label);
82
+ if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) continue;
62
83
 
63
- const first = markers[0] as Marker;
64
- const last = markers[markers.length - 1] as Marker;
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((t) =>
67
- src.slice(Math.max(t.start, first.range[1]), Math.min(t.end, last.range[0])),
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((s) => /[.!?]\s/.test(s))) continue;
70
- const span = sentenceSpan(src, view, first.range[0], last.range[1]);
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
- const announced = /:\s*$/.test(src.slice(span[0], first.range[0]));
74
- const separated = /[,;]\s*$/.test(src.slice((markers[0] as Marker).range[1], (markers[1] as Marker).range[0]));
75
- if (markers.length === 2 && !announced && !separated) continue;
76
- const items = markers.map((m, i) => {
77
- const end = i + 1 < markers.length ? (markers[i + 1] as Marker).range[0] : span[1];
78
- return { text: cleanItem(src.slice(m.range[1], end), { last: i + 1 === markers.length }) };
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], first.range[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",