@jpeakai/prose-gates 0.1.0 → 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.
@@ -8,83 +8,90 @@
8
8
  // The check counts labelled segments; the fix is stricter and needs every
9
9
  // segment of the sentence to be one, so no stray clause becomes a bullet.
10
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";
11
+ import { cleanLeadIn, itemsOf, promote } from "../fix/promote.ts";
12
+ import type { ParagraphView } from "../model.ts";
13
+ import { colonsIn, matchesInAll, proseSentences, sentenceSpans } from "../query.ts";
14
+ import { collapse, first, type Range, spansBetween, within } from "../text.ts";
15
+ import { type Edit, RULE, type Rule } from "./types.ts";
15
16
 
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
+ // A labelled segment, built from its parts so each one can be read on its
18
+ // own: the conjunction that joins the final segment, an optional "Rules: "
19
+ // intro on the first, the label itself, and the content that makes it an
20
+ // item rather than a bare mention.
21
+ const JOINER = "(?:and\\s+|or\\s+)?";
22
+ const INTRO = "(?:[A-Za-z][\\w\\s-]{0,24}:\\s*)?";
23
+ const LABEL = "(?:CODE|[A-Z][A-Za-z]*\\d[\\w/-]*)";
24
+ const CONTENT = "\\s+\\S";
25
+ const LABELLED_SEGMENT = new RegExp(`^${JOINER}${INTRO}${LABEL}${CONTENT}`);
26
+
27
+ // The intro that may precede the first label, as the lead-in test reads it.
28
+ const INTRO_ONLY = /^[A-Za-z][\w\s-]{0,24}$/;
17
29
 
18
30
  // A source slice as the check sees it: code spans become one CODE token.
19
31
  const asProse = (src: string, view: ParagraphView, range: Range): string => {
20
32
  let out = "";
21
33
  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;
34
+ for (const [start, end] of view.codeRanges) {
35
+ if (end <= range[0] || start >= range[1]) continue;
36
+ out += `${src.slice(at, start)}CODE`;
37
+ at = end;
26
38
  }
27
39
  return collapse(out + src.slice(at, range[1])).trim();
28
40
  };
29
41
 
