@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.
Files changed (58) hide show
  1. package/README.md +37 -4
  2. package/dist/bin.js +8472 -539
  3. package/dist/index.js +8500 -540
  4. package/dist/testing.js +18321 -0
  5. package/examples/plugin-skills/README.md +60 -0
  6. package/examples/plugin-skills/index.mjs +14 -0
  7. package/examples/plugin-skills/package.json +12 -0
  8. package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
  9. package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
  10. package/examples/plugin-skills-typescript/README.md +42 -0
  11. package/examples/plugin-skills-typescript/index.ts +21 -0
  12. package/examples/plugin-skills-typescript/package.json +12 -0
  13. package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
  14. package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
  15. package/examples/plugin-skills-typescript/tsconfig.json +14 -0
  16. package/package.json +10 -3
  17. package/src/bin.ts +2 -2
  18. package/src/check.ts +40 -13
  19. package/src/cli.ts +197 -28
  20. package/src/config.ts +119 -0
  21. package/src/errors.ts +191 -0
  22. package/src/fix/engine.ts +31 -9
  23. package/src/fix/promote.ts +14 -2
  24. package/src/fix/verify.ts +29 -0
  25. package/src/index.ts +38 -2
  26. package/src/interpunct.ts +48 -0
  27. package/src/model.ts +129 -12
  28. package/src/plugins/contract.ts +41 -0
  29. package/src/plugins/discover.ts +140 -0
  30. package/src/plugins/index.ts +103 -0
  31. package/src/plugins/load.ts +234 -0
  32. package/src/plugins/runtime.ts +15 -0
  33. package/src/project.ts +81 -0
  34. package/src/query.ts +131 -0
  35. package/src/rules/index.ts +31 -12
  36. package/src/rules/list-comma-labelled-run.ts +101 -0
  37. package/src/rules/list-inline-enumeration-markers.ts +127 -0
  38. package/src/rules/list-interpunct-joined-run.ts +68 -0
  39. package/src/rules/list-semicolon-delimited-run.ts +60 -0
  40. package/src/rules/list-stacked-interpunct-runs.ts +80 -0
  41. package/src/rules/punctuation-em-dash-in-prose.ts +155 -0
  42. package/src/rules/punctuation-interpunct-in-prose.ts +52 -0
  43. package/src/rules/registry.ts +133 -0
  44. package/src/rules/sentence-one-per-line.ts +99 -0
  45. package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +22 -20
  46. package/src/rules/types.ts +70 -25
  47. package/src/testing.ts +60 -0
  48. package/src/text.ts +50 -0
  49. package/src/fix/spans.ts +0 -75
  50. package/src/rules/interpunct.ts +0 -19
  51. package/src/rules/pg001-wrap.ts +0 -79
  52. package/src/rules/pg003-semicolon-list.ts +0 -68
  53. package/src/rules/pg004-em-dash.ts +0 -117
  54. package/src/rules/pg005-interpunct.ts +0 -43
  55. package/src/rules/pg006-interpunct-run.ts +0 -84
  56. package/src/rules/pg007-inline-enum.ts +0 -119
  57. package/src/rules/pg008-labelled-run.ts +0 -94
  58. package/src/rules/pg009-stacked-runs.ts +0 -79
@@ -1,117 +0,0 @@
1
- // PG004: 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 { matchesIn } from "../fix/spans.ts";
7
- import type { DocModel, Range, TextView } from "../model.ts";
8
- import { type Edit, RULE, type Rule } from "./types.ts";
9
-
10
- // Any horizontal space around the dash belongs to it, including no-break and
11
- // 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;
13
-
14
- // An aside that reads as an enumeration marker, "(b)", would plant a PG007
15
- // marker and hide the word from the content fingerprint.
16
- const MARKER_LIKE = /^(?:[a-z]|[ivx]+|\d+)$/i;
17
-
18
- interface Dash {
19
- range: Range; // the dash with its surrounding spaces
20
- text: TextView;
21
- }
22
-
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);
32
- if (found === null) entry.refused = true;
33
- else entry.dashes.push(...found.map((range) => ({ range, text: t })));
34
- }
35
-
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
-
54
- for (const [sentence, group] of groups) {
55
- const edit = rewrite(src, group, sentence, sentenceOf, colons);
56
- if (edit) edits.push(edit);
57
- }
58
- }
59
- return edits;
60
- };
61
-
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;
93
- }
94
- return {
95
- rule: RULE.EM_DASH,
96
- start: first.range[0],
97
- end: last.range[1],
98
- text,
99
- expect: { kind: "same-shape" },
100
- };
101
- };
102
-
103
- export const pg004: Rule = {
104
- id: RULE.EM_DASH,
105
- category: "punctuation",
106
- 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
- })),
116
- fix,
117
- };
@@ -1,43 +0,0 @@
1
- // PG005: a stray interpunct in prose. One inside a reported run is left to
2
- // PG006 or PG009. 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 { matchesIn } from "../fix/spans.ts";
6
- import { type DocModel, inRanges } from "../model.ts";
7
- import { interpunctRuns } from "./interpunct.ts";
8
- import { type Edit, RULE, type Rule } from "./types.ts";
9
-
10
- const fix = (doc: DocModel): Edit[] => {
11
- const runs = interpunctRuns(doc).map((r) => r.range);
12
- 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" } });
22
- }
23
- }
24
- return edits;
25
- };
26
-
27
- export const pg005: Rule = {
28
- id: RULE.INTERPUNCT,
29
- category: "punctuation",
30
- 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
- },
42
- fix,
43
- };
@@ -1,84 +0,0 @@
1
- // PG006: 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 { 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";
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. 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
- };
26
-
27
- const fix = (doc: DocModel): Edit[] => {
28
- const { src } = doc;
29
- 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();
47
- if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset)) continue;
48
-
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
- });
54
- if (tailIsProse(items) || (!colon && headIsProse(items))) continue;
55
-
56
- const edit = promote({
57
- rule: RULE.INTERPUNCT_RUN,
58
- src,
59
- view,
60
- span,
61
- leadIn: colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
62
- items: items.map((text) => ({ text })),
63
- ordered: false,
64
- });
65
- if (edit) edits.push(edit);
66
- }
67
- return edits;
68
- };
69
-
70
- export const pg006: Rule = {
71
- id: RULE.INTERPUNCT_RUN,
72
- category: "list",
73
- 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
- })),
83
- fix,
84
- };
@@ -1,119 +0,0 @@
1
- // PG007: 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 { 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";
10
-
11
- const ENUM_FAMILIES: Array<[string, string]> = [
12
- ["(a)", "(b)"],
13
- ["(1)", "(2)"],
14
- ["(i)", "(ii)"],
15
- ];
16
-
17
- const ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii"];
18
- const LETTERS = [..."abcdefghijklmnopqrstuvwxyz"];
19
-
20
- // 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;
25
- return null;
26
- };
27
-
28
- const MARKER = /\(([a-z]|[ivx]+|\d{1,2})\)(?=\s)/g;
29
-
30
- interface Marker {
31
- range: Range;
32
- label: string;
33
- }
34
-
35
- // Markers that open an item: preceded by whitespace or the start of a direct
36
- // text node, and followed by whitespace. The parsed value must agree with the
37
- // 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 });
49
- }
50
- }
51
- return out;
52
- };
53
-
54
- const fix = (doc: DocModel): Edit[] => {
55
- const { src } = doc;
56
- const edits: Edit[] = [];
57
- for (const view of doc.paragraphs) {
58
- const markers = markersIn(src, view.directTexts);
59
- 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;
62
-
63
- const first = markers[0] as Marker;
64
- const last = markers[markers.length - 1] as Marker;
65
- // 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])),
68
- );
69
- if (between.some((s) => /[.!?]\s/.test(s))) continue;
70
- const span = sentenceSpan(src, view, first.range[0], last.range[1]);
71
- // 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
- });
80
- const edit = promote({
81
- rule: RULE.INLINE_ENUM,
82
- src,
83
- view,
84
- span,
85
- leadIn: cleanLeadIn(src.slice(span[0], first.range[0])),
86
- items,
87
- ordered: true,
88
- });
89
- if (edit) edits.push(edit);
90
- }
91
- return edits;
92
- };
93
-
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
- export const pg007: Rule = {
114
- id: RULE.INLINE_ENUM,
115
- category: "list",
116
- summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
117
- check,
118
- fix,
119
- };
@@ -1,94 +0,0 @@
1
- // PG008: one sentence stringing 3+ comma-separated segments each led by a
2
- // label (an inline-code span, or an identifier like PG001 / MD025 / S4)
3
- // WITH content after it is a hidden list of labelled items. Bare labels
4
- // ("A, B, and C are interchangeable") are a subject enumeration, not a
5
- // list. An optional "Rules: " style intro on the first segment, and a
6
- // joining "and"/"or", are tolerated.
7
- //
8
- // The check counts labelled segments; the fix is stricter and needs every
9
- // segment of the sentence to be one, so no stray clause becomes a bullet.
10
-
11
- import { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
12
- import { collapse, matchesInAll, sentenceSpans, within } from "../fix/spans.ts";
13
- import { type DocModel, type ParagraphView, type Range, sentences } from "../model.ts";
14
- import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
15
-
16
- const LABELLED_SEGMENT = /^(?:and\s+|or\s+)?(?:[A-Za-z][\w\s-]{0,24}:\s*)?(?:CODE|[A-Z][A-Za-z]*\d[\w/-]*)\s+\S/;
17
-
18
- // A source slice as the check sees it: code spans become one CODE token.
19
- const asProse = (src: string, view: ParagraphView, range: Range): string => {
20
- let out = "";
21
- let at = range[0];
22
- for (const [a, b] of view.codeRanges) {
23
- if (b <= range[0] || a >= range[1]) continue;
24
- out += `${src.slice(at, a)}CODE`;
25
- at = b;
26
- }
27
- return collapse(out + src.slice(at, range[1])).trim();
28
- };
29
-
30
- const fix = (doc: DocModel): Edit[] => {
31
- const { src } = doc;
32
- const edits: Edit[] = [];
33
- for (const view of doc.paragraphs) {
34
- const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
35
- const colons = matchesInAll(src, view.directTexts, /:/);
36
- if (commas === null || colons === null) continue;
37
- for (const span of sentenceSpans(src, view)) {
38
- const inside = commas.filter((r) => within(r, span));
39
- if (inside.length < 2) continue;
40
- const bounds: Range[] = inside.map((c, i) => [i === 0 ? span[0] : (inside[i - 1] as Range)[1], c[0]]);
41
- bounds.push([(inside[inside.length - 1] as Range)[1], span[1]]);
42
- if (!bounds.every((b) => LABELLED_SEGMENT.test(asProse(src, view, b)))) continue;
43
-
44
- // An intro colon in the first segment, before its label, is the lead-in.
45
- const firstBound = bounds[0] as Range;
46
- const colon = colons.find((c) => within(c, firstBound));
47
- const intro = colon && /^[A-Za-z][\w\s-]{0,24}$/.test(collapse(src.slice(span[0], colon[0])).trim());
48
- if (intro && colon) firstBound[0] = colon[1];
49
-
50
- const items = bounds.map((b, i) => ({
51
- text: cleanItem(src.slice(b[0], b[1]), { last: i + 1 === bounds.length }),
52
- }));
53
- const edit = promote({
54
- rule: RULE.LABELLED_RUN,
55
- src,
56
- view,
57
- span,
58
- leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
59
- items,
60
- ordered: false,
61
- });
62
- if (edit) edits.push(edit);
63
- break;
64
- }
65
- }
66
- return edits;
67
- };
68
-
69
- const check: Rule["check"] = ({ doc, file }) => {
70
- const findings: Finding[] = [];
71
- for (const p of doc.paragraphs) {
72
- if (p.inTable) continue;
73
- for (const s of sentences(p.prose)) {
74
- const labelled = s.split(/,\s+/).filter((seg) => LABELLED_SEGMENT.test(seg)).length;
75
- if (labelled >= 3) {
76
- findings.push({
77
- file,
78
- line: p.line,
79
- rule: RULE.LABELLED_RUN,
80
- message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`,
81
- });
82
- }
83
- }
84
- }
85
- return findings;
86
- };
87
-
88
- export const pg008: Rule = {
89
- id: RULE.LABELLED_RUN,
90
- category: "list",
91
- summary: "comma-joined labelled run (3+ labelled segments)",
92
- check,
93
- fix,
94
- };
@@ -1,79 +0,0 @@
1
- // PG009: interpunct runs stacked on 2+ source lines of one paragraph are a
2
- // two-level structure, so PG009 replaces PG006 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 { cleanItem, type PromotedItem, promote } from "../fix/promote.ts";
7
- import { collapse, escaped, matchesInAll, within } from "../fix/spans.ts";
8
- import type { DocModel, Range } from "../model.ts";
9
- import { interpunctRuns } from "./interpunct.ts";
10
- import { type Edit, RULE, type Rule } from "./types.ts";
11
-
12
- const fix = (doc: DocModel): Edit[] => {
13
- const { src } = doc;
14
- const edits: Edit[] = [];
15
- const runs = interpunctRuns(doc).filter((r) => r.lines >= 2);
16
- for (const view of doc.paragraphs) {
17
- if (!runs.some((r) => r.range[0] === view.startOffset)) continue;
18
- const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
19
- const colons = matchesInAll(src, view.directTexts, /:/);
20
- if (
21
- seps === null ||
22
- colons === null ||
23
- seps.length !== (view.prose.match(/·/g) ?? []).length ||
24
- seps.some(([s]) => escaped(src, s))
25
- )
26
- continue;
27
-
28
- // Source lines of the paragraph, as absolute ranges.
29
- const lines: Range[] = [];
30
- let at = view.startOffset;
31
- for (const line of view.raw.split("\n")) {
32
- lines.push([at, at + line.length]);
33
- at += line.length + 1;
34
- }
35
-
36
- const parents: PromotedItem[] = [];
37
- for (const line of lines) {
38
- const lineSeps = seps.filter((s) => within(s, line));
39
- const colon = colons.find((c) => within(c, line));
40
- if (lineSeps.length === 0 || !colon || colon[1] > (lineSeps[0] as Range)[0]) break;
41
- const label = collapse(src.slice(line[0], colon[0])).trim();
42
- const cuts: Range[] = [colon, ...lineSeps];
43
- const children = cuts.map((cut, i) => {
44
- const end = i + 1 < cuts.length ? (cuts[i + 1] as Range)[0] : line[1];
45
- return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
46
- });
47
- parents.push({ text: label, children });
48
- }
49
- if (parents.length !== lines.length) continue;
50
-
51
- const edit = promote({
52
- rule: RULE.STACKED_RUNS,
53
- src,
54
- view,
55
- span: [view.startOffset, view.endOffset],
56
- leadIn: null,
57
- items: parents,
58
- ordered: false,
59
- });
60
- if (edit) edits.push(edit);
61
- }
62
- return edits;
63
- };
64
-
65
- export const pg009: Rule = {
66
- id: RULE.STACKED_RUNS,
67
- category: "list",
68
- summary: "interpunct runs stacked on several lines (nested list)",
69
- check: ({ file, runs }) =>
70
- runs
71
- .filter((r) => r.lines >= 2)
72
- .map((r) => ({
73
- file,
74
- line: r.line,
75
- rule: RULE.STACKED_RUNS,
76
- message: `interpunct runs stacked on ${r.lines} lines; promote to a nested list`,
77
- })),
78
- fix,
79
- };