@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.
- package/README.md +1 -0
- 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
package/package.json
CHANGED
package/src/check.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
// embedded markdown fences as documents of their own.
|
|
3
3
|
|
|
4
4
|
import { buildDocModel, type DocModel } from "./model.ts";
|
|
5
|
-
import { RULES } from "./rules/index.ts";
|
|
6
|
-
import { interpunctRuns } from "./rules/interpunct.ts";
|
|
5
|
+
import { RULES, ruleContext } from "./rules/index.ts";
|
|
7
6
|
import type { Finding } from "./rules/types.ts";
|
|
8
7
|
|
|
9
8
|
export const DEFAULT_MAX_WORDS = 25;
|
|
10
9
|
|
|
11
10
|
export const checkModel = (doc: DocModel, file: string, maxWords: number): Finding[] => {
|
|
12
|
-
const ctx = { doc, file, maxWords
|
|
11
|
+
const ctx = { ...ruleContext(doc), file, maxWords };
|
|
13
12
|
const findings = RULES.flatMap((rule) => rule.check(ctx));
|
|
14
13
|
// The fence body starts one line below the opening fence.
|
|
15
14
|
for (const fence of doc.fences) {
|
package/src/fix/engine.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// source they were computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
6
|
import { buildDocModel, parse } from "../model.ts";
|
|
7
|
-
import { FIX_ORDER } from "../rules/index.ts";
|
|
7
|
+
import { FIX_ORDER, ruleContext } from "../rules/index.ts";
|
|
8
8
|
import type { Edit } from "../rules/types.ts";
|
|
9
9
|
import { verify } from "./verify.ts";
|
|
10
10
|
|
|
@@ -35,8 +35,8 @@ export const fixMarkdownReport = (src: string): FixReport => {
|
|
|
35
35
|
let current = src;
|
|
36
36
|
|
|
37
37
|
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
38
|
-
const
|
|
39
|
-
const before = { src: current, tree: doc.tree };
|
|
38
|
+
const ctx = ruleContext(buildDocModel(current));
|
|
39
|
+
const before = { src: current, tree: ctx.doc.tree };
|
|
40
40
|
const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
41
41
|
|
|
42
42
|
const attempt = (edits: Edit[]): boolean => {
|
|
@@ -52,7 +52,7 @@ export const fixMarkdownReport = (src: string): FixReport => {
|
|
|
52
52
|
|
|
53
53
|
let progressed = false;
|
|
54
54
|
for (const rule of FIX_ORDER) {
|
|
55
|
-
const edits = (rule.fix?.(
|
|
55
|
+
const edits = (rule.fix?.(ctx) ?? []).filter(
|
|
56
56
|
(e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)),
|
|
57
57
|
);
|
|
58
58
|
if (edits.length === 0) continue;
|
package/src/fix/promote.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// markdown list. Item text is sliced from the source, never re-serialised,
|
|
4
4
|
// so links, emphasis, escapes and code spans travel unchanged.
|
|
5
5
|
|
|
6
|
-
import type { ParagraphView
|
|
6
|
+
import type { ParagraphView } from "../model.ts";
|
|
7
7
|
import type { Edit, RuleId } from "../rules/types.ts";
|
|
8
|
-
import { collapse } from "
|
|
8
|
+
import { collapse, type Range, spansBetween } from "../text.ts";
|
|
9
9
|
|
|
10
10
|
export interface PromotedItem {
|
|
11
11
|
text: string;
|
|
@@ -46,6 +46,18 @@ export const cleanItem = (raw: string, { last }: { last: boolean }): string => {
|
|
|
46
46
|
return s;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// A run of source spans as cleaned item text, ready to promote. Only the
|
|
50
|
+
// last item may shed a joining conjunction or a trailing full stop, so the
|
|
51
|
+
// spans must arrive in document order.
|
|
52
|
+
export const itemsOf = (src: string, spans: Range[]): string[] =>
|
|
53
|
+
spans.map((span, i) => cleanItem(src.slice(span[0], span[1]), { last: i + 1 === spans.length }));
|
|
54
|
+
|
|
55
|
+
// The spans between a run of cut points, as cleaned item text. This is the
|
|
56
|
+
// shared body of every hidden-list fixer: the rule decides where the cuts
|
|
57
|
+
// are, and this decides what each item says.
|
|
58
|
+
export const itemsBetween = (src: string, cuts: Range[], end: number): string[] =>
|
|
59
|
+
itemsOf(src, spansBetween(cuts, end));
|
|
60
|
+
|
|
49
61
|
export const cleanLeadIn = (raw: string): string | null => {
|
|
50
62
|
const s = collapse(raw)
|
|
51
63
|
.trim()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The interpunct run: the one piece of data derived from a whole document
|
|
2
|
+
// that more than one rule reads. PG006 and PG009 report a run, PG005 stays
|
|
3
|
+
// quiet inside one, and PG001 leaves a stacked one unreflowed, so it is
|
|
4
|
+
// derived once per document and handed to every rule as data.
|
|
5
|
+
|
|
6
|
+
import type { DocModel, ParagraphView } from "./model.ts";
|
|
7
|
+
import { proseParagraphs } from "./query.ts";
|
|
8
|
+
import { countOf, type Range } from "./text.ts";
|
|
9
|
+
|
|
10
|
+
export const INTERPUNCT = "·";
|
|
11
|
+
|
|
12
|
+
// The glyph on its own, for counting, and the glyph with the space a run
|
|
13
|
+
// puts around it, for splitting. PG006 and PG009 both cut on the separator;
|
|
14
|
+
// PG005 is stricter and needs space on both sides, so it keeps its own.
|
|
15
|
+
export const GLYPH = /·/g;
|
|
16
|
+
export const RUN_SEPARATOR = /[ \t]*·[ \t]*/;
|
|
17
|
+
|
|
18
|
+
// A paragraph whose prose joins a run of items with 2+ interpunct separators.
|
|
19
|
+
export interface InterpunctRun {
|
|
20
|
+
range: Range;
|
|
21
|
+
line: number;
|
|
22
|
+
separators: number;
|
|
23
|
+
lines: number; // source lines of the paragraph that carry a separator
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const interpunctRuns = (doc: DocModel): InterpunctRun[] => {
|
|
27
|
+
const runs: InterpunctRun[] = [];
|
|
28
|
+
for (const view of proseParagraphs(doc)) {
|
|
29
|
+
const separators = countOf(view.prose, GLYPH);
|
|
30
|
+
if (separators < 2) continue;
|
|
31
|
+
const lines = view.raw.split("\n").filter((line) => line.includes(INTERPUNCT)).length;
|
|
32
|
+
runs.push({ range: [view.startOffset, view.endOffset], line: view.line, separators, lines });
|
|
33
|
+
}
|
|
34
|
+
return runs;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// A run on one source line is a flat list, which PG006 owns. A run spread
|
|
38
|
+
// over two or more lines is a two-level structure, which PG009 owns and
|
|
39
|
+
// PG001 leaves unreflowed. The two predicates partition every run.
|
|
40
|
+
export const isFlat = (run: InterpunctRun): boolean => run.lines === 1;
|
|
41
|
+
|
|
42
|
+
export const isStacked = (run: InterpunctRun): boolean => run.lines >= 2;
|
|
43
|
+
|
|
44
|
+
// The paragraphs those runs were derived from. A run is keyed by the
|
|
45
|
+
// paragraph it spans, so this is how a rule gets from the runs it was handed
|
|
46
|
+
// back to the views its fixer needs.
|
|
47
|
+
export const paragraphsOf = (doc: DocModel, runs: InterpunctRun[]): ParagraphView[] =>
|
|
48
|
+
doc.paragraphs.filter((view) => runs.some((run) => run.range[0] === view.startOffset));
|
package/src/model.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
|
9
9
|
import { frontmatter } from "micromark-extension-frontmatter";
|
|
10
10
|
import { gfm } from "micromark-extension-gfm";
|
|
11
11
|
import { visitParents } from "unist-util-visit-parents";
|
|
12
|
+
import type { Range } from "./text.ts";
|
|
12
13
|
|
|
13
14
|
// Fenced blocks in these languages hold markdown templates (a conventions or
|
|
14
15
|
// skeleton file shown verbatim): their prose is audited as embedded markdown
|
|
@@ -16,8 +17,6 @@ import { visitParents } from "unist-util-visit-parents";
|
|
|
16
17
|
// fence boundary.
|
|
17
18
|
const MARKDOWN_FENCE_LANGS = new Set(["markdown", "md"]);
|
|
18
19
|
|
|
19
|
-
export type Range = [number, number];
|
|
20
|
-
|
|
21
20
|
// A text node that is a direct child of its paragraph. Only these carry
|
|
22
21
|
// separators a fixer may split on: text inside a link, emphasis or code is
|
|
23
22
|
// cargo and moves whole.
|
|
@@ -48,7 +47,6 @@ export interface ParagraphView {
|
|
|
48
47
|
export interface TextView {
|
|
49
48
|
value: string;
|
|
50
49
|
line: number;
|
|
51
|
-
offset: number | undefined;
|
|
52
50
|
start: number | undefined;
|
|
53
51
|
end: number | undefined;
|
|
54
52
|
block: Node | undefined; // nearest paragraph, heading or table cell
|
|
@@ -128,7 +126,6 @@ export const buildDocModel = (src: string): DocModel => {
|
|
|
128
126
|
texts.push({
|
|
129
127
|
value: t.value,
|
|
130
128
|
line: t.position?.start.line ?? 0,
|
|
131
|
-
offset: start,
|
|
132
129
|
start,
|
|
133
130
|
end,
|
|
134
131
|
block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell"),
|
|
@@ -154,12 +151,3 @@ export const buildDocModel = (src: string): DocModel => {
|
|
|
154
151
|
}
|
|
155
152
|
return { src, tree, paragraphs, texts, fences };
|
|
156
153
|
};
|
|
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);
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Read-only questions about one document. Everything here takes the model, or
|
|
2
|
+
// a view out of it, and answers where something is or what may be read; none
|
|
3
|
+
// of it builds an edit. Both halves of a rule ask the same questions here, so
|
|
4
|
+
// a check and its fix can never disagree about what the document holds.
|
|
5
|
+
|
|
6
|
+
import type { DirectText, DocModel, ParagraphView, TextView } from "./model.ts";
|
|
7
|
+
import { countOf, type Range, sentences, within } from "./text.ts";
|
|
8
|
+
|
|
9
|
+
// --- what a rule may read -------------------------------------------------
|
|
10
|
+
|
|
11
|
+
// Table cells are exempt by construction, so every rule that reads running
|
|
12
|
+
// prose starts here rather than filtering for itself.
|
|
13
|
+
export const proseParagraphs = (doc: DocModel): ParagraphView[] => doc.paragraphs.filter((view) => !view.inTable);
|
|
14
|
+
|
|
15
|
+
// One sentence with the paragraph it came from, so a finding can name the
|
|
16
|
+
// line without the rule tracking it through a nested loop.
|
|
17
|
+
export interface ProseSentence {
|
|
18
|
+
view: ParagraphView;
|
|
19
|
+
sentence: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const proseSentences = (doc: DocModel): ProseSentence[] =>
|
|
23
|
+
proseParagraphs(doc).flatMap((view) => sentences(view.prose).map((sentence) => ({ view, sentence })));
|
|
24
|
+
|
|
25
|
+
// The prose text nodes holding a glyph. This is how a punctuation rule finds
|
|
26
|
+
// what it reports, and it reads headings and list items as well as
|
|
27
|
+
// paragraphs, because a stray glyph is wrong wherever it sits.
|
|
28
|
+
export const textsContaining = (doc: DocModel, glyph: string): TextView[] =>
|
|
29
|
+
doc.texts.filter((text) => text.value.includes(glyph));
|
|
30
|
+
|
|
31
|
+
// --- where something sits in the source -----------------------------------
|
|
32
|
+
|
|
33
|
+
// Absolute offsets of every match of `pattern` inside a text node, or null
|
|
34
|
+
// when the source slice and the parsed value disagree (an entity like
|
|
35
|
+
// `·` or an escape). A rule that cannot map a glyph back to the
|
|
36
|
+
// source refuses rather than guesses.
|
|
37
|
+
export const matchesIn = (src: string, text: DirectText, pattern: RegExp): Range[] | null => {
|
|
38
|
+
const slice = src.slice(text.start, text.end);
|
|
39
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
40
|
+
const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
|
|
41
|
+
const inValue = [...text.value.matchAll(new RegExp(pattern.source, flags))];
|
|
42
|
+
if (inSlice.length !== inValue.length) return null;
|
|
43
|
+
return inSlice.map((m) => [text.start + (m.index ?? 0), text.start + (m.index ?? 0) + m[0].length]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const matchesInAll = (src: string, texts: DirectText[], pattern: RegExp): Range[] | null => {
|
|
47
|
+
const out: Range[] = [];
|
|
48
|
+
for (const t of texts) {
|
|
49
|
+
const found = matchesIn(src, t, pattern);
|
|
50
|
+
if (found === null) return null;
|
|
51
|
+
out.push(...found);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// A glyph the author escaped is literal text, never a separator to rewrite.
|
|
57
|
+
export const escaped = (src: string, offset: number): boolean => src[offset - 1] === "\\";
|
|
58
|
+
|
|
59
|
+
// Every separator in a paragraph that a fixer may split on, or null when one
|
|
60
|
+
// of them cannot be trusted. A separator is untrustworthy when the parsed
|
|
61
|
+
// value and the source slice disagree (an entity like `·`), when the
|
|
62
|
+
// author escaped it, or when it sits inside a link or emphasis rather than
|
|
63
|
+
// direct text. Splitting on a subset would silently drop an item, so the
|
|
64
|
+
// fixer refuses instead.
|
|
65
|
+
export const splittableSeparators = (
|
|
66
|
+
src: string,
|
|
67
|
+
view: ParagraphView,
|
|
68
|
+
separator: RegExp,
|
|
69
|
+
glyph: RegExp,
|
|
70
|
+
): Range[] | null => {
|
|
71
|
+
const found = matchesInAll(src, view.directTexts, separator);
|
|
72
|
+
if (found === null) return null;
|
|
73
|
+
if (found.length !== countOf(view.prose, glyph)) return null;
|
|
74
|
+
if (found.some(([start]) => escaped(src, start))) return null;
|
|
75
|
+
return found;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// --- sentences, as source offsets -----------------------------------------
|
|
79
|
+
|
|
80
|
+
// Sentence boundaries a fixer trusts: a terminal followed by whitespace,
|
|
81
|
+
// inside a direct text node. Terminals inside links, emphasis or code never
|
|
82
|
+
// split a sentence. Each boundary is [end of terminal, start of next sentence].
|
|
83
|
+
const boundaries = (src: string, view: ParagraphView): Range[] => {
|
|
84
|
+
const out: Range[] = [];
|
|
85
|
+
for (const t of view.directTexts) {
|
|
86
|
+
const slice = src.slice(t.start, t.end);
|
|
87
|
+
for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
|
|
88
|
+
const at = t.start + (m.index ?? 0);
|
|
89
|
+
out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out.sort((a, b) => a[0] - b[0]);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// The sentence span that covers [from, to): from the start of the sentence
|
|
96
|
+
// holding `from` to the end of the sentence holding `to`.
|
|
97
|
+
export const sentenceSpan = (src: string, view: ParagraphView, from: number, to: number): Range => {
|
|
98
|
+
let start = view.startOffset;
|
|
99
|
+
let end = view.endOffset;
|
|
100
|
+
for (const [terminalEnd, nextStart] of boundaries(src, view)) {
|
|
101
|
+
if (nextStart <= from) start = nextStart;
|
|
102
|
+
if (terminalEnd >= to && end === view.endOffset) end = terminalEnd;
|
|
103
|
+
}
|
|
104
|
+
return [start, end];
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Every sentence span of a paragraph, in order.
|
|
108
|
+
export const sentenceSpans = (src: string, view: ParagraphView): Range[] => {
|
|
109
|
+
const spans: Range[] = [];
|
|
110
|
+
let start = view.startOffset;
|
|
111
|
+
for (const [terminalEnd, nextStart] of boundaries(src, view)) {
|
|
112
|
+
spans.push([start, terminalEnd]);
|
|
113
|
+
start = nextStart;
|
|
114
|
+
}
|
|
115
|
+
spans.push([start, view.endOffset]);
|
|
116
|
+
return spans;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// --- lead-ins -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
// The colons of a paragraph as source ranges, or null when one of them
|
|
122
|
+
// cannot be trusted. Every rule that promotes a list needs these to find the
|
|
123
|
+
// lead-in that announces it.
|
|
124
|
+
export const colonsIn = (src: string, view: ParagraphView): Range[] | null => matchesInAll(src, view.directTexts, /:/);
|
|
125
|
+
|
|
126
|
+
// The colons that could announce a list: inside `range`, and closing before
|
|
127
|
+
// the first item starts. Which one a rule takes is the only thing the list
|
|
128
|
+
// rules disagree about, so they pick from this rather than each writing the
|
|
129
|
+
// filter again.
|
|
130
|
+
export const colonsBefore = (colons: Range[], range: Range, item: number): Range[] =>
|
|
131
|
+
colons.filter((colon) => within(colon, range) && colon[1] <= item);
|
package/src/rules/index.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// a Rule with its category, register it here, and add its section and
|
|
3
3
|
// examples to RULES.md, which tests/rules-doc.test.ts holds to the code.
|
|
4
4
|
|
|
5
|
+
import { interpunctRuns } from "../interpunct.ts";
|
|
6
|
+
import type { DocModel } from "../model.ts";
|
|
5
7
|
import { pg001 } from "./pg001-wrap.ts";
|
|
6
8
|
import { pg002 } from "./pg002-length.ts";
|
|
7
9
|
import { pg003 } from "./pg003-semicolon-list.ts";
|
|
@@ -11,10 +13,15 @@ import { pg006 } from "./pg006-interpunct-run.ts";
|
|
|
11
13
|
import { pg007 } from "./pg007-inline-enum.ts";
|
|
12
14
|
import { pg008 } from "./pg008-labelled-run.ts";
|
|
13
15
|
import { pg009 } from "./pg009-stacked-runs.ts";
|
|
14
|
-
import type { Rule } from "./types.ts";
|
|
16
|
+
import type { Rule, RuleContext } from "./types.ts";
|
|
15
17
|
|
|
16
18
|
export const RULES: Rule[] = [pg001, pg002, pg003, pg004, pg005, pg006, pg007, pg008, pg009];
|
|
17
19
|
|
|
20
|
+
// The data every rule shares, derived once per document. Interpunct runs are
|
|
21
|
+
// read by four rules, so computing them here keeps the fix engine from
|
|
22
|
+
// rebuilding them once per rule on every pass of the fixpoint loop.
|
|
23
|
+
export const ruleContext = (doc: DocModel): RuleContext => ({ doc, runs: interpunctRuns(doc) });
|
|
24
|
+
|
|
18
25
|
// The order fixers run in. Structure first: a promotion needs the separators
|
|
19
26
|
// a glyph swap would erase, so PG009 and the list rules run before PG005
|
|
20
27
|
// turns interpuncts into commas. Reflow runs last, over the settled blocks.
|
package/src/rules/pg001-wrap.ts
CHANGED
|
@@ -2,70 +2,90 @@
|
|
|
2
2
|
// does not end a sentence is a mid-sentence wrap. The fix reflows the
|
|
3
3
|
// paragraph to one sentence per line.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { isStacked, paragraphsOf } from "../interpunct.ts";
|
|
6
|
+
import type { ParagraphView } from "../model.ts";
|
|
7
|
+
import { proseParagraphs } from "../query.ts";
|
|
8
|
+
import { collapse, inRanges } from "../text.ts";
|
|
9
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
8
10
|
|
|
11
|
+
// A line break after one of these ends a sentence, so it is authored layout.
|
|
9
12
|
const TERMINALS = new Set([".", "!", "?", ":", ";"]);
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
// What may sit between the terminal and the break: trailing space, a carriage
|
|
15
|
+
// return, or the backslash form of a hard break.
|
|
16
|
+
const TRAILING = new Set([" ", "\t", "\r", "\\"]);
|
|
17
|
+
|
|
18
|
+
// A sentence ends at a terminal followed by space, and the next one opens with
|
|
19
|
+
// a capital or an opening delimiter. Anything else is an abbreviation.
|
|
20
|
+
const SENTENCE_BREAK = /[.!?]\s+(?=[A-Z`"'([])/g;
|
|
21
|
+
|
|
22
|
+
// Offsets within the paragraph where a line break interrupts a sentence.
|
|
23
|
+
const wrapsIn = (view: ParagraphView): number[] => {
|
|
24
|
+
const wraps: number[] = [];
|
|
25
|
+
for (let i = 0; i < view.raw.length; i++) {
|
|
26
|
+
if (view.raw[i] !== "\n") continue;
|
|
27
|
+
if (inRanges(view.startOffset + i, view.codeRanges)) continue;
|
|
28
|
+
let j = i - 1;
|
|
29
|
+
while (j >= 0 && TRAILING.has(view.raw.charAt(j))) j--;
|
|
30
|
+
if (j >= 0 && !TERMINALS.has(view.raw.charAt(j))) wraps.push(i);
|
|
25
31
|
}
|
|
26
|
-
return
|
|
32
|
+
return wraps;
|
|
27
33
|
};
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
// The paragraph rewritten with one sentence per line. Code spans are masked
|
|
36
|
+
// character for character before the split, keeping whitespace, so offsets
|
|
37
|
+
// stay aligned and a terminal inside a code span never starts a new line.
|
|
38
|
+
const reflow = (view: ParagraphView): string => {
|
|
39
|
+
const indent = " ".repeat(view.startColumn - 1);
|
|
40
|
+
const eol = view.raw.includes("\r\n") ? "\r\n" : "\n";
|
|
41
|
+
const masked = [...view.raw]
|
|
42
|
+
.map((ch, i) => (inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch))
|
|
43
|
+
.join("");
|
|
44
|
+
const joined = collapse(view.raw);
|
|
45
|
+
const joinedMask = collapse(masked);
|
|
46
|
+
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
let from = 0;
|
|
49
|
+
for (const match of joinedMask.matchAll(SENTENCE_BREAK)) {
|
|
50
|
+
const at = match.index ?? 0;
|
|
51
|
+
lines.push(joined.slice(from, at + 1));
|
|
52
|
+
from = at + match[0].length;
|
|
53
|
+
}
|
|
54
|
+
lines.push(joined.slice(from));
|
|
55
|
+
return lines.map((line, i) => (i === 0 ? line.trim() : indent + line.trim())).join(eol);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
59
|
+
proseParagraphs(doc).flatMap((view) =>
|
|
60
|
+
wrapsIn(view).map((at) => ({
|
|
61
|
+
file,
|
|
62
|
+
line: view.line + view.raw.slice(0, at).split("\n").length - 1,
|
|
63
|
+
rule: RULE.WRAP,
|
|
64
|
+
message: "mid-sentence line wrap; write one sentence per line",
|
|
65
|
+
})),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const fix: Rule["fix"] = ({ doc, runs }) => {
|
|
30
69
|
const edits: Edit[] = [];
|
|
31
70
|
// Interpunct runs stacked on several lines are a nested list PG009 either
|
|
32
71
|
// promotes or leaves for a human. Joining the lines would destroy the
|
|
33
72
|
// structure and hand PG006 a flat run it would merge wrongly.
|
|
34
|
-
const stacked =
|
|
35
|
-
|
|
36
|
-
.map((r) => r.range[0]);
|
|
37
|
-
for (const p of doc.paragraphs) {
|
|
73
|
+
const stacked = new Set(paragraphsOf(doc, runs.filter(isStacked)));
|
|
74
|
+
for (const view of proseParagraphs(doc)) {
|
|
38
75
|
// A hard break is structure the author chose, so the paragraph is left
|
|
39
76
|
// exactly as written.
|
|
40
|
-
if (
|
|
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);
|
|
77
|
+
if (view.inBlockquote || view.hasBreak) continue;
|
|
78
|
+
if (stacked.has(view)) continue;
|
|
56
79
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (text !== p.raw) {
|
|
67
|
-
edits.push({ rule: RULE.WRAP, start: p.startOffset, end: p.endOffset, text, expect: { kind: "same-tree" } });
|
|
68
|
-
}
|
|
80
|
+
const text = reflow(view);
|
|
81
|
+
if (text === view.raw) continue;
|
|
82
|
+
edits.push({
|
|
83
|
+
rule: RULE.WRAP,
|
|
84
|
+
start: view.startOffset,
|
|
85
|
+
end: view.endOffset,
|
|
86
|
+
text,
|
|
87
|
+
expect: { kind: "same-tree" },
|
|
88
|
+
});
|
|
69
89
|
}
|
|
70
90
|
return edits;
|
|
71
91
|
};
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// rewrite, and a rewrite needs discretion. The message estimates the clauses
|
|
4
4
|
// in the sentence so the rewrite splits it rather than compressing it.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { proseSentences } from "../query.ts";
|
|
7
|
+
import { words } from "../text.ts";
|
|
8
|
+
import { RULE, type Rule } from "./types.ts";
|
|
8
9
|
|
|
9
10
|
// A clause boundary is a semicolon or colon with text after it, a comma
|
|
10
11
|
// before a coordinating conjunction, or a subordinating or relative word.
|
|
@@ -15,30 +16,30 @@ const CLAUSE_BOUNDARY =
|
|
|
15
16
|
|
|
16
17
|
export const countClauses = (sentence: string): number => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
|
|
17
18
|
|
|
18
|
-
const excerpt = (
|
|
19
|
+
const excerpt = (sentence: string): string => (sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence);
|
|
19
20
|
|
|
20
21
|
export const lengthMessage = (sentence: string, maxWords: number): string => {
|
|
21
|
-
const
|
|
22
|
+
const count = words(sentence);
|
|
22
23
|
const clauses = countClauses(sentence);
|
|
23
|
-
const target = Math.max(2, clauses, Math.ceil(
|
|
24
|
+
const target = Math.max(2, clauses, Math.ceil(count / maxWords));
|
|
24
25
|
const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
|
|
25
|
-
return `sentence has ${
|
|
26
|
+
return `sentence has ${count} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
const check: Rule["check"] = ({ doc, file, maxWords }) =>
|
|
30
|
+
proseSentences(doc)
|
|
31
|
+
.filter(({ sentence }) => words(sentence) > maxWords)
|
|
32
|
+
.map(({ view, sentence }) => ({
|
|
33
|
+
file,
|
|
34
|
+
line: view.line,
|
|
35
|
+
rule: RULE.LENGTH,
|
|
36
|
+
message: lengthMessage(sentence, maxWords),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// No fixer: shortening a sentence changes its words, per PRS-0006.
|
|
28
40
|
export const pg002: Rule = {
|
|
29
41
|
id: RULE.LENGTH,
|
|
30
42
|
category: "sentence",
|
|
31
43
|
summary: "sentence longer than the word budget (default 25)",
|
|
32
|
-
check
|
|
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
|
+
check,
|
|
44
45
|
};
|
|
@@ -2,54 +2,46 @@
|
|
|
2
2
|
// list. The fix needs a colon lead-in to know where the list starts, and
|
|
3
3
|
// refuses without one.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { matchesInAll,
|
|
7
|
-
import {
|
|
8
|
-
import { type Edit,
|
|
5
|
+
import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
|
|
6
|
+
import { colonsBefore, colonsIn, matchesInAll, proseSentences, sentenceSpans } from "../query.ts";
|
|
7
|
+
import { countOf, first, within } from "../text.ts";
|
|
8
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
9
9
|
|
|
10
|
-
const
|
|
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
|
-
};
|
|
10
|
+
const SEMICOLON = /;/g;
|
|
28
11
|
|
|
29
|
-
const
|
|
30
|
-
|
|
12
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
13
|
+
proseSentences(doc)
|
|
14
|
+
.map(({ view, sentence }) => ({ view, semicolons: countOf(sentence, SEMICOLON) }))
|
|
15
|
+
.filter(({ semicolons }) => semicolons >= 2)
|
|
16
|
+
.map(({ view, semicolons }) => ({
|
|
17
|
+
file,
|
|
18
|
+
line: view.line,
|
|
19
|
+
rule: RULE.SEMICOLON_LIST,
|
|
20
|
+
message: `semicolon-delimited list (${semicolons} ';'); promote to a bullet list`,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const fix: Rule["fix"] = ({ doc }) => {
|
|
31
24
|
const { src } = doc;
|
|
25
|
+
const edits: Edit[] = [];
|
|
32
26
|
for (const view of doc.paragraphs) {
|
|
33
|
-
const
|
|
34
|
-
const colons =
|
|
35
|
-
if (
|
|
27
|
+
const semicolons = matchesInAll(src, view.directTexts, SEMICOLON);
|
|
28
|
+
const colons = colonsIn(src, view);
|
|
29
|
+
if (semicolons === null || colons === null) continue;
|
|
36
30
|
for (const span of sentenceSpans(src, view)) {
|
|
37
|
-
const inside =
|
|
31
|
+
const inside = semicolons.filter((semicolon) => within(semicolon, span));
|
|
38
32
|
if (inside.length < 2) continue;
|
|
39
|
-
|
|
33
|
+
// The outermost colon before the first item opens the lead-in, so a
|
|
34
|
+
// "Note: the rules: a; b; c" keeps the whole of "Note: the rules".
|
|
35
|
+
const colon = colonsBefore(colons, span, first(inside)[0])[0];
|
|
40
36
|
if (!colon) continue;
|
|
41
|
-
|
|
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
|
-
});
|
|
37
|
+
|
|
46
38
|
const edit = promote({
|
|
47
39
|
rule: RULE.SEMICOLON_LIST,
|
|
48
40
|
src,
|
|
49
41
|
view,
|
|
50
42
|
span,
|
|
51
43
|
leadIn: cleanLeadIn(src.slice(span[0], colon[0])),
|
|
52
|
-
items,
|
|
44
|
+
items: itemsBetween(src, [colon, ...inside], span[1]).map((text) => ({ text })),
|
|
53
45
|
ordered: false,
|
|
54
46
|
});
|
|
55
47
|
if (edit) edits.push(edit);
|