@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/prose-gates",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Deterministic prose gates for markdown documentation",
6
6
  "license": "MIT",
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, runs: interpunctRuns(doc) };
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 doc = buildDocModel(current);
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?.(doc) ?? []).filter(
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;
@@ -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, Range } from "../model.ts";
6
+ import type { ParagraphView } from "../model.ts";
7
7
  import type { Edit, RuleId } from "../rules/types.ts";
8
- import { collapse } from "./spans.ts";
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
+ // `&middot;` 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 `&middot;`), 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);
@@ -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.
@@ -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 { type DocModel, inRanges } from "../model.ts";
6
- import { interpunctRuns } from "./interpunct.ts";
7
- import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
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
- 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
- }
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 findings;
32
+ return wraps;
27
33
  };
28
34
 
29
- const fix = (doc: DocModel): Edit[] => {
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 = interpunctRuns(doc)
35
- .filter((r) => r.lines >= 2)
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 (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);
77
+ if (view.inBlockquote || view.hasBreak) continue;
78
+ if (stacked.has(view)) continue;
56
79
 
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
- }
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 { sentences } from "../model.ts";
7
- import { type Finding, RULE, type Rule } from "./types.ts";
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 = (s: string): string => (s.length > 60 ? `${s.slice(0, 60)}...` : s);
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 words = sentence.split(/\s+/).length;
22
+ const count = words(sentence);
22
23
  const clauses = countClauses(sentence);
23
- const target = Math.max(2, clauses, Math.ceil(words / maxWords));
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 ${words} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
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: ({ 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
+ 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 { 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";
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 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
- };
10
+ const SEMICOLON = /;/g;
28
11
 
29
- const fix = (doc: DocModel): Edit[] => {
30
- const edits: Edit[] = [];
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 semis = matchesInAll(src, view.directTexts, /;/);
34
- const colons = matchesInAll(src, view.directTexts, /:/);
35
- if (semis === null || colons === null) continue;
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 = semis.filter((r) => within(r, span));
31
+ const inside = semicolons.filter((semicolon) => within(semicolon, span));
38
32
  if (inside.length < 2) continue;
39
- const colon = colons.find((r) => within(r, span) && r[1] <= (inside[0] as [number, number])[0]);
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
- 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
- });
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);