@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.
- package/README.md +9 -5
- 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
|
@@ -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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
|
|
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 [
|
|
23
|
-
if (
|
|
24
|
-
out += `${src.slice(at,
|
|
25
|
-
at =
|
|
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
|
|
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 =
|
|
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((
|
|
64
|
+
const inside = commas.filter((comma) => within(comma, span));
|
|
39
65
|
if (inside.length < 2) continue;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
};
|
package/src/rules/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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?: (
|
|
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
|
-
// `·` 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] === "\\";
|
package/src/rules/interpunct.ts
DELETED
|
@@ -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
|
-
};
|