@jpeakai/prose-gates 0.1.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/src/model.ts ADDED
@@ -0,0 +1,165 @@
1
+ // The document model: markdown parsed to mdast once, then flattened into the
2
+ // views every rule and fixer reads. Code spans, code blocks, tables and
3
+ // frontmatter are exempt by construction, never by pattern matching.
4
+
5
+ import type { Code, InlineCode, Node, Paragraph, Parent, Root, Text } from "mdast";
6
+ import { fromMarkdown } from "mdast-util-from-markdown";
7
+ import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
8
+ import { gfmFromMarkdown } from "mdast-util-gfm";
9
+ import { frontmatter } from "micromark-extension-frontmatter";
10
+ import { gfm } from "micromark-extension-gfm";
11
+ import { visitParents } from "unist-util-visit-parents";
12
+
13
+ // Fenced blocks in these languages hold markdown templates (a conventions or
14
+ // skeleton file shown verbatim): their prose is audited as embedded markdown
15
+ // instead of being exempt as code. Check-only, so no fix rewrites through a
16
+ // fence boundary.
17
+ const MARKDOWN_FENCE_LANGS = new Set(["markdown", "md"]);
18
+
19
+ export type Range = [number, number];
20
+
21
+ // A text node that is a direct child of its paragraph. Only these carry
22
+ // separators a fixer may split on: text inside a link, emphasis or code is
23
+ // cargo and moves whole.
24
+ export interface DirectText {
25
+ value: string;
26
+ start: number;
27
+ end: number;
28
+ }
29
+
30
+ // One paragraph with everything the rules need, derived in a single walk.
31
+ export interface ParagraphView {
32
+ node: Paragraph;
33
+ raw: string; // exact source slice
34
+ line: number;
35
+ startOffset: number;
36
+ endOffset: number;
37
+ startColumn: number;
38
+ prose: string; // text values joined; inline code becomes one CODE token
39
+ codeRanges: Range[]; // inline-code offsets in src
40
+ directTexts: DirectText[];
41
+ hasBreak: boolean; // a hard line break is structure, so reflow leaves it
42
+ inTable: boolean; // check rules skip table cells
43
+ inBlockquote: boolean;
44
+ inListItem: boolean;
45
+ onlyChild: boolean; // the paragraph is its parent's only block
46
+ }
47
+
48
+ export interface TextView {
49
+ value: string;
50
+ line: number;
51
+ offset: number | undefined;
52
+ start: number | undefined;
53
+ end: number | undefined;
54
+ block: Node | undefined; // nearest paragraph, heading or table cell
55
+ }
56
+
57
+ // A fenced code block tagged markdown/md: a template whose body is itself
58
+ // markdown prose, audited recursively.
59
+ export interface FenceView {
60
+ value: string; // fence body (starts on the line after the opening fence)
61
+ line: number; // line of the opening fence
62
+ }
63
+
64
+ export interface DocModel {
65
+ src: string;
66
+ tree: Root;
67
+ paragraphs: ParagraphView[];
68
+ texts: TextView[]; // every prose text node, including headings and lists
69
+ fences: FenceView[]; // embedded-markdown fences, audited recursively
70
+ }
71
+
72
+ export const parse = (src: string): Root =>
73
+ // Frontmatter is metadata, not prose. Without this extension a leading
74
+ // `---` block parses as a thematicBreak followed by a setext heading whose
75
+ // text is the raw YAML, so every text rule fires on it.
76
+ fromMarkdown(src, {
77
+ extensions: [frontmatter(["yaml", "toml"]), gfm()],
78
+ mdastExtensions: [frontmatterFromMarkdown(["yaml", "toml"]), gfmFromMarkdown()],
79
+ });
80
+
81
+ export const buildDocModel = (src: string): DocModel => {
82
+ const tree = parse(src);
83
+ const paragraphs: ParagraphView[] = [];
84
+ const texts: TextView[] = [];
85
+ const fences: FenceView[] = [];
86
+ const viewOf = new Map<Paragraph, ParagraphView>();
87
+ const proseParts = new Map<Paragraph, string[]>();
88
+
89
+ visitParents(tree, (node: Node, ancestors: Node[]) => {
90
+ if (node.type === "code") {
91
+ const c = node as Code;
92
+ if (c.lang != null && MARKDOWN_FENCE_LANGS.has(c.lang) && c.position) {
93
+ fences.push({ value: c.value, line: c.position.start.line });
94
+ }
95
+ return;
96
+ }
97
+ const parent = ancestors.at(-1) as Parent | undefined;
98
+ if (node.type === "paragraph") {
99
+ const p = node as Paragraph;
100
+ const pos = p.position;
101
+ if (!pos || pos.start.offset == null || pos.end.offset == null) return;
102
+ const view: ParagraphView = {
103
+ node: p,
104
+ raw: src.slice(pos.start.offset, pos.end.offset),
105
+ line: pos.start.line,
106
+ startOffset: pos.start.offset,
107
+ endOffset: pos.end.offset,
108
+ startColumn: pos.start.column,
109
+ prose: "",
110
+ codeRanges: [],
111
+ directTexts: [],
112
+ hasBreak: false,
113
+ inTable: ancestors.some((a) => a.type === "table" || a.type === "tableCell"),
114
+ inBlockquote: ancestors.some((a) => a.type === "blockquote"),
115
+ inListItem: ancestors.some((a) => a.type === "listItem"),
116
+ onlyChild: parent !== undefined && parent.children.length === 1,
117
+ };
118
+ paragraphs.push(view);
119
+ viewOf.set(p, view);
120
+ proseParts.set(p, []);
121
+ }
122
+ const paragraph = ancestors.findLast((a): a is Paragraph => a.type === "paragraph");
123
+ const view = paragraph ? viewOf.get(paragraph) : undefined;
124
+ if (node.type === "text") {
125
+ const t = node as Text;
126
+ const start = t.position?.start.offset;
127
+ const end = t.position?.end.offset;
128
+ texts.push({
129
+ value: t.value,
130
+ line: t.position?.start.line ?? 0,
131
+ offset: start,
132
+ start,
133
+ end,
134
+ block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell"),
135
+ });
136
+ if (paragraph) proseParts.get(paragraph)?.push(t.value);
137
+ if (view && parent === paragraph && start != null && end != null) {
138
+ view.directTexts.push({ value: t.value, start, end });
139
+ }
140
+ } else if (node.type === "inlineCode" && view) {
141
+ const c = node as InlineCode;
142
+ proseParts.get(paragraph as Paragraph)?.push("CODE");
143
+ if (c.position?.start.offset != null && c.position.end.offset != null) {
144
+ view.codeRanges.push([c.position.start.offset, c.position.end.offset]);
145
+ }
146
+ } else if (node.type === "break" && view) {
147
+ view.hasBreak = true;
148
+ }
149
+ });
150
+
151
+ for (const [p, parts] of proseParts) {
152
+ const view = viewOf.get(p);
153
+ if (view) view.prose = parts.join(" ").replace(/\s+/g, " ").trim();
154
+ }
155
+ return { src, tree, paragraphs, texts, fences };
156
+ };
157
+
158
+ export const inRanges = (offset: number, ranges: Range[]): boolean =>
159
+ ranges.some(([a, b]) => offset >= a && offset < b);
160
+
161
+ export const sentences = (text: string): string[] =>
162
+ text
163
+ .split(/(?<=[.!?])\s+/)
164
+ .map((s) => s.trim())
165
+ .filter((s) => s.length > 0);
@@ -0,0 +1,21 @@
1
+ // The rule catalogue, in id order. To add a rule: write one module exporting
2
+ // a Rule with its category, register it here, and add its section and
3
+ // examples to RULES.md, which tests/rules-doc.test.ts holds to the code.
4
+
5
+ import { pg001 } from "./pg001-wrap.ts";
6
+ import { pg002 } from "./pg002-length.ts";
7
+ import { pg003 } from "./pg003-semicolon-list.ts";
8
+ import { pg004 } from "./pg004-em-dash.ts";
9
+ import { pg005 } from "./pg005-interpunct.ts";
10
+ import { pg006 } from "./pg006-interpunct-run.ts";
11
+ import { pg007 } from "./pg007-inline-enum.ts";
12
+ import { pg008 } from "./pg008-labelled-run.ts";
13
+ import { pg009 } from "./pg009-stacked-runs.ts";
14
+ import type { Rule } from "./types.ts";
15
+
16
+ export const RULES: Rule[] = [pg001, pg002, pg003, pg004, pg005, pg006, pg007, pg008, pg009];
17
+
18
+ // The order fixers run in. Structure first: a promotion needs the separators
19
+ // a glyph swap would erase, so PG009 and the list rules run before PG005
20
+ // turns interpuncts into commas. Reflow runs last, over the settled blocks.
21
+ export const FIX_ORDER: Rule[] = [pg009, pg007, pg008, pg006, pg003, pg005, pg004, pg001];
@@ -0,0 +1,19 @@
1
+ // Paragraphs whose prose joins a run of items with 2+ interpunct separators.
2
+ // PG006 and PG009 report the run, and PG005 stays quiet inside one, so the
3
+ // runs are computed once and passed to every check as data.
4
+
5
+ import type { DocModel } from "../model.ts";
6
+ import type { InterpunctRun } from "./types.ts";
7
+
8
+ export const interpunctRuns = (doc: DocModel): InterpunctRun[] => {
9
+ const runs: InterpunctRun[] = [];
10
+ for (const p of doc.paragraphs) {
11
+ if (p.inTable) continue;
12
+ const separators = (p.prose.match(/·/g) ?? []).length;
13
+ if (separators >= 2) {
14
+ const lines = p.raw.split("\n").filter((l) => l.includes("·")).length;
15
+ runs.push({ range: [p.startOffset, p.endOffset], line: p.line, separators, lines });
16
+ }
17
+ }
18
+ return runs;
19
+ };
@@ -0,0 +1,79 @@
1
+ // PG001: a newline inside a paragraph whose preceding non-space character
2
+ // does not end a sentence is a mid-sentence wrap. The fix reflows the
3
+ // paragraph to one sentence per line.
4
+
5
+ import { type DocModel, inRanges } from "../model.ts";
6
+ import { interpunctRuns } from "./interpunct.ts";
7
+ import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
8
+
9
+ const TERMINALS = new Set([".", "!", "?", ":", ";"]);
10
+
11
+ const check: Rule["check"] = ({ doc, file }) => {
12
+ const findings: Finding[] = [];
13
+ for (const p of doc.paragraphs) {
14
+ if (p.inTable) continue;
15
+ for (let i = 0; i < p.raw.length; i++) {
16
+ if (p.raw[i] !== "\n") continue;
17
+ if (inRanges(p.startOffset + i, p.codeRanges)) continue;
18
+ let j = i - 1;
19
+ while (j >= 0 && (p.raw[j] === " " || p.raw[j] === "\t" || p.raw[j] === "\r" || p.raw[j] === "\\")) j--;
20
+ if (j >= 0 && !TERMINALS.has(p.raw[j] as string)) {
21
+ const line = p.line + p.raw.slice(0, i).split("\n").length - 1;
22
+ findings.push({ file, line, rule: RULE.WRAP, message: "mid-sentence line wrap; write one sentence per line" });
23
+ }
24
+ }
25
+ }
26
+ return findings;
27
+ };
28
+
29
+ const fix = (doc: DocModel): Edit[] => {
30
+ const edits: Edit[] = [];
31
+ // Interpunct runs stacked on several lines are a nested list PG009 either
32
+ // promotes or leaves for a human. Joining the lines would destroy the
33
+ // structure and hand PG006 a flat run it would merge wrongly.
34
+ const stacked = interpunctRuns(doc)
35
+ .filter((r) => r.lines >= 2)
36
+ .map((r) => r.range[0]);
37
+ for (const p of doc.paragraphs) {
38
+ // A hard break is structure the author chose, so the paragraph is left
39
+ // exactly as written.
40
+ if (p.inTable || p.inBlockquote || p.hasBreak || stacked.includes(p.startOffset)) continue;
41
+
42
+ // Continuation lines keep the paragraph's own indent column, and the
43
+ // paragraph's own line ending.
44
+ const indent = " ".repeat(p.startColumn - 1);
45
+ const eol = p.raw.includes("\r\n") ? "\r\n" : "\n";
46
+
47
+ // Mask code spans character for character, keeping whitespace, so the
48
+ // same join applies to both strings and offsets stay aligned. A terminal
49
+ // inside a code span then never splits a sentence.
50
+ const masked = [...p.raw]
51
+ .map((ch, i) => (inRanges(p.startOffset + i, p.codeRanges) && !/\s/.test(ch) ? "x" : ch))
52
+ .join("");
53
+ const join = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
54
+ const joined = join(p.raw);
55
+ const joinedMask = join(masked);
56
+
57
+ const out: string[] = [];
58
+ let last = 0;
59
+ for (const m of joinedMask.matchAll(/[.!?]\s+(?=[A-Z`"'([])/g)) {
60
+ const at = m.index ?? 0;
61
+ out.push(joined.slice(last, at + 1));
62
+ last = at + m[0].length;
63
+ }
64
+ out.push(joined.slice(last));
65
+ const text = out.map((s, i) => (i === 0 ? s.trim() : indent + s.trim())).join(eol);
66
+ if (text !== p.raw) {
67
+ edits.push({ rule: RULE.WRAP, start: p.startOffset, end: p.endOffset, text, expect: { kind: "same-tree" } });
68
+ }
69
+ }
70
+ return edits;
71
+ };
72
+
73
+ export const pg001: Rule = {
74
+ id: RULE.WRAP,
75
+ category: "sentence",
76
+ summary: "mid-sentence line wrap (one sentence per line)",
77
+ check,
78
+ fix,
79
+ };
@@ -0,0 +1,44 @@
1
+ // PG002: sentences over the word budget are hard to unpack, especially for
2
+ // ESL readers and translators. Never autofixed: shortening a sentence is a
3
+ // rewrite, and a rewrite needs discretion. The message estimates the clauses
4
+ // in the sentence so the rewrite splits it rather than compressing it.
5
+
6
+ import { sentences } from "../model.ts";
7
+ import { type Finding, RULE, type Rule } from "./types.ts";
8
+
9
+ // A clause boundary is a semicolon or colon with text after it, a comma
10
+ // before a coordinating conjunction, or a subordinating or relative word.
11
+ // ", which" matches once, through the subordinator alone. The count is a
12
+ // guide, not a parse: an Oxford comma in a list reads as a boundary too.
13
+ const CLAUSE_BOUNDARY =
14
+ /[;:](?=\s+\S)|,\s+(?:and|but|or|so|yet|nor)\b|\b(?:because|although|though|whereas|unless|whenever|wherever|while|when|if|which|who|whom|whose)\b/gi;
15
+
16
+ export const countClauses = (sentence: string): number => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
17
+
18
+ const excerpt = (s: string): string => (s.length > 60 ? `${s.slice(0, 60)}...` : s);
19
+
20
+ export const lengthMessage = (sentence: string, maxWords: number): string => {
21
+ const words = sentence.split(/\s+/).length;
22
+ const clauses = countClauses(sentence);
23
+ const target = Math.max(2, clauses, Math.ceil(words / maxWords));
24
+ const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
25
+ return `sentence has ${words} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
26
+ };
27
+
28
+ export const pg002: Rule = {
29
+ id: RULE.LENGTH,
30
+ category: "sentence",
31
+ summary: "sentence longer than the word budget (default 25)",
32
+ check: ({ doc, file, maxWords }) => {
33
+ const findings: Finding[] = [];
34
+ for (const p of doc.paragraphs) {
35
+ if (p.inTable) continue;
36
+ for (const s of sentences(p.prose)) {
37
+ if (s.split(/\s+/).length > maxWords) {
38
+ findings.push({ file, line: p.line, rule: RULE.LENGTH, message: lengthMessage(s, maxWords) });
39
+ }
40
+ }
41
+ }
42
+ return findings;
43
+ },
44
+ };
@@ -0,0 +1,68 @@
1
+ // PG003: a run of items joined by semicolons is a wall of text hiding a
2
+ // list. The fix needs a colon lead-in to know where the list starts, and
3
+ // refuses without one.
4
+
5
+ import { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
6
+ import { matchesInAll, sentenceSpans, within } from "../fix/spans.ts";
7
+ import { type DocModel, sentences } from "../model.ts";
8
+ import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
9
+
10
+ const check: Rule["check"] = ({ doc, file }) => {
11
+ const findings: Finding[] = [];
12
+ for (const p of doc.paragraphs) {
13
+ if (p.inTable) continue;
14
+ for (const s of sentences(p.prose)) {
15
+ const semis = (s.match(/;/g) ?? []).length;
16
+ if (semis >= 2) {
17
+ findings.push({
18
+ file,
19
+ line: p.line,
20
+ rule: RULE.SEMICOLON_LIST,
21
+ message: `semicolon-delimited list (${semis} ';'); promote to a bullet list`,
22
+ });
23
+ }
24
+ }
25
+ }
26
+ return findings;
27
+ };
28
+
29
+ const fix = (doc: DocModel): Edit[] => {
30
+ const edits: Edit[] = [];
31
+ const { src } = doc;
32
+ for (const view of doc.paragraphs) {
33
+ const semis = matchesInAll(src, view.directTexts, /;/);
34
+ const colons = matchesInAll(src, view.directTexts, /:/);
35
+ if (semis === null || colons === null) continue;
36
+ for (const span of sentenceSpans(src, view)) {
37
+ const inside = semis.filter((r) => within(r, span));
38
+ if (inside.length < 2) continue;
39
+ const colon = colons.find((r) => within(r, span) && r[1] <= (inside[0] as [number, number])[0]);
40
+ if (!colon) continue;
41
+ const cuts = [colon, ...inside];
42
+ const items = cuts.map((cut, i) => {
43
+ const end = i + 1 < cuts.length ? (cuts[i + 1] as [number, number])[0] : span[1];
44
+ return { text: cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length }) };
45
+ });
46
+ const edit = promote({
47
+ rule: RULE.SEMICOLON_LIST,
48
+ src,
49
+ view,
50
+ span,
51
+ leadIn: cleanLeadIn(src.slice(span[0], colon[0])),
52
+ items,
53
+ ordered: false,
54
+ });
55
+ if (edit) edits.push(edit);
56
+ break; // one promotion per paragraph; the engine re-parses and comes back
57
+ }
58
+ }
59
+ return edits;
60
+ };
61
+
62
+ export const pg003: Rule = {
63
+ id: RULE.SEMICOLON_LIST,
64
+ category: "list",
65
+ summary: "semicolon-delimited list (2+ ';' in one sentence)",
66
+ check,
67
+ fix,
68
+ };
@@ -0,0 +1,117 @@
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
+ };
@@ -0,0 +1,43 @@
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
+ };
@@ -0,0 +1,84 @@
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
+ };