30
- const fix = (doc: DocModel): Edit[] => {
42
+ const labelledSegments = (sentence: string): number =>
43
+ sentence.split(/,\s+/).filter((segment) => LABELLED_SEGMENT.test(segment)).length;
44
+
45
+ const check: Rule["check"] = ({ doc, file }) =>
46
+ proseSentences(doc)
47
+ .map(({ view, sentence }) => ({ view, labelled: labelledSegments(sentence) }))
48
+ .filter(({ labelled }) => labelled >= 3)
49
+ .map(({ view, labelled }) => ({
50
+ file,
51
+ line: view.line,
52
+ rule: RULE.LABELLED_RUN,
53
+ message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`,
54
+ }));
55
+
56
+ const fix: Rule["fix"] = ({ doc }) => {
31
57
  const { src } = doc;
32
58
  const edits: Edit[] = [];
33
59
  for (const view of doc.paragraphs) {
34
60
  const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
35
- const colons = matchesInAll(src, view.directTexts, /:/);
61
+ const colons = colonsIn(src, view);
36
62
  if (commas === null || colons === null) continue;
37
63
  for (const span of sentenceSpans(src, view)) {
38
- const inside = commas.filter((r) => within(r, span));
64
+ const inside = commas.filter((comma) => within(comma, span));
39
65
  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;
66
+ // The sentence start is the cut before the first segment, so the spans
67
+ // between the cuts are exactly the comma-separated segments.
68
+ const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
69
+ if (!segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment)))) continue;
43
70
 
44
71
  // 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];
72
+ // The first item then starts after it, so the intro is not repeated as
73
+ // both the lead-in and the head of the first bullet.
74
+ const opening = first(segments);
75
+ const colon = colons.find((c) => within(c, opening));
76
+ const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
77
+ const items: Range[] = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
49
78
 
50
- const items = bounds.map((b, i) => ({
51
- text: cleanItem(src.slice(b[0], b[1]), { last: i + 1 === bounds.length }),
52
- }));
53
79
  const edit = promote({
54
80
  rule: RULE.LABELLED_RUN,
55
81
  src,
56
82
  view,
57
83
  span,
58
84
  leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
59
- items,
85
+ items: itemsOf(src, items).map((text) => ({ text })),
60
86
  ordered: false,
61
87
  });
62
88
  if (edit) edits.push(edit);
63
- break;
89
+ break; // one promotion per paragraph; the engine re-parses and comes back
64
90
  }
65
91
  }
66
92
  return edits;
67
93
  };
68
94
 
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
95
  export const pg008: Rule = {
89
96
  id: RULE.LABELLED_RUN,
90
97
  category: "list",
@@ -3,49 +3,58 @@
3
3
  // needs every line to read `Label: a · b · c`, and builds one parent bullet
4
4
  // per label with the items nested beneath it.
5
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";
6
+ import { itemsBetween, type PromotedItem, promote } from "../fix/promote.ts";
7
+ import { GLYPH, isStacked, paragraphsOf, RUN_SEPARATOR } from "../interpunct.ts";
8
+ import type { ParagraphView } from "../model.ts";
9
+ import { colonsBefore, colonsIn, splittableSeparators } from "../query.ts";
10
+ import { collapse, first, type Range, within } from "../text.ts";
10
11
  import { type Edit, RULE, type Rule } from "./types.ts";
11
12
 
12
- const fix = (doc: DocModel): Edit[] => {
13
+ // The source lines of a paragraph, as absolute ranges.
14
+ const linesOf = (view: ParagraphView): Range[] => {
15
+ const lines: Range[] = [];
16
+ let at = view.startOffset;
17
+ for (const line of view.raw.split("\n")) {
18
+ lines.push([at, at + line.length]);
19
+ at += line.length + 1;
20
+ }
21
+ return lines;
22
+ };
23
+
24
+ // One line read as `Label: a · b · c`, or null when it is not that shape.
25
+ // Every line of the paragraph has to parse, or the fix has no nested list to
26
+ // build and leaves the whole paragraph reported.
27
+ const parentOf = (src: string, line: Range, separators: Range[], colons: Range[]): PromotedItem | null => {
28
+ const inside = separators.filter((separator) => within(separator, line));
29
+ if (inside.length === 0) return null;
30
+ const colon = colonsBefore(colons, line, first(inside)[0])[0];
31
+ if (!colon) return null;
32
+ return {
33
+ text: collapse(src.slice(line[0], colon[0])).trim(),
34
+ children: itemsBetween(src, [colon, ...inside], line[1]),
35
+ };
36
+ };
37
+
38
+ const check: Rule["check"] = ({ file, runs }) =>
39
+ runs.filter(isStacked).map((run) => ({
40
+ file,
41
+ line: run.line,
42
+ rule: RULE.STACKED_RUNS,
43
+ message: `interpunct runs stacked on ${run.lines} lines; promote to a nested list`,
44
+ }));
45
+
46
+ const fix: Rule["fix"] = ({ doc, runs }) => {
13
47
  const { src } = doc;
14
48
  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
- }
49
+ for (const view of paragraphsOf(doc, runs.filter(isStacked))) {
50
+ const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
51
+ const colons = colonsIn(src, view);
52
+ if (separators === null || colons === null) continue;
35
53
 
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
- }
54
+ const lines = linesOf(view);
55
+ const parents = lines
56
+ .map((line) => parentOf(src, line, separators, colons))
57
+ .filter((parent): parent is PromotedItem => parent !== null);
49
58
  if (parents.length !== lines.length) continue;
50
59
 
51
60
  const edit = promote({
@@ -66,14 +75,6 @@ export const pg009: Rule = {
66
75
  id: RULE.STACKED_RUNS,
67
76
  category: "list",
68
77
  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
+ check,
78
79
  fix,
79
80
  };
@@ -2,7 +2,8 @@
2
2
  // optional fixer that proposes edits. The fix engine, not the fixer, decides
3
3
  // whether an edit is safe to keep.
4
4
 
5
- import type { DocModel, ParagraphView, Range } from "../model.ts";
5
+ import type { InterpunctRun } from "../interpunct.ts";
6
+ import type { DocModel, ParagraphView } from "../model.ts";
6
7
 
7
8
  export const RULE = {
8
9
  WRAP: "PG001",
@@ -25,22 +26,24 @@ export interface Finding {
25
26
  message: string;
26
27
  }
27
28
 
28
- // Paragraphs whose prose joins a run of items with 2+ interpunct separators.
29
- // Shared by PG005, PG006 and PG009 as an explicit data dependency.
30
- export interface InterpunctRun {
31
- range: Range;
32
- line: number;
33
- separators: number;
34
- lines: number; // source lines of the paragraph that carry a separator
29
+ // What every rule reads, computed once per document and handed to it. Shared
30
+ // data is passed in rather than recomputed, so a check and a fix can never
31
+ // disagree about what the document holds.
32
+ export interface RuleContext {
33
+ doc: DocModel;
34
+ runs: InterpunctRun[];
35
35
  }
36
36
 
37
- export interface CheckContext {
38
- doc: DocModel;
37
+ // A check also names the file it reports against, and the sentence budget.
38
+ export interface CheckContext extends RuleContext {
39
39
  file: string;
40
40
  maxWords: number;
41
- runs: InterpunctRun[];
42
41
  }
43
42
 
43
+ // A fix needs nothing beyond the shared context. It proposes edits, and the
44
+ // engine alone decides which of them survive verification.
45
+ export type FixContext = RuleContext;
46
+
44
47
  // What the engine must be able to prove about the document after an edit.
45
48
  export type Expectation =
46
49
  // Whitespace only: the tree is unchanged once text whitespace is collapsed.
@@ -73,5 +76,5 @@ export interface Rule {
73
76
  summary: string;
74
77
  check: (ctx: CheckContext) => Finding[];
75
78
  // Absent for rules whose fix needs discretion (PG002).
76
- fix?: (doc: DocModel) => Edit[];
79
+ fix?: (ctx: FixContext) => Edit[];
77
80
  }
package/src/text.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Text and range arithmetic that knows nothing about markdown. Everything
2
+ // here is a pure function of its arguments, so it is the one layer that can
3
+ // be read without the document model in mind.
4
+
5
+ // A half-open span of absolute offsets into a source string. Every range in
6
+ // this package is [start, end), and is only valid for the source it was
7
+ // computed from.
8
+ export type Range = [number, number];
9
+
10
+ // The ends of a list the caller has already proved non-empty. Written once
11
+ // here so `noUncheckedIndexedAccess` costs the package two casts rather than
12
+ // one per rule.
13
+ export const first = <T>(xs: readonly T[]): T => xs[0] as T;
14
+
15
+ export const last = <T>(xs: readonly T[]): T => xs[xs.length - 1] as T;
16
+
17
+ export const within = (r: Range, span: Range): boolean => r[0] >= span[0] && r[1] <= span[1];
18
+
19
+ // Soft line breaks collapse to single spaces; nothing else in the slice moves.
20
+ export const collapse = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
21
+
22
+ // How many times a pattern matches. The pattern must be global: matchAll
23
+ // throws otherwise, which is the loud failure we want over a silent count
24
+ // of one.
25
+ export const countOf = (text: string, pattern: RegExp): number => [...text.matchAll(pattern)].length;
26
+
27
+ // The spans a run of cut points carves out of a sentence. Each span runs from
28
+ // the end of its own cut to the start of the next one, and the last runs to
29
+ // `end`. Every hidden-list fixer needs exactly this, so the fencepost is
30
+ // written once here rather than once per rule.
31
+ export const spansBetween = (cuts: Range[], end: number): Range[] =>
32
+ cuts.map((cut, i) => {
33
+ const next = cuts[i + 1];
34
+ return [cut[1], next ? next[0] : end];
35
+ });
36
+
37
+ // Words of a sentence or an item, counted the one way for every rule.
38
+ export const words = (text: string): number => text.split(/\s+/).filter(Boolean).length;
39
+
40
+ // Whether an offset falls inside any of the ranges.
41
+ export const inRanges = (offset: number, ranges: Range[]): boolean =>
42
+ ranges.some(([a, b]) => offset >= a && offset < b);
43
+
44
+ // Prose split on terminals. A check reads sentences this way; a fixer needs
45
+ // the source offsets instead and asks `sentenceSpans` for them.
46
+ export const sentences = (text: string): string[] =>
47
+ text
48
+ .split(/(?<=[.!?])\s+/)
49
+ .map((s) => s.trim())
50
+ .filter((s) => s.length > 0);
package/src/fix/spans.ts DELETED
@@ -1,75 +0,0 @@
1
- // Source-offset helpers shared by fixers. Everything here works on absolute
2
- // offsets into the document source, and only ever looks inside text the
3
- // parser proved is prose.
4
-
5
- import type { DirectText, ParagraphView, Range } from "../model.ts";
6
-
7
- // Absolute offsets of every match of `pattern` inside a text node, or null
8
- // when the source slice and the parsed value disagree (an entity like
9
- // `&middot;` or an escape). A fixer that cannot map a glyph back to the
10
- // source refuses rather than guesses.
11
- export const matchesIn = (src: string, text: DirectText, pattern: RegExp): Range[] | null => {
12
- const slice = src.slice(text.start, text.end);
13
- const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
14
- const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
15
- const inValue = [...text.value.matchAll(new RegExp(pattern.source, flags))];
16
- if (inSlice.length !== inValue.length) return null;
17
- return inSlice.map((m) => [text.start + (m.index ?? 0), text.start + (m.index ?? 0) + m[0].length]);
18
- };
19
-
20
- export const matchesInAll = (src: string, texts: DirectText[], pattern: RegExp): Range[] | null => {
21
- const out: Range[] = [];
22
- for (const t of texts) {
23
- const found = matchesIn(src, t, pattern);
24
- if (found === null) return null;
25
- out.push(...found);
26
- }
27
- return out;
28
- };
29
-
30
- // Sentence boundaries a fixer trusts: a terminal followed by whitespace,
31
- // inside a direct text node. Terminals inside links, emphasis or code never
32
- // split a sentence. Each boundary is [end of terminal, start of next sentence].
33
- const boundaries = (src: string, view: ParagraphView): Range[] => {
34
- const out: Range[] = [];
35
- for (const t of view.directTexts) {
36
- const slice = src.slice(t.start, t.end);
37
- for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
38
- const at = t.start + (m.index ?? 0);
39
- out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
40
- }
41
- }
42
- return out.sort((a, b) => a[0] - b[0]);
43
- };
44
-
45
- // The sentence span that covers [from, to): from the start of the sentence
46
- // holding `from` to the end of the sentence holding `to`.
47
- export const sentenceSpan = (src: string, view: ParagraphView, from: number, to: number): Range => {
48
- let start = view.startOffset;
49
- let end = view.endOffset;
50
- for (const [terminalEnd, nextStart] of boundaries(src, view)) {
51
- if (nextStart <= from) start = nextStart;
52
- if (terminalEnd >= to && end === view.endOffset) end = terminalEnd;
53
- }
54
- return [start, end];
55
- };
56
-
57
- // Every sentence span of a paragraph, in order.
58
- export const sentenceSpans = (src: string, view: ParagraphView): Range[] => {
59
- const spans: Range[] = [];
60
- let start = view.startOffset;
61
- for (const [terminalEnd, nextStart] of boundaries(src, view)) {
62
- spans.push([start, terminalEnd]);
63
- start = nextStart;
64
- }
65
- spans.push([start, view.endOffset]);
66
- return spans;
67
- };
68
-
69
- export const within = (r: Range, span: Range): boolean => r[0] >= span[0] && r[1] <= span[1];
70
-
71
- // Soft line breaks collapse to single spaces; nothing else in the slice moves.
72
- export const collapse = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
73
-
74
- // A glyph the author escaped is literal text, never a separator to rewrite.
75
- export const escaped = (src: string, offset: number): boolean => src[offset - 1] === "\\";
@@ -1,19 +0,0 @@
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
- };