@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/src/project.ts CHANGED
@@ -1,10 +1,11 @@
1
- // Setting up a run: find the project root, read its config, load the plugins,
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
  };
@@ -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
- const fix: Rule["fix"] = ({ doc }) => {
57
- const { src } = doc;
58
- const edits: Edit[] = [];
59
- for (const view of doc.paragraphs) {
60
- const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
61
- const colons = colonsIn(src, view);
62
- if (commas === null || colons === null) continue;
63
- for (const span of sentenceSpans(src, view)) {
64
- const inside = commas.filter((comma) => within(comma, span));
65
- if (inside.length < 2) 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;
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
- // An intro colon in the first segment, before its label, is the lead-in.
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;
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
- const edit = promote({
80
- rule: RULE.LABELLED_RUN,
81
- src,
82
- view,
83
- span,
84
- leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
85
- items: itemsOf(src, items).map((text) => ({ text })),
86
- ordered: false,
87
- });
88
- if (edit) edits.push(edit);
89
- break; // one promotion per paragraph; the engine re-parses and comes back
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 edits;
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
- const fix: Rule["fix"] = ({ doc }) => {
76
- const { src } = doc;
77
- const edits: Edit[] = [];
78
- for (const view of doc.paragraphs) {
79
- const markers = markersIn(src, view.directTexts);
80
- if (markers === null || markers.length < 2) continue;
81
- const sequence = sequenceFor(first(markers).label);
82
- if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) continue;
83
-
84
- const opener = first(markers);
85
- const closer = last(markers);
86
- // Markers split across sentences are two references, not one list.
87
- const between = view.directTexts.map((text) =>
88
- src.slice(Math.max(text.start, opener.range[1]), Math.min(text.end, closer.range[0])),
89
- );
90
- if (between.some((slice) => /[.!?]\s/.test(slice))) continue;
91
-
92
- const span = sentenceSpan(src, view, opener.range[0], closer.range[1]);
93
- // Two markers joined only by a conjunction, with no colon to announce
94
- // them, read as cross-references ("see (a) above and (b) below"). With
95
- // exactly two markers the closer is also the second, so the text between
96
- // them is the whole of the first item.
97
- if (markers.length === 2) {
98
- const announced = /:\s*$/.test(src.slice(span[0], opener.range[0]));
99
- const separated = /[,;]\s*$/.test(src.slice(opener.range[1], closer.range[0]));
100
- if (!announced && !separated) continue;
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
- const edit = promote({
104
- rule: RULE.INLINE_ENUM,
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
- view,
107
- span,
108
- leadIn: cleanLeadIn(src.slice(span[0], opener.range[0])),
109
- items: itemsBetween(
110
- src,
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
- const fix: Rule["fix"] = ({ doc, runs }) => {
30
- const { src } = doc;
31
- const edits: Edit[] = [];
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
- const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
38
- // The innermost colon before the first item, so a lead-in that itself
39
- // contains a colon keeps only its final clause.
40
- const colon = colonsBefore(colons, span, first(separators)[0]).pop();
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
- const cuts: Range[] = [colon ?? [span[0], span[0]], ...separators];
45
- const items = itemsBetween(src, cuts, span[1]);
46
- if (tailIsProse(items) || (!colon && headIsProse(items))) continue;
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
- const edit = promote({
49
- rule: RULE.INTERPUNCT_RUN,
50
- src,
51
- view,
52
- span,
53
- leadIn: colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
54
- items: items.map((text) => ({ text })),
55
- ordered: false,
56
- });
57
- if (edit) edits.push(edit);
58
- }
59
- return edits;
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
+ };
@@ -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
- if ("maxWords" in options && !((options.maxWords as number) >= 1)) {
81
- throw new RuleOptionError(where, rule.id, "maxWords", `option "maxWords" of "${rule.id}" must be at least 1`);
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. The fix reflows the
3
- // paragraph to one sentence per line.
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 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.
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.line + view.raw.slice(0, at).split("\n").length - 1,
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 { proseSentences } from "../query.ts";
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. The count is a
13
- // guide, not a parse: an Oxford comma in a list reads as a boundary too.
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
- export const countClauses = (sentence: string): number => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
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
- export const lengthMessage = (sentence: string, maxWords: number): string => {
22
- const count = words(sentence);
23
- const clauses = countClauses(sentence);
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
- 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
- }));
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 = {