@jpeakai/prose-gates 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/dist/bin.js +21370 -722
- package/dist/index.js +21379 -724
- package/dist/testing.js +21058 -555
- package/package.json +3 -1
- package/src/check.ts +19 -7
- package/src/cli.ts +80 -50
- package/src/cohesion.ts +70 -0
- package/src/concepts.ts +209 -0
- package/src/errors.ts +21 -0
- package/src/fix/engine.ts +77 -59
- package/src/fix/verify.ts +39 -27
- package/src/index.ts +13 -0
- package/src/language.ts +107 -0
- package/src/model.ts +176 -102
- package/src/plugins/discover.ts +19 -13
- package/src/plugins/index.ts +66 -33
- package/src/plugins/load.ts +73 -33
- package/src/project.ts +5 -2
- package/src/rules/index.ts +2 -0
- package/src/rules/list-comma-labelled-run.ts +50 -34
- package/src/rules/list-inline-enumeration-markers.ts +54 -43
- package/src/rules/list-interpunct-joined-run.ts +31 -28
- package/src/rules/paragraph-sentence-budget-exceeded.ts +64 -0
- package/src/rules/registry.ts +12 -2
- package/src/rules/sentence-one-per-line.ts +41 -14
- package/src/rules/sentence-word-budget-exceeded.ts +27 -16
- package/src/rules/types.ts +9 -1
package/src/project.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Setting up a run: find the project root, read its config
|
|
2
|
-
// and build the registry every check and fix in the run uses. This happens
|
|
1
|
+
// Setting up a run: find the project root, read its config and its concept
|
|
2
|
+
// file, load the plugins, and build the registry every check and fix in the run uses. This happens
|
|
3
3
|
// once, before any file is read, so a bad config or plugin stops the run
|
|
4
4
|
// before it touches a document.
|
|
5
5
|
|
|
6
6
|
import { access } from "node:fs/promises";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { loadConcepts } from "./concepts.ts";
|
|
8
9
|
import { CONFIG_FILES, findConfig, loadConfig, NO_CONFIG } from "./config.ts";
|
|
9
10
|
import { ConfigNotFoundError } from "./errors.ts";
|
|
10
11
|
import { loadPlugins, type PluginFailure, type PluginMode } from "./plugins/index.ts";
|
|
@@ -69,6 +70,7 @@ export const setUp = async ({
|
|
|
69
70
|
else if (collect) mode = "collect";
|
|
70
71
|
else if (lenient || config.pluginLoading === "lenient") mode = "lenient";
|
|
71
72
|
const plugins = await loadPlugins(root, config, mode);
|
|
73
|
+
const concepts = await loadConcepts(root);
|
|
72
74
|
const registry = buildRegistry({
|
|
73
75
|
rules: [...RULES, ...plugins.rules],
|
|
74
76
|
fixOrder: [...FIX_ORDER, ...plugins.rules.filter((r) => r.fix)],
|
|
@@ -76,6 +78,7 @@ export const setUp = async ({
|
|
|
76
78
|
sources: plugins.sources,
|
|
77
79
|
config,
|
|
78
80
|
ignoreUnknownPluginIds: mode === "lenient" || mode === "collect",
|
|
81
|
+
concepts,
|
|
79
82
|
});
|
|
80
83
|
return { registry, skipped: plugins.skipped, failures: plugins.failures };
|
|
81
84
|
};
|
package/src/rules/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { listInlineEnumerationMarkers } from "./list-inline-enumeration-markers.
|
|
|
7
7
|
import { listInterpunctJoinedRun } from "./list-interpunct-joined-run.ts";
|
|
8
8
|
import { listSemicolonDelimitedRun } from "./list-semicolon-delimited-run.ts";
|
|
9
9
|
import { listStackedInterpunctRuns } from "./list-stacked-interpunct-runs.ts";
|
|
10
|
+
import { paragraphSentenceBudgetExceeded } from "./paragraph-sentence-budget-exceeded.ts";
|
|
10
11
|
import { punctuationEmDashInProse } from "./punctuation-em-dash-in-prose.ts";
|
|
11
12
|
import { punctuationInterpunctInProse } from "./punctuation-interpunct-in-prose.ts";
|
|
12
13
|
import { sentenceOnePerLine } from "./sentence-one-per-line.ts";
|
|
@@ -16,6 +17,7 @@ import type { Rule } from "./types.ts";
|
|
|
16
17
|
export const RULES: Rule[] = [
|
|
17
18
|
sentenceOnePerLine,
|
|
18
19
|
sentenceWordBudgetExceeded,
|
|
20
|
+
paragraphSentenceBudgetExceeded,
|
|
19
21
|
listSemicolonDelimitedRun,
|
|
20
22
|
punctuationEmDashInProse,
|
|
21
23
|
punctuationInterpunctInProse,
|
|
@@ -53,45 +53,61 @@ const check: Rule["check"] = ({ doc, file }) =>
|
|
|
53
53
|
message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`,
|
|
54
54
|
}));
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
56
|
+
// The comma-separated segments of a sentence when every one is labelled, or
|
|
57
|
+
// null when the sentence has too few commas or carries a stray clause.
|
|
58
|
+
const labelledRun = (src: string, view: ParagraphView, commas: Range[], span: Range): Range[] | null => {
|
|
59
|
+
const inside = commas.filter((comma) => within(comma, span));
|
|
60
|
+
if (inside.length < 2) return null;
|
|
61
|
+
// The sentence start is the cut before the first segment, so the spans
|
|
62
|
+
// between the cuts are exactly the comma-separated segments.
|
|
63
|
+
const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
|
|
64
|
+
return segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment))) ? segments : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
interface LabelledSentence {
|
|
68
|
+
src: string;
|
|
69
|
+
view: ParagraphView;
|
|
70
|
+
span: Range;
|
|
71
|
+
segments: Range[];
|
|
72
|
+
colons: Range[];
|
|
73
|
+
}
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// An intro colon in the first segment, before its label, is the lead-in.
|
|
76
|
+
// The first item then starts after it, so the intro is not repeated as
|
|
77
|
+
// both the lead-in and the head of the first bullet.
|
|
78
|
+
const promoteRun = ({ src, view, span, segments, colons }: LabelledSentence): Edit | null => {
|
|
79
|
+
const opening = first(segments);
|
|
80
|
+
const colon = colons.find((c) => within(c, opening));
|
|
81
|
+
const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
|
|
82
|
+
const items: Range[] = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
return promote({
|
|
85
|
+
rule: RULE.LABELLED_RUN,
|
|
86
|
+
src,
|
|
87
|
+
view,
|
|
88
|
+
span,
|
|
89
|
+
leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
|
|
90
|
+
items: itemsOf(src, items).map((text) => ({ text })),
|
|
91
|
+
ordered: false,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// One promotion per paragraph: the first fully labelled sentence is the only
|
|
96
|
+
// one tried, and the engine re-parses and comes back for the rest.
|
|
97
|
+
const fixParagraph = (src: string, view: ParagraphView): Edit | null => {
|
|
98
|
+
const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
|
|
99
|
+
const colons = colonsIn(src, view);
|
|
100
|
+
if (commas === null || colons === null) return null;
|
|
101
|
+
for (const span of sentenceSpans(src, view)) {
|
|
102
|
+
const segments = labelledRun(src, view, commas, span);
|
|
103
|
+
if (segments !== null) return promoteRun({ src, view, span, segments, colons });
|
|
91
104
|
}
|
|
92
|
-
return
|
|
105
|
+
return null;
|
|
93
106
|
};
|
|
94
107
|
|
|
108
|
+
const fix: Rule["fix"] = ({ doc }) =>
|
|
109
|
+
doc.paragraphs.map((view) => fixParagraph(doc.src, view)).filter((edit): edit is Edit => edit !== null);
|
|
110
|
+
|
|
95
111
|
export const listCommaLabelledRun: Rule = {
|
|
96
112
|
id: RULE.LABELLED_RUN,
|
|
97
113
|
category: "list",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// every marker in order, none skipped, none from another family.
|
|
5
5
|
|
|
6
6
|
import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
|
|
7
|
-
import type { DirectText } from "../model.ts";
|
|
7
|
+
import type { DirectText, ParagraphView } from "../model.ts";
|
|
8
8
|
import { proseParagraphs, sentenceSpan } from "../query.ts";
|
|
9
9
|
import { first, last, type Range } from "../text.ts";
|
|
10
10
|
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
@@ -72,52 +72,63 @@ const check: Rule["check"] = ({ doc, file }) =>
|
|
|
72
72
|
];
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
75
|
+
// The paragraph's markers when they form a clean sequence: at least two, every
|
|
76
|
+
// one in order from the family the first opens, none skipped. Otherwise null.
|
|
77
|
+
const cleanSequence = (src: string, view: ParagraphView): Marker[] | null => {
|
|
78
|
+
const markers = markersIn(src, view.directTexts);
|
|
79
|
+
if (markers === null || markers.length < 2) return null;
|
|
80
|
+
const sequence = sequenceFor(first(markers).label);
|
|
81
|
+
if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) return null;
|
|
82
|
+
return markers;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Markers split across sentences are two references, not one list.
|
|
86
|
+
const splitAcrossSentences = (src: string, view: ParagraphView, opener: Marker, closer: Marker): boolean =>
|
|
87
|
+
view.directTexts
|
|
88
|
+
.map((text) => src.slice(Math.max(text.start, opener.range[1]), Math.min(text.end, closer.range[0])))
|
|
89
|
+
.some((slice) => /[.!?]\s/.test(slice));
|
|
90
|
+
|
|
91
|
+
// Two markers joined only by a conjunction, with no colon to announce
|
|
92
|
+
// them, read as cross-references ("see (a) above and (b) below"). With
|
|
93
|
+
// exactly two markers the closer is also the second, so the text between
|
|
94
|
+
// them is the whole of the first item.
|
|
95
|
+
const crossReference = (src: string, span: Range, markers: Marker[]): boolean => {
|
|
96
|
+
if (markers.length !== 2) return false;
|
|
97
|
+
const opener = first(markers);
|
|
98
|
+
const closer = last(markers);
|
|
99
|
+
const announced = /:\s*$/.test(src.slice(span[0], opener.range[0]));
|
|
100
|
+
const separated = /[,;]\s*$/.test(src.slice(opener.range[1], closer.range[0]));
|
|
101
|
+
return !announced && !separated;
|
|
102
|
+
};
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
const fixParagraph = (src: string, view: ParagraphView): Edit | null => {
|
|
105
|
+
const markers = cleanSequence(src, view);
|
|
106
|
+
if (markers === null) return null;
|
|
107
|
+
const opener = first(markers);
|
|
108
|
+
const closer = last(markers);
|
|
109
|
+
if (splitAcrossSentences(src, view, opener, closer)) return null;
|
|
110
|
+
|
|
111
|
+
const span = sentenceSpan(src, view, opener.range[0], closer.range[1]);
|
|
112
|
+
if (crossReference(src, span, markers)) return null;
|
|
113
|
+
|
|
114
|
+
return promote({
|
|
115
|
+
rule: RULE.INLINE_ENUM,
|
|
116
|
+
src,
|
|
117
|
+
view,
|
|
118
|
+
span,
|
|
119
|
+
leadIn: cleanLeadIn(src.slice(span[0], opener.range[0])),
|
|
120
|
+
items: itemsBetween(
|
|
105
121
|
src,
|
|
106
|
-
|
|
107
|
-
span,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
markers.map((marker) => marker.range),
|
|
112
|
-
span[1],
|
|
113
|
-
).map((text) => ({ text })),
|
|
114
|
-
ordered: true,
|
|
115
|
-
});
|
|
116
|
-
if (edit) edits.push(edit);
|
|
117
|
-
}
|
|
118
|
-
return edits;
|
|
122
|
+
markers.map((marker) => marker.range),
|
|
123
|
+
span[1],
|
|
124
|
+
).map((text) => ({ text })),
|
|
125
|
+
ordered: true,
|
|
126
|
+
});
|
|
119
127
|
};
|
|
120
128
|
|
|
129
|
+
const fix: Rule["fix"] = ({ doc }) =>
|
|
130
|
+
doc.paragraphs.map((view) => fixParagraph(doc.src, view)).filter((edit): edit is Edit => edit !== null);
|
|
131
|
+
|
|
121
132
|
export const listInlineEnumerationMarkers: Rule = {
|
|
122
133
|
id: RULE.INLINE_ENUM,
|
|
123
134
|
category: "list",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
|
|
7
7
|
import { GLYPH, isFlat, paragraphsOf, RUN_SEPARATOR } from "../interpunct.ts";
|
|
8
|
+
import type { ParagraphView } from "../model.ts";
|
|
8
9
|
import { colonsBefore, colonsIn, sentenceSpan, splittableSeparators } from "../query.ts";
|
|
9
10
|
import { first, last, type Range, words } from "../text.ts";
|
|
10
11
|
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
@@ -26,39 +27,41 @@ const check: Rule["check"] = ({ file, runs }) =>
|
|
|
26
27
|
message: `interpunct-joined inline list (${run.separators + 1} items); promote to a bullet list`,
|
|
27
28
|
}));
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
for (const view of paragraphsOf(doc, runs.filter(isFlat))) {
|
|
33
|
-
const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
|
|
34
|
-
const colons = colonsIn(src, view);
|
|
35
|
-
if (separators === null || colons === null) continue;
|
|
30
|
+
// With no colon to announce it, the run has to be the whole paragraph.
|
|
31
|
+
const coversParagraph = (view: ParagraphView, span: Range): boolean =>
|
|
32
|
+
span[0] === view.startOffset && span[1] === view.endOffset;
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// With no colon to announce it, the run has to be the whole paragraph.
|
|
42
|
-
if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset)) continue;
|
|
34
|
+
const fixParagraph = (src: string, view: ParagraphView): Edit | null => {
|
|
35
|
+
const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
|
|
36
|
+
const colons = colonsIn(src, view);
|
|
37
|
+
if (separators === null || colons === null) return null;
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
|
|
40
|
+
// The innermost colon before the first item, so a lead-in that itself
|
|
41
|
+
// contains a colon keeps only its final clause.
|
|
42
|
+
const colon = colonsBefore(colons, span, first(separators)[0]).pop();
|
|
43
|
+
if (!colon && !coversParagraph(view, span)) return null;
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
45
|
+
const cuts: Range[] = [colon ?? [span[0], span[0]], ...separators];
|
|
46
|
+
const items = itemsBetween(src, cuts, span[1]);
|
|
47
|
+
if (tailIsProse(items) || (!colon && headIsProse(items))) return null;
|
|
48
|
+
|
|
49
|
+
return promote({
|
|
50
|
+
rule: RULE.INTERPUNCT_RUN,
|
|
51
|
+
src,
|
|
52
|
+
view,
|
|
53
|
+
span,
|
|
54
|
+
leadIn: colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
|
|
55
|
+
items: items.map((text) => ({ text })),
|
|
56
|
+
ordered: false,
|
|
57
|
+
});
|
|
60
58
|
};
|
|
61
59
|
|
|
60
|
+
const fix: Rule["fix"] = ({ doc, runs }) =>
|
|
61
|
+
paragraphsOf(doc, runs.filter(isFlat))
|
|
62
|
+
.map((view) => fixParagraph(doc.src, view))
|
|
63
|
+
.filter((edit): edit is Edit => edit !== null);
|
|
64
|
+
|
|
62
65
|
export const listInterpunctJoinedRun: Rule = {
|
|
63
66
|
id: RULE.INTERPUNCT_RUN,
|
|
64
67
|
category: "list",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// paragraph-sentence-budget-exceeded: a paragraph of more sentences than the
|
|
2
|
+
// budget renders as a wall of text, even when every sentence is short. Never
|
|
3
|
+
// autofixed: where a paragraph breaks is a judgement about its topics. The
|
|
4
|
+
// message counts the topics and names the sentences to break after.
|
|
5
|
+
//
|
|
6
|
+
// It runs after the list rules. A list hidden in a paragraph is theirs to
|
|
7
|
+
// report, and promoting it often brings the paragraph within budget on its
|
|
8
|
+
// own, so a paragraph a list rule reports is counted only once the list is out.
|
|
9
|
+
|
|
10
|
+
import { gapCohesion, planBreaks } from "../cohesion.ts";
|
|
11
|
+
import type { LanguageSentence } from "../language.ts";
|
|
12
|
+
import type { ParagraphView } from "../model.ts";
|
|
13
|
+
import { proseParagraphs } from "../query.ts";
|
|
14
|
+
import { type Finding, RULE, type Rule } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MAX_SENTENCES = 5;
|
|
17
|
+
|
|
18
|
+
// A list of sentence numbers as prose: "3", "3 and 6", "2, 3 and 6".
|
|
19
|
+
const series = (ns: number[]): string =>
|
|
20
|
+
ns.length === 1 ? `${ns[0]}` : `${ns.slice(0, -1).join(", ")} and ${ns.at(-1)}`;
|
|
21
|
+
|
|
22
|
+
const plural = (n: number, word: string): string => `${word}${n === 1 ? "" : "s"}`;
|
|
23
|
+
|
|
24
|
+
export const paragraphMessage = (sentences: readonly LanguageSentence[], maxSentences: number): string => {
|
|
25
|
+
const { topics, breaks } = planBreaks(gapCohesion(sentences), maxSentences);
|
|
26
|
+
const where = `break it after ${plural(breaks.length, "sentence")} ${series(breaks)}`;
|
|
27
|
+
const counted = `paragraph has ${sentences.length} sentences (budget ${maxSentences})`;
|
|
28
|
+
return topics === 1
|
|
29
|
+
? `${counted} and one topic throughout; ${where}, where neighbouring sentences share the fewest words`
|
|
30
|
+
: `${counted} and about ${topics} topics; ${where}, one topic per paragraph`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const budgetOf = (option: unknown): number => (typeof option === "number" ? option : DEFAULT_MAX_SENTENCES);
|
|
34
|
+
|
|
35
|
+
// Whether a list rule reported a line of this paragraph. A list rule may
|
|
36
|
+
// report the paragraph's first line or the line its run starts on.
|
|
37
|
+
const holdsListFinding = (view: ParagraphView, earlier: readonly Finding[]): boolean => {
|
|
38
|
+
const last = view.line + view.raw.split("\n").length - 1;
|
|
39
|
+
return earlier.some((f) => f.line >= view.line && f.line <= last);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const check: Rule["check"] = ({ doc, file, options, earlier }) => {
|
|
43
|
+
const maxSentences = budgetOf(options.maxSentences);
|
|
44
|
+
return proseParagraphs(doc)
|
|
45
|
+
.filter((view) => !holdsListFinding(view, earlier))
|
|
46
|
+
.map((view) => ({ view, sentences: view.language.sentences }))
|
|
47
|
+
.filter(({ sentences }) => sentences.length > maxSentences)
|
|
48
|
+
.map(({ view, sentences }) => ({
|
|
49
|
+
file,
|
|
50
|
+
line: view.line,
|
|
51
|
+
rule: RULE.PARAGRAPH_LENGTH,
|
|
52
|
+
message: paragraphMessage(sentences, maxSentences),
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// No fixer: breaking a paragraph is a judgement about its topics, per PRS-0030.
|
|
57
|
+
export const paragraphSentenceBudgetExceeded: Rule = {
|
|
58
|
+
id: RULE.PARAGRAPH_LENGTH,
|
|
59
|
+
category: "paragraph",
|
|
60
|
+
summary: `paragraph with more sentences than the budget (default ${DEFAULT_MAX_SENTENCES})`,
|
|
61
|
+
options: { maxSentences: "number" },
|
|
62
|
+
after: ["list"],
|
|
63
|
+
check,
|
|
64
|
+
};
|
package/src/rules/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// that is off loses its check and its fixer together. Nothing else reads the
|
|
4
4
|
// catalogue directly, which is what lets a plugin rule behave as a built-in.
|
|
5
5
|
|
|
6
|
+
import { type ConceptModel, NO_CONCEPTS } from "../concepts.ts";
|
|
6
7
|
import type { Config } from "../config.ts";
|
|
7
8
|
import { RuleOptionError, UnknownRuleError } from "../errors.ts";
|
|
8
9
|
import { interpunctRuns, isFlat } from "../interpunct.ts";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
|
|
23
24
|
export const BUILTIN_CATEGORIES: ReadonlyMap<string, string> = new Map([
|
|
24
25
|
["sentence", "How a sentence is laid out and how long it runs"],
|
|
26
|
+
["paragraph", "How many sentences a paragraph holds before a break"],
|
|
25
27
|
["list", "A list hidden in running prose, promoted to a real markdown list"],
|
|
26
28
|
["punctuation", "A glyph that reads as generated text"],
|
|
27
29
|
]);
|
|
@@ -45,6 +47,8 @@ export interface Registry {
|
|
|
45
47
|
sources: LoadedSource[];
|
|
46
48
|
// Config settings dropped because they name a plugin rule that did not load.
|
|
47
49
|
ignoredSettings: string[];
|
|
50
|
+
// The project's concept file, read once per run, which every document model carries.
|
|
51
|
+
concepts: ConceptModel;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export interface RegistryInput {
|
|
@@ -55,6 +59,7 @@ export interface RegistryInput {
|
|
|
55
59
|
config?: Config;
|
|
56
60
|
// A lenient run drops a setting for an unknown plugin rule instead of failing.
|
|
57
61
|
ignoreUnknownPluginIds?: boolean;
|
|
62
|
+
concepts?: ConceptModel;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
const typeOf = (value: unknown): OptionType | null => {
|
|
@@ -77,13 +82,17 @@ const checkOptions = (where: string, rule: Rule, options: RuleOptions): void =>
|
|
|
77
82
|
throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be a ${want}`);
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
// A budget below one would fail every sentence or every paragraph.
|
|
86
|
+
for (const budget of ["maxWords", "maxSentences"]) {
|
|
87
|
+
if (budget in options && !((options[budget] as number) >= 1)) {
|
|
88
|
+
throw new RuleOptionError(where, rule.id, budget, `option "${budget}" of "${rule.id}" must be at least 1`);
|
|
89
|
+
}
|
|
82
90
|
}
|
|
83
91
|
};
|
|
84
92
|
|
|
85
93
|
export const buildRegistry = (input: RegistryInput): Registry => {
|
|
86
94
|
const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
|
|
95
|
+
const concepts = input.concepts ?? NO_CONCEPTS;
|
|
87
96
|
const off = new Set<string>();
|
|
88
97
|
const options = new Map<RuleId, RuleOptions>();
|
|
89
98
|
const ignored: string[] = [];
|
|
@@ -116,6 +125,7 @@ export const buildRegistry = (input: RegistryInput): Registry => {
|
|
|
116
125
|
categories,
|
|
117
126
|
sources,
|
|
118
127
|
ignoredSettings: ignored,
|
|
128
|
+
concepts,
|
|
119
129
|
};
|
|
120
130
|
};
|
|
121
131
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// sentence-one-per-line: a newline inside a paragraph whose preceding non-space character
|
|
2
|
-
// does not end a sentence is a mid-sentence wrap
|
|
3
|
-
//
|
|
2
|
+
// does not end a sentence is a mid-sentence wrap, and a sentence break with no
|
|
3
|
+
// newline puts several sentences on one line. The fix reflows the paragraph to
|
|
4
|
+
// one sentence per line, so the check reports every paragraph the fix changes.
|
|
4
5
|
|
|
5
6
|
import { isStacked, paragraphsOf } from "../interpunct.ts";
|
|
6
7
|
import type { ParagraphView } from "../model.ts";
|
|
@@ -32,17 +33,35 @@ const wrapsIn = (view: ParagraphView): number[] => {
|
|
|
32
33
|
return wraps;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
// The paragraph
|
|
36
|
-
//
|
|
37
|
-
//
|
|
36
|
+
// The paragraph with each code span masked character for character, keeping
|
|
37
|
+
// whitespace, so offsets stay aligned and a terminal inside a code span never
|
|
38
|
+
// reads as the end of a sentence.
|
|
39
|
+
const masked = (view: ParagraphView): string =>
|
|
40
|
+
[...view.raw].map((ch, i) => (inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch)).join("");
|
|
41
|
+
|
|
42
|
+
// Offsets within the paragraph of a sentence break that has no newline in it,
|
|
43
|
+
// one per line, since one finding per line is enough to name it.
|
|
44
|
+
const packedIn = (view: ParagraphView): number[] => {
|
|
45
|
+
const lines = new Set<number>();
|
|
46
|
+
const at: number[] = [];
|
|
47
|
+
const text = masked(view);
|
|
48
|
+
for (const match of text.matchAll(SENTENCE_BREAK)) {
|
|
49
|
+
if (match[0].includes("\n")) continue;
|
|
50
|
+
const offset = match.index ?? 0;
|
|
51
|
+
const line = text.slice(0, offset).split("\n").length;
|
|
52
|
+
if (lines.has(line)) continue;
|
|
53
|
+
lines.add(line);
|
|
54
|
+
at.push(offset);
|
|
55
|
+
}
|
|
56
|
+
return at;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The paragraph rewritten with one sentence per line.
|
|
38
60
|
const reflow = (view: ParagraphView): string => {
|
|
39
61
|
const indent = " ".repeat(view.startColumn - 1);
|
|
40
62
|
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
63
|
const joined = collapse(view.raw);
|
|
45
|
-
const joinedMask = collapse(masked);
|
|
64
|
+
const joinedMask = collapse(masked(view));
|
|
46
65
|
|
|
47
66
|
const lines: string[] = [];
|
|
48
67
|
let from = 0;
|
|
@@ -55,15 +74,23 @@ const reflow = (view: ParagraphView): string => {
|
|
|
55
74
|
return lines.map((line, i) => (i === 0 ? line.trim() : indent + line.trim())).join(eol);
|
|
56
75
|
};
|
|
57
76
|
|
|
77
|
+
const lineOf = (view: ParagraphView, at: number): number => view.line + view.raw.slice(0, at).split("\n").length - 1;
|
|
78
|
+
|
|
58
79
|
const check: Rule["check"] = ({ doc, file }) =>
|
|
59
|
-
proseParagraphs(doc).flatMap((view) =>
|
|
60
|
-
wrapsIn(view).map((at) => ({
|
|
80
|
+
proseParagraphs(doc).flatMap((view) => [
|
|
81
|
+
...wrapsIn(view).map((at) => ({
|
|
61
82
|
file,
|
|
62
|
-
line: view
|
|
83
|
+
line: lineOf(view, at),
|
|
63
84
|
rule: RULE.WRAP,
|
|
64
85
|
message: "mid-sentence line wrap; write one sentence per line",
|
|
65
86
|
})),
|
|
66
|
-
|
|
87
|
+
...packedIn(view).map((at) => ({
|
|
88
|
+
file,
|
|
89
|
+
line: lineOf(view, at),
|
|
90
|
+
rule: RULE.WRAP,
|
|
91
|
+
message: "several sentences on one line; write one sentence per line",
|
|
92
|
+
})),
|
|
93
|
+
]);
|
|
67
94
|
|
|
68
95
|
const fix: Rule["fix"] = ({ doc, runs }) => {
|
|
69
96
|
const edits: Edit[] = [];
|
|
@@ -93,7 +120,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
|
|
|
93
120
|
export const sentenceOnePerLine: Rule = {
|
|
94
121
|
id: RULE.WRAP,
|
|
95
122
|
category: "sentence",
|
|
96
|
-
summary: "mid-sentence line wrap (one sentence per line)",
|
|
123
|
+
summary: "mid-sentence line wrap or several sentences on one line (one sentence per line)",
|
|
97
124
|
check,
|
|
98
125
|
fix,
|
|
99
126
|
};
|
|
@@ -3,38 +3,49 @@
|
|
|
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 {
|
|
6
|
+
import { type LanguageSentence, sentenceOf } from "../language.ts";
|
|
7
|
+
import { proseParagraphs } from "../query.ts";
|
|
7
8
|
import { words } from "../text.ts";
|
|
8
9
|
import { RULE, type Rule } from "./types.ts";
|
|
9
10
|
|
|
10
11
|
// A clause boundary is a semicolon or colon with text after it, a comma
|
|
11
12
|
// before a coordinating conjunction, or a subordinating or relative word.
|
|
12
|
-
// ", which" matches once, through the subordinator alone.
|
|
13
|
-
//
|
|
13
|
+
// ", which" matches once, through the subordinator alone. An Oxford comma in
|
|
14
|
+
// a list reads as a boundary too.
|
|
14
15
|
const CLAUSE_BOUNDARY =
|
|
15
16
|
/[;:](?=\s+\S)|,\s+(?:and|but|or|so|yet|nor)\b|\b(?:because|although|though|whereas|unless|whenever|wherever|while|when|if|which|who|whom|whose)\b/gi;
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// Two signals, and the count is the larger. Every clause has a verb, so the
|
|
19
|
+
// verb phrases compromise finds catch a series of verbs and a clause joined
|
|
20
|
+
// with no marker word. Its tagger misses some verbs, such as "reparses", so
|
|
21
|
+
// the punctuation and marker words stay as a floor. The count is a guide,
|
|
22
|
+
// not a parse.
|
|
23
|
+
export const clausesOf = (sentence: LanguageSentence): number =>
|
|
24
|
+
Math.max(1 + [...sentence.text.matchAll(CLAUSE_BOUNDARY)].length, sentence.verbPhrases);
|
|
25
|
+
|
|
26
|
+
export const countClauses = (sentence: string): number => clausesOf(sentenceOf(sentence));
|
|
18
27
|
|
|
19
28
|
const excerpt = (sentence: string): string => (sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence);
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
const count = words(sentence);
|
|
23
|
-
const clauses =
|
|
30
|
+
const messageOf = (sentence: LanguageSentence, maxWords: number): string => {
|
|
31
|
+
const count = words(sentence.text);
|
|
32
|
+
const clauses = clausesOf(sentence);
|
|
24
33
|
const target = Math.max(2, clauses, Math.ceil(count / maxWords));
|
|
25
34
|
const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
|
|
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)}"`;
|
|
35
|
+
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.text)}"`;
|
|
27
36
|
};
|
|
28
37
|
|
|
38
|
+
// The message for one sentence, as a plugin's helpers give it.
|
|
39
|
+
export const lengthMessage = (sentence: string, maxWords: number): string => messageOf(sentenceOf(sentence), maxWords);
|
|
40
|
+
|
|
41
|
+
// Sentences come from each paragraph's language view, so an abbreviation or
|
|
42
|
+
// a decimal never ends one early.
|
|
29
43
|
const check: Rule["check"] = ({ doc, file, maxWords }) =>
|
|
30
|
-
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
file,
|
|
34
|
-
|
|
35
|
-
rule: RULE.LENGTH,
|
|
36
|
-
message: lengthMessage(sentence, maxWords),
|
|
37
|
-
}));
|
|
44
|
+
proseParagraphs(doc).flatMap((view) =>
|
|
45
|
+
view.language.sentences
|
|
46
|
+
.filter((sentence) => words(sentence.text) > maxWords)
|
|
47
|
+
.map((sentence) => ({ file, line: view.line, rule: RULE.LENGTH, message: messageOf(sentence, maxWords) })),
|
|
48
|
+
);
|
|
38
49
|
|
|
39
50
|
// No fixer: shortening a sentence changes its words, per PRS-0006.
|
|
40
51
|
export const sentenceWordBudgetExceeded: Rule = {
|