@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/dist/bin.js CHANGED
@@ -9986,7 +9986,6 @@ var buildDocModel = (src) => {
9986
9986
  texts.push({
9987
9987
  value: t.value,
9988
9988
  line: t.position?.start.line ?? 0,
9989
- offset: start,
9990
9989
  start,
9991
9990
  end,
9992
9991
  block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell")
@@ -10013,24 +10012,109 @@ var buildDocModel = (src) => {
10013
10012
  }
10014
10013
  return { src, tree, paragraphs, texts, fences };
10015
10014
  };
10015
+
10016
+ // src/text.ts
10017
+ var first = (xs) => xs[0];
10018
+ var last = (xs) => xs[xs.length - 1];
10019
+ var within = (r, span) => r[0] >= span[0] && r[1] <= span[1];
10020
+ var collapse = (s) => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
10021
+ var countOf = (text4, pattern) => [...text4.matchAll(pattern)].length;
10022
+ var spansBetween = (cuts, end) => cuts.map((cut, i) => {
10023
+ const next = cuts[i + 1];
10024
+ return [cut[1], next ? next[0] : end];
10025
+ });
10026
+ var words = (text4) => text4.split(/\s+/).filter(Boolean).length;
10016
10027
  var inRanges = (offset, ranges) => ranges.some(([a, b]) => offset >= a && offset < b);
10017
10028
  var sentences = (text4) => text4.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.length > 0);
10018
10029
 
10019
- // src/rules/interpunct.ts
10030
+ // src/query.ts
10031
+ var proseParagraphs = (doc) => doc.paragraphs.filter((view) => !view.inTable);
10032
+ var proseSentences = (doc) => proseParagraphs(doc).flatMap((view) => sentences(view.prose).map((sentence) => ({ view, sentence })));
10033
+ var textsContaining = (doc, glyph) => doc.texts.filter((text4) => text4.value.includes(glyph));
10034
+ var matchesIn = (src, text4, pattern) => {
10035
+ const slice = src.slice(text4.start, text4.end);
10036
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
10037
+ const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
10038
+ const inValue = [...text4.value.matchAll(new RegExp(pattern.source, flags))];
10039
+ if (inSlice.length !== inValue.length)
10040
+ return null;
10041
+ return inSlice.map((m) => [text4.start + (m.index ?? 0), text4.start + (m.index ?? 0) + m[0].length]);
10042
+ };
10043
+ var matchesInAll = (src, texts, pattern) => {
10044
+ const out = [];
10045
+ for (const t of texts) {
10046
+ const found = matchesIn(src, t, pattern);
10047
+ if (found === null)
10048
+ return null;
10049
+ out.push(...found);
10050
+ }
10051
+ return out;
10052
+ };
10053
+ var escaped = (src, offset) => src[offset - 1] === "\\";
10054
+ var splittableSeparators = (src, view, separator, glyph) => {
10055
+ const found = matchesInAll(src, view.directTexts, separator);
10056
+ if (found === null)
10057
+ return null;
10058
+ if (found.length !== countOf(view.prose, glyph))
10059
+ return null;
10060
+ if (found.some(([start]) => escaped(src, start)))
10061
+ return null;
10062
+ return found;
10063
+ };
10064
+ var boundaries = (src, view) => {
10065
+ const out = [];
10066
+ for (const t of view.directTexts) {
10067
+ const slice = src.slice(t.start, t.end);
10068
+ for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
10069
+ const at = t.start + (m.index ?? 0);
10070
+ out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
10071
+ }
10072
+ }
10073
+ return out.sort((a, b) => a[0] - b[0]);
10074
+ };
10075
+ var sentenceSpan = (src, view, from, to) => {
10076
+ let start = view.startOffset;
10077
+ let end = view.endOffset;
10078
+ for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10079
+ if (nextStart <= from)
10080
+ start = nextStart;
10081
+ if (terminalEnd >= to && end === view.endOffset)
10082
+ end = terminalEnd;
10083
+ }
10084
+ return [start, end];
10085
+ };
10086
+ var sentenceSpans = (src, view) => {
10087
+ const spans = [];
10088
+ let start = view.startOffset;
10089
+ for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10090
+ spans.push([start, terminalEnd]);
10091
+ start = nextStart;
10092
+ }
10093
+ spans.push([start, view.endOffset]);
10094
+ return spans;
10095
+ };
10096
+ var colonsIn = (src, view) => matchesInAll(src, view.directTexts, /:/);
10097
+ var colonsBefore = (colons, range, item) => colons.filter((colon) => within(colon, range) && colon[1] <= item);
10098
+
10099
+ // src/interpunct.ts
10100
+ var INTERPUNCT = "·";
10101
+ var GLYPH = /·/g;
10102
+ var RUN_SEPARATOR = /[ \t]*·[ \t]*/;
10020
10103
  var interpunctRuns = (doc) => {
10021
10104
  const runs = [];
10022
- for (const p of doc.paragraphs) {
10023
- if (p.inTable)
10105
+ for (const view of proseParagraphs(doc)) {
10106
+ const separators = countOf(view.prose, GLYPH);
10107
+ if (separators < 2)
10024
10108
  continue;
10025
- const separators = (p.prose.match(/·/g) ?? []).length;
10026
- if (separators >= 2) {
10027
- const lines = p.raw.split(`
10028
- `).filter((l) => l.includes("·")).length;
10029
- runs.push({ range: [p.startOffset, p.endOffset], line: p.line, separators, lines });
10030
- }
10109
+ const lines = view.raw.split(`
10110
+ `).filter((line) => line.includes(INTERPUNCT)).length;
10111
+ runs.push({ range: [view.startOffset, view.endOffset], line: view.line, separators, lines });
10031
10112
  }
10032
10113
  return runs;
10033
10114
  };
10115
+ var isFlat = (run) => run.lines === 1;
10116
+ var isStacked = (run) => run.lines >= 2;
10117
+ var paragraphsOf = (doc, runs) => doc.paragraphs.filter((view) => runs.some((run) => run.range[0] === view.startOffset));
10034
10118
 
10035
10119
  // src/rules/types.ts
10036
10120
  var RULE = {
@@ -10048,56 +10132,68 @@ var CATEGORIES = ["sentence", "list", "punctuation"];
10048
10132
 
10049
10133
  // src/rules/pg001-wrap.ts
10050
10134
  var TERMINALS = new Set([".", "!", "?", ":", ";"]);
10051
- var check = ({ doc, file }) => {
10052
- const findings = [];
10053
- for (const p of doc.paragraphs) {
10054
- if (p.inTable)
10055
- continue;
10056
- for (let i = 0;i < p.raw.length; i++) {
10057
- if (p.raw[i] !== `
10135
+ var TRAILING = new Set([" ", "\t", "\r", "\\"]);
10136
+ var SENTENCE_BREAK = /[.!?]\s+(?=[A-Z`"'([])/g;
10137
+ var wrapsIn = (view) => {
10138
+ const wraps = [];
10139
+ for (let i = 0;i < view.raw.length; i++) {
10140
+ if (view.raw[i] !== `
10058
10141
  `)
10059
- continue;
10060
- if (inRanges(p.startOffset + i, p.codeRanges))
10061
- continue;
10062
- let j = i - 1;
10063
- while (j >= 0 && (p.raw[j] === " " || p.raw[j] === "\t" || p.raw[j] === "\r" || p.raw[j] === "\\"))
10064
- j--;
10065
- if (j >= 0 && !TERMINALS.has(p.raw[j])) {
10066
- const line = p.line + p.raw.slice(0, i).split(`
10067
- `).length - 1;
10068
- findings.push({ file, line, rule: RULE.WRAP, message: "mid-sentence line wrap; write one sentence per line" });
10069
- }
10070
- }
10142
+ continue;
10143
+ if (inRanges(view.startOffset + i, view.codeRanges))
10144
+ continue;
10145
+ let j = i - 1;
10146
+ while (j >= 0 && TRAILING.has(view.raw.charAt(j)))
10147
+ j--;
10148
+ if (j >= 0 && !TERMINALS.has(view.raw.charAt(j)))
10149
+ wraps.push(i);
10071
10150
  }
10072
- return findings;
10151
+ return wraps;
10073
10152
  };
10074
- var fix = (doc) => {
10075
- const edits = [];
10076
- const stacked = interpunctRuns(doc).filter((r) => r.lines >= 2).map((r) => r.range[0]);
10077
- for (const p of doc.paragraphs) {
10078
- if (p.inTable || p.inBlockquote || p.hasBreak || stacked.includes(p.startOffset))
10079
- continue;
10080
- const indent2 = " ".repeat(p.startColumn - 1);
10081
- const eol = p.raw.includes(`\r
10153
+ var reflow = (view) => {
10154
+ const indent2 = " ".repeat(view.startColumn - 1);
10155
+ const eol = view.raw.includes(`\r
10082
10156
  `) ? `\r
10083
10157
  ` : `
10084
10158
  `;
10085
- const masked = [...p.raw].map((ch, i) => inRanges(p.startOffset + i, p.codeRanges) && !/\s/.test(ch) ? "x" : ch).join("");
10086
- const join = (s) => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
10087
- const joined = join(p.raw);
10088
- const joinedMask = join(masked);
10089
- const out = [];
10090
- let last = 0;
10091
- for (const m of joinedMask.matchAll(/[.!?]\s+(?=[A-Z`"'([])/g)) {
10092
- const at = m.index ?? 0;
10093
- out.push(joined.slice(last, at + 1));
10094
- last = at + m[0].length;
10095
- }
10096
- out.push(joined.slice(last));
10097
- const text4 = out.map((s, i) => i === 0 ? s.trim() : indent2 + s.trim()).join(eol);
10098
- if (text4 !== p.raw) {
10099
- edits.push({ rule: RULE.WRAP, start: p.startOffset, end: p.endOffset, text: text4, expect: { kind: "same-tree" } });
10100
- }
10159
+ const masked = [...view.raw].map((ch, i) => inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch).join("");
10160
+ const joined = collapse(view.raw);
10161
+ const joinedMask = collapse(masked);
10162
+ const lines = [];
10163
+ let from = 0;
10164
+ for (const match of joinedMask.matchAll(SENTENCE_BREAK)) {
10165
+ const at = match.index ?? 0;
10166
+ lines.push(joined.slice(from, at + 1));
10167
+ from = at + match[0].length;
10168
+ }
10169
+ lines.push(joined.slice(from));
10170
+ return lines.map((line, i) => i === 0 ? line.trim() : indent2 + line.trim()).join(eol);
10171
+ };
10172
+ var check = ({ doc, file }) => proseParagraphs(doc).flatMap((view) => wrapsIn(view).map((at) => ({
10173
+ file,
10174
+ line: view.line + view.raw.slice(0, at).split(`
10175
+ `).length - 1,
10176
+ rule: RULE.WRAP,
10177
+ message: "mid-sentence line wrap; write one sentence per line"
10178
+ })));
10179
+ var fix = ({ doc, runs }) => {
10180
+ const edits = [];
10181
+ const stacked = new Set(paragraphsOf(doc, runs.filter(isStacked)));
10182
+ for (const view of proseParagraphs(doc)) {
10183
+ if (view.inBlockquote || view.hasBreak)
10184
+ continue;
10185
+ if (stacked.has(view))
10186
+ continue;
10187
+ const text4 = reflow(view);
10188
+ if (text4 === view.raw)
10189
+ continue;
10190
+ edits.push({
10191
+ rule: RULE.WRAP,
10192
+ start: view.startOffset,
10193
+ end: view.endOffset,
10194
+ text: text4,
10195
+ expect: { kind: "same-tree" }
10196
+ });
10101
10197
  }
10102
10198
  return edits;
10103
10199
  };
@@ -10112,106 +10208,46 @@ var pg001 = {
10112
10208
  // src/rules/pg002-length.ts
10113
10209
  var CLAUSE_BOUNDARY = /[;:](?=\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;
10114
10210
  var countClauses = (sentence) => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
10115
- var excerpt = (s) => s.length > 60 ? `${s.slice(0, 60)}...` : s;
10211
+ var excerpt = (sentence) => sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence;
10116
10212
  var lengthMessage = (sentence, maxWords) => {
10117
- const words = sentence.split(/\s+/).length;
10213
+ const count = words(sentence);
10118
10214
  const clauses = countClauses(sentence);
10119
- const target = Math.max(2, clauses, Math.ceil(words / maxWords));
10215
+ const target = Math.max(2, clauses, Math.ceil(count / maxWords));
10120
10216
  const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
10121
- return `sentence has ${words} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
10217
+ 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)}"`;
10122
10218
  };
10219
+ var check2 = ({ doc, file, maxWords }) => proseSentences(doc).filter(({ sentence }) => words(sentence) > maxWords).map(({ view, sentence }) => ({
10220
+ file,
10221
+ line: view.line,
10222
+ rule: RULE.LENGTH,
10223
+ message: lengthMessage(sentence, maxWords)
10224
+ }));
10123
10225
  var pg002 = {
10124
10226
  id: RULE.LENGTH,
10125
10227
  category: "sentence",
10126
10228
  summary: "sentence longer than the word budget (default 25)",
10127
- check: ({ doc, file, maxWords }) => {
10128
- const findings = [];
10129
- for (const p of doc.paragraphs) {
10130
- if (p.inTable)
10131
- continue;
10132
- for (const s of sentences(p.prose)) {
10133
- if (s.split(/\s+/).length > maxWords) {
10134
- findings.push({ file, line: p.line, rule: RULE.LENGTH, message: lengthMessage(s, maxWords) });
10135
- }
10136
- }
10137
- }
10138
- return findings;
10139
- }
10140
- };
10141
-
10142
- // src/fix/spans.ts
10143
- var matchesIn = (src, text4, pattern) => {
10144
- const slice = src.slice(text4.start, text4.end);
10145
- const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
10146
- const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
10147
- const inValue = [...text4.value.matchAll(new RegExp(pattern.source, flags))];
10148
- if (inSlice.length !== inValue.length)
10149
- return null;
10150
- return inSlice.map((m) => [text4.start + (m.index ?? 0), text4.start + (m.index ?? 0) + m[0].length]);
10151
- };
10152
- var matchesInAll = (src, texts, pattern) => {
10153
- const out = [];
10154
- for (const t of texts) {
10155
- const found = matchesIn(src, t, pattern);
10156
- if (found === null)
10157
- return null;
10158
- out.push(...found);
10159
- }
10160
- return out;
10161
- };
10162
- var boundaries = (src, view) => {
10163
- const out = [];
10164
- for (const t of view.directTexts) {
10165
- const slice = src.slice(t.start, t.end);
10166
- for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
10167
- const at = t.start + (m.index ?? 0);
10168
- out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
10169
- }
10170
- }
10171
- return out.sort((a, b) => a[0] - b[0]);
10229
+ check: check2
10172
10230
  };
10173
- var sentenceSpan = (src, view, from, to) => {
10174
- let start = view.startOffset;
10175
- let end = view.endOffset;
10176
- for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10177
- if (nextStart <= from)
10178
- start = nextStart;
10179
- if (terminalEnd >= to && end === view.endOffset)
10180
- end = terminalEnd;
10181
- }
10182
- return [start, end];
10183
- };
10184
- var sentenceSpans = (src, view) => {
10185
- const spans = [];
10186
- let start = view.startOffset;
10187
- for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10188
- spans.push([start, terminalEnd]);
10189
- start = nextStart;
10190
- }
10191
- spans.push([start, view.endOffset]);
10192
- return spans;
10193
- };
10194
- var within = (r, span) => r[0] >= span[0] && r[1] <= span[1];
10195
- var collapse = (s) => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
10196
- var escaped = (src, offset) => src[offset - 1] === "\\";
10197
10231
 
10198
10232
  // src/fix/promote.ts
10199
10233
  var UNSAFE_START = /^(?:[-+*](?:\s|$)|#{1,6}(?:\s|$)|>|\d{1,9}[.)](?:\s|$)|`{3}|~{3}|<|\||[-_*=](?:\s*[-_*=]){2,}\s*$)/;
10200
10234
  var CONJUNCTION_HEAD = /^(?:and|or)\s+/i;
10201
10235
  var CONJUNCTION_TAIL = /\s+(?:and|or)$/i;
10202
- var cleanItem = (raw, { last }) => {
10236
+ var cleanItem = (raw, { last: last2 }) => {
10203
10237
  let s = collapse(raw).trim();
10204
10238
  for (let prev = "";prev !== s; ) {
10205
10239
  prev = s;
10206
10240
  s = s.replace(/[;,]+$/, "").trim();
10207
10241
  s = s.replace(CONJUNCTION_TAIL, "").trim();
10208
- if (last)
10242
+ if (last2)
10209
10243
  s = s.replace(CONJUNCTION_HEAD, "").trim();
10210
10244
  }
10211
- if (last)
10245
+ if (last2)
10212
10246
  s = s.replace(/(?<!\.)\.$/, "").trim();
10213
10247
  return s;
10214
10248
  };
10249
+ var itemsOf = (src, spans) => spans.map((span, i) => cleanItem(src.slice(span[0], span[1]), { last: i + 1 === spans.length }));
10250
+ var itemsBetween = (src, cuts, end) => itemsOf(src, spansBetween(cuts, end));
10215
10251
  var cleanLeadIn = (raw) => {
10216
10252
  const s = collapse(raw).trim().replace(/[\s:;,—-]+$/, "").trim();
10217
10253
  return s.length > 0 ? `${s}:` : null;
@@ -10264,52 +10300,35 @@ ${list2}`;
10264
10300
  };
10265
10301
 
10266
10302
  // src/rules/pg003-semicolon-list.ts
10267
- var check2 = ({ doc, file }) => {
10268
- const findings = [];
10269
- for (const p of doc.paragraphs) {
10270
- if (p.inTable)
10271
- continue;
10272
- for (const s of sentences(p.prose)) {
10273
- const semis = (s.match(/;/g) ?? []).length;
10274
- if (semis >= 2) {
10275
- findings.push({
10276
- file,
10277
- line: p.line,
10278
- rule: RULE.SEMICOLON_LIST,
10279
- message: `semicolon-delimited list (${semis} ';'); promote to a bullet list`
10280
- });
10281
- }
10282
- }
10283
- }
10284
- return findings;
10285
- };
10286
- var fix2 = (doc) => {
10287
- const edits = [];
10303
+ var SEMICOLON = /;/g;
10304
+ var check3 = ({ doc, file }) => proseSentences(doc).map(({ view, sentence }) => ({ view, semicolons: countOf(sentence, SEMICOLON) })).filter(({ semicolons }) => semicolons >= 2).map(({ view, semicolons }) => ({
10305
+ file,
10306
+ line: view.line,
10307
+ rule: RULE.SEMICOLON_LIST,
10308
+ message: `semicolon-delimited list (${semicolons} ';'); promote to a bullet list`
10309
+ }));
10310
+ var fix2 = ({ doc }) => {
10288
10311
  const { src } = doc;
10312
+ const edits = [];
10289
10313
  for (const view of doc.paragraphs) {
10290
- const semis = matchesInAll(src, view.directTexts, /;/);
10291
- const colons = matchesInAll(src, view.directTexts, /:/);
10292
- if (semis === null || colons === null)
10314
+ const semicolons = matchesInAll(src, view.directTexts, SEMICOLON);
10315
+ const colons = colonsIn(src, view);
10316
+ if (semicolons === null || colons === null)
10293
10317
  continue;
10294
10318
  for (const span of sentenceSpans(src, view)) {
10295
- const inside = semis.filter((r) => within(r, span));
10319
+ const inside = semicolons.filter((semicolon) => within(semicolon, span));
10296
10320
  if (inside.length < 2)
10297
10321
  continue;
10298
- const colon = colons.find((r) => within(r, span) && r[1] <= inside[0][0]);
10322
+ const colon = colonsBefore(colons, span, first(inside)[0])[0];
10299
10323
  if (!colon)
10300
10324
  continue;
10301
- const cuts = [colon, ...inside];
10302
- const items = cuts.map((cut, i) => {
10303
- const end = i + 1 < cuts.length ? cuts[i + 1][0] : span[1];
10304
- return { text: cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length }) };
10305
- });
10306
10325
  const edit = promote({
10307
10326
  rule: RULE.SEMICOLON_LIST,
10308
10327
  src,
10309
10328
  view,
10310
10329
  span,
10311
10330
  leadIn: cleanLeadIn(src.slice(span[0], colon[0])),
10312
- items,
10331
+ items: itemsBetween(src, [colon, ...inside], span[1]).map((text4) => ({ text: text4 })),
10313
10332
  ordered: false
10314
10333
  });
10315
10334
  if (edit)
@@ -10323,115 +10342,124 @@ var pg003 = {
10323
10342
  id: RULE.SEMICOLON_LIST,
10324
10343
  category: "list",
10325
10344
  summary: "semicolon-delimited list (2+ ';' in one sentence)",
10326
- check: check2,
10345
+ check: check3,
10327
10346
  fix: fix2
10328
10347
  };
10329
10348
 
10330
10349
  // src/rules/pg004-em-dash.ts
10331
- var DASH = /[\p{Zs}\t\u200b]*—[\p{Zs}\t\u200b]*/u;
10350
+ var EM_DASH = "—";
10351
+ var DASH = /[\p{Zs}\t​]*—[\p{Zs}\t​]*/u;
10332
10352
  var MARKER_LIKE = /^(?:[a-z]|[ivx]+|\d+)$/i;
10333
- var fix3 = (doc) => {
10334
- const { src } = doc;
10353
+ var blocksOf = (doc) => {
10335
10354
  const byBlock = new Map;
10336
- for (const t of doc.texts) {
10337
- if (t.block === undefined || t.start === undefined || t.end === undefined)
10355
+ for (const { block, start, end, value: value2 } of doc.texts) {
10356
+ if (block === undefined || start === undefined || end === undefined)
10338
10357
  continue;
10339
- const entry = byBlock.get(t.block) ?? { texts: [], dashes: [], refused: false };
10340
- byBlock.set(t.block, entry);
10341
- entry.texts.push(t);
10342
- const found = matchesIn(src, { value: t.value, start: t.start, end: t.end }, DASH);
10358
+ let entry = byBlock.get(block);
10359
+ if (!entry) {
10360
+ entry = { texts: [], dashes: [], refused: false };
10361
+ byBlock.set(block, entry);
10362
+ }
10363
+ const text4 = { value: value2, start, end };
10364
+ entry.texts.push(text4);
10365
+ const found = matchesIn(doc.src, text4, DASH);
10343
10366
  if (found === null)
10344
10367
  entry.refused = true;
10345
10368
  else
10346
- entry.dashes.push(...found.map((range) => ({ range, text: t })));
10369
+ entry.dashes.push(...found.map((range) => ({ range, text: text4 })));
10347
10370
  }
10348
- const edits = [];
10349
- for (const { texts, dashes, refused } of byBlock.values()) {
10350
- if (refused || dashes.length === 0)
10351
- continue;
10352
- const terminals = [];
10353
- const colons = [];
10354
- for (const t of texts) {
10355
- const slice = src.slice(t.start, t.end);
10356
- for (const m of slice.matchAll(/[.!?](?=\s)/g))
10357
- terminals.push(t.start + (m.index ?? 0));
10358
- for (const m of slice.matchAll(/:/g))
10359
- colons.push(t.start + (m.index ?? 0));
10360
- }
10361
- const sentenceOf = (offset) => terminals.filter((x) => x < offset).length;
10362
- const groups = new Map;
10363
- for (const d of dashes) {
10364
- const key = sentenceOf(d.range[0]);
10365
- groups.set(key, [...groups.get(key) ?? [], d]);
10366
- }
10367
- for (const [sentence, group] of groups) {
10368
- const edit = rewrite(src, group, sentence, sentenceOf, colons);
10369
- if (edit)
10370
- edits.push(edit);
10371
- }
10371
+ return [...byBlock.values()];
10372
+ };
10373
+ var offsetsIn = (src, texts, pattern) => texts.flatMap((text4) => [...src.slice(text4.start, text4.end).matchAll(pattern)].map((m) => text4.start + (m.index ?? 0)));
10374
+ var asidesIn = (src, { texts, dashes }) => {
10375
+ const terminals = offsetsIn(src, texts, /[.!?](?=\s)/g);
10376
+ const colons = offsetsIn(src, texts, /:/g);
10377
+ const sentenceOf = (offset) => terminals.filter((terminal) => terminal < offset).length;
10378
+ const bySentence = new Map;
10379
+ for (const dash of dashes) {
10380
+ const sentence = sentenceOf(dash.range[0]);
10381
+ const group = bySentence.get(sentence);
10382
+ if (group)
10383
+ group.push(dash);
10384
+ else
10385
+ bySentence.set(sentence, [dash]);
10372
10386
  }
10373
- return edits;
10387
+ return [...bySentence].map(([sentence, group]) => ({
10388
+ dashes: group,
10389
+ hasColon: colons.some((colon) => sentenceOf(colon) === sentence)
10390
+ }));
10374
10391
  };
10375
- var rewrite = (src, group, sentence, sentenceOf, colons) => {
10376
- const first = group[0];
10377
- const last = group[group.length - 1];
10378
- const touchesEdge = (d) => d.range[0] === d.text.start || d.range[1] === d.text.end || /[\r\n.!?:;,(\\]/.test(src[d.range[0] - 1] ?? `
10379
- `) || /[\r\n.!?:;,)]/.test(src[d.range[1]] ?? `
10392
+ var touchesEdge = (src, dash) => dash.range[0] === dash.text.start || dash.range[1] === dash.text.end || /[\r\n.!?:;,(\\]/.test(src[dash.range[0] - 1] ?? `
10393
+ `) || /[\r\n.!?:;,)]/.test(src[dash.range[1]] ?? `
10380
10394
  `);
10381
- if (group.some(touchesEdge))
10395
+ var replacementFor = (src, { dashes, hasColon }) => {
10396
+ const opening = first(dashes);
10397
+ const closing = last(dashes);
10398
+ if (dashes.length === 2) {
10399
+ const inner = src.slice(opening.range[1], closing.range[0]);
10400
+ return MARKER_LIKE.test(inner.trim()) ? null : ` (${inner}) `;
10401
+ }
10402
+ if (dashes.length === 1) {
10403
+ const numericRange = /\d/.test(src[opening.range[0] - 1] ?? "") && /\d/.test(src[opening.range[1]] ?? "");
10404
+ return numericRange || hasColon ? null : ": ";
10405
+ }
10406
+ return null;
10407
+ };
10408
+ var rewrite = (src, aside) => {
10409
+ if (aside.dashes.some((dash) => touchesEdge(src, dash)))
10382
10410
  return null;
10383
- let text4;
10384
- if (group.length === 2) {
10385
- const inner = src.slice(first.range[1], last.range[0]);
10386
- if (MARKER_LIKE.test(inner.trim()))
10387
- return null;
10388
- text4 = ` (${inner}) `;
10389
- } else if (group.length === 1) {
10390
- if (/\d/.test(src[first.range[0] - 1] ?? "") && /\d/.test(src[first.range[1]] ?? ""))
10391
- return null;
10392
- if (colons.some((c) => sentenceOf(c) === sentence))
10393
- return null;
10394
- text4 = ": ";
10395
- } else {
10411
+ const text4 = replacementFor(src, aside);
10412
+ if (text4 === null)
10396
10413
  return null;
10397
- }
10398
10414
  return {
10399
10415
  rule: RULE.EM_DASH,
10400
- start: first.range[0],
10401
- end: last.range[1],
10416
+ start: first(aside.dashes).range[0],
10417
+ end: last(aside.dashes).range[1],
10402
10418
  text: text4,
10403
10419
  expect: { kind: "same-shape" }
10404
10420
  };
10405
10421
  };
10422
+ var check4 = ({ doc, file }) => textsContaining(doc, EM_DASH).map((text4) => ({
10423
+ file,
10424
+ line: text4.line,
10425
+ rule: RULE.EM_DASH,
10426
+ message: "em-dash in prose; use comma, colon, parentheses, or two sentences"
10427
+ }));
10428
+ var fix3 = ({ doc }) => blocksOf(doc).filter((block) => !block.refused && block.dashes.length > 0).flatMap((block) => asidesIn(doc.src, block)).map((aside) => rewrite(doc.src, aside)).filter((edit) => edit !== null);
10406
10429
  var pg004 = {
10407
10430
  id: RULE.EM_DASH,
10408
10431
  category: "punctuation",
10409
10432
  summary: "em-dash U+2014 in prose",
10410
- check: ({ doc, file }) => doc.texts.filter((t) => t.value.includes("—")).map((t) => ({
10411
- file,
10412
- line: t.line,
10413
- rule: RULE.EM_DASH,
10414
- message: "em-dash in prose; use comma, colon, parentheses, or two sentences"
10415
- })),
10433
+ check: check4,
10416
10434
  fix: fix3
10417
10435
  };
10418
10436
 
10419
10437
  // src/rules/pg005-interpunct.ts
10420
- var fix4 = (doc) => {
10421
- const runs = interpunctRuns(doc).map((r) => r.range);
10438
+ var SEPARATOR = /[ \t]+·[ \t]+/;
10439
+ var check5 = ({ doc, file, runs }) => {
10440
+ const reported = runs.map((run) => run.range);
10441
+ return textsContaining(doc, INTERPUNCT).filter((text4) => text4.start === undefined || !inRanges(text4.start, reported)).map((text4) => ({
10442
+ file,
10443
+ line: text4.line,
10444
+ rule: RULE.INTERPUNCT,
10445
+ message: "interpunct in prose; use a comma, slash, or a list"
10446
+ }));
10447
+ };
10448
+ var fix4 = ({ doc, runs }) => {
10449
+ const reported = runs.map((run) => run.range);
10422
10450
  const edits = [];
10423
- for (const t of doc.texts) {
10424
- if (t.start === undefined || t.end === undefined || !t.value.includes("·"))
10451
+ for (const { start, end, value: value2 } of textsContaining(doc, INTERPUNCT)) {
10452
+ if (start === undefined || end === undefined)
10425
10453
  continue;
10426
- if (inRanges(t.start, runs))
10454
+ if (inRanges(start, reported))
10427
10455
  continue;
10428
- const text4 = { value: t.value, start: t.start, end: t.end };
10429
- const glyphs = matchesIn(doc.src, text4, /·/);
10430
- const spaced = matchesIn(doc.src, text4, /[ \t]+·[ \t]+/);
10431
- if (glyphs === null || spaced === null || glyphs.length !== spaced.length)
10456
+ const text4 = { value: value2, start, end };
10457
+ const glyphs = matchesIn(doc.src, text4, GLYPH);
10458
+ const separators = matchesIn(doc.src, text4, SEPARATOR);
10459
+ if (glyphs === null || separators === null || glyphs.length !== separators.length)
10432
10460
  continue;
10433
- for (const [start, end] of spaced) {
10434
- edits.push({ rule: RULE.INTERPUNCT, start, end, text: ", ", expect: { kind: "same-shape" } });
10461
+ for (const [from, to] of separators) {
10462
+ edits.push({ rule: RULE.INTERPUNCT, start: from, end: to, text: ", ", expect: { kind: "same-shape" } });
10435
10463
  }
10436
10464
  }
10437
10465
  return edits;
@@ -10440,51 +10468,33 @@ var pg005 = {
10440
10468
  id: RULE.INTERPUNCT,
10441
10469
  category: "punctuation",
10442
10470
  summary: "interpunct U+00B7 in prose",
10443
- check: ({ doc, file, runs }) => {
10444
- const suppressed = runs.map((r) => r.range);
10445
- return doc.texts.filter((t) => t.value.includes("·") && !(t.offset != null && inRanges(t.offset, suppressed))).map((t) => ({
10446
- file,
10447
- line: t.line,
10448
- rule: RULE.INTERPUNCT,
10449
- message: "interpunct in prose; use a comma, slash, or a list"
10450
- }));
10451
- },
10471
+ check: check5,
10452
10472
  fix: fix4
10453
10473
  };
10454
10474
 
10455
10475
  // src/rules/pg006-interpunct-run.ts
10456
- var words = (s) => s.split(/\s+/).filter(Boolean).length;
10457
- var tailIsProse = (items) => {
10458
- const last = words(items[items.length - 1] ?? "");
10459
- const others = Math.max(...items.slice(0, -1).map(words));
10460
- return last > Math.max(8, 3 * others);
10461
- };
10462
- var headIsProse = (items) => {
10463
- const first = words(items[0] ?? "");
10464
- return first > Math.max(...items.slice(1).map(words));
10465
- };
10466
- var fix5 = (doc) => {
10476
+ var tailIsProse = (items) => words(last(items)) > Math.max(8, 3 * Math.max(...items.slice(0, -1).map(words)));
10477
+ var headIsProse = (items) => words(first(items)) > Math.max(...items.slice(1).map(words));
10478
+ var check6 = ({ file, runs }) => runs.filter(isFlat).map((run) => ({
10479
+ file,
10480
+ line: run.line,
10481
+ rule: RULE.INTERPUNCT_RUN,
10482
+ message: `interpunct-joined inline list (${run.separators + 1} items); promote to a bullet list`
10483
+ }));
10484
+ var fix5 = ({ doc, runs }) => {
10467
10485
  const { src } = doc;
10468
10486
  const edits = [];
10469
- const runs = interpunctRuns(doc).filter((r) => r.lines === 1);
10470
- for (const view of doc.paragraphs) {
10471
- if (!runs.some((r) => r.range[0] === view.startOffset))
10487
+ for (const view of paragraphsOf(doc, runs.filter(isFlat))) {
10488
+ const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
10489
+ const colons = colonsIn(src, view);
10490
+ if (separators === null || colons === null)
10472
10491
  continue;
10473
- const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
10474
- const colons = matchesInAll(src, view.directTexts, /:/);
10475
- if (seps === null || colons === null || seps.length !== (view.prose.match(/·/g) ?? []).length || seps.some(([s]) => escaped(src, s)))
10476
- continue;
10477
- const first = seps[0];
10478
- const last = seps[seps.length - 1];
10479
- const span = sentenceSpan(src, view, first[0], last[1]);
10480
- const colon = colons.filter((c) => within(c, span) && c[1] <= first[0]).pop();
10492
+ const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
10493
+ const colon = colonsBefore(colons, span, first(separators)[0]).pop();
10481
10494
  if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset))
10482
10495
  continue;
10483
- const cuts = [colon ?? [span[0], span[0]], ...seps];
10484
- const items = cuts.map((cut, i) => {
10485
- const end = i + 1 < cuts.length ? cuts[i + 1][0] : span[1];
10486
- return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
10487
- });
10496
+ const cuts = [colon ?? [span[0], span[0]], ...separators];
10497
+ const items = itemsBetween(src, cuts, span[1]);
10488
10498
  if (tailIsProse(items) || !colon && headIsProse(items))
10489
10499
  continue;
10490
10500
  const edit = promote({
@@ -10505,12 +10515,7 @@ var pg006 = {
10505
10515
  id: RULE.INTERPUNCT_RUN,
10506
10516
  category: "list",
10507
10517
  summary: "interpunct-joined inline list (2+ separators)",
10508
- check: ({ file, runs }) => runs.filter((r) => r.lines < 2).map((r) => ({
10509
- file,
10510
- line: r.line,
10511
- rule: RULE.INTERPUNCT_RUN,
10512
- message: `interpunct-joined inline list (${r.separators + 1} items); promote to a bullet list`
10513
- })),
10518
+ check: check6,
10514
10519
  fix: fix5
10515
10520
  };
10516
10521
 
@@ -10522,62 +10527,74 @@ var ENUM_FAMILIES = [
10522
10527
  ];
10523
10528
  var ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii"];
10524
10529
  var LETTERS = [..."abcdefghijklmnopqrstuvwxyz"];
10525
- var sequenceFor = (first) => {
10526
- if (first === "a")
10530
+ var sequenceFor = (label) => {
10531
+ if (label === "a")
10527
10532
  return LETTERS;
10528
- if (first === "1")
10533
+ if (label === "1")
10529
10534
  return Array.from({ length: 99 }, (_, i) => String(i + 1));
10530
- if (first === "i")
10535
+ if (label === "i")
10531
10536
  return ROMAN;
10532
10537
  return null;
10533
10538
  };
10534
10539
  var MARKER = /\(([a-z]|[ivx]+|\d{1,2})\)(?=\s)/g;
10535
10540
  var markersIn = (src, texts) => {
10536
- const out = [];
10537
- for (const t of texts) {
10538
- const slice = src.slice(t.start, t.end);
10539
- const opening = (s) => [...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s[(m.index ?? 0) - 1]));
10540
- const inSlice = opening(slice);
10541
- if (inSlice.length !== opening(t.value).length)
10541
+ const opening = (s) => [...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s.charAt((m.index ?? 0) - 1)));
10542
+ const markers2 = [];
10543
+ for (const text4 of texts) {
10544
+ const inSlice = opening(src.slice(text4.start, text4.end));
10545
+ if (inSlice.length !== opening(text4.value).length)
10542
10546
  return null;
10543
- for (const m of inSlice) {
10544
- const start = t.start + (m.index ?? 0);
10545
- out.push({ range: [start, start + m[0].length], label: m[1] });
10547
+ for (const match of inSlice) {
10548
+ const start = text4.start + (match.index ?? 0);
10549
+ markers2.push({ range: [start, start + match[0].length], label: match[1] ?? "" });
10546
10550
  }
10547
10551
  }
10548
- return out;
10552
+ return markers2;
10549
10553
  };
10550
- var fix6 = (doc) => {
10554
+ var familyIn = (prose) => ENUM_FAMILIES.find(([opener2, second]) => prose.includes(opener2) && prose.includes(second));
10555
+ var check7 = ({ doc, file }) => proseParagraphs(doc).flatMap((view) => {
10556
+ const family = familyIn(view.prose);
10557
+ if (!family)
10558
+ return [];
10559
+ const [opener2, second] = family;
10560
+ return [
10561
+ {
10562
+ file,
10563
+ line: view.line,
10564
+ rule: RULE.INLINE_ENUM,
10565
+ message: `enumeration ${opener2} ${second} ... inlined in prose; promote to a bullet list`
10566
+ }
10567
+ ];
10568
+ });
10569
+ var fix6 = ({ doc }) => {
10551
10570
  const { src } = doc;
10552
10571
  const edits = [];
10553
10572
  for (const view of doc.paragraphs) {
10554
10573
  const markers2 = markersIn(src, view.directTexts);
10555
10574
  if (markers2 === null || markers2.length < 2)
10556
10575
  continue;
10557
- const sequence = sequenceFor(markers2[0].label);
10558
- if (sequence === null || markers2.some((m, i) => m.label !== sequence[i]))
10559
- continue;
10560
- const first = markers2[0];
10561
- const last = markers2[markers2.length - 1];
10562
- const between = view.directTexts.map((t) => src.slice(Math.max(t.start, first.range[1]), Math.min(t.end, last.range[0])));
10563
- if (between.some((s) => /[.!?]\s/.test(s)))
10576
+ const sequence = sequenceFor(first(markers2).label);
10577
+ if (sequence === null || markers2.some((marker, i) => marker.label !== sequence[i]))
10564
10578
  continue;
10565
- const span = sentenceSpan(src, view, first.range[0], last.range[1]);
10566
- const announced = /:\s*$/.test(src.slice(span[0], first.range[0]));
10567
- const separated = /[,;]\s*$/.test(src.slice(markers2[0].range[1], markers2[1].range[0]));
10568
- if (markers2.length === 2 && !announced && !separated)
10579
+ const opener2 = first(markers2);
10580
+ const closer = last(markers2);
10581
+ const between = view.directTexts.map((text4) => src.slice(Math.max(text4.start, opener2.range[1]), Math.min(text4.end, closer.range[0])));
10582
+ if (between.some((slice) => /[.!?]\s/.test(slice)))
10569
10583
  continue;
10570
- const items = markers2.map((m, i) => {
10571
- const end = i + 1 < markers2.length ? markers2[i + 1].range[0] : span[1];
10572
- return { text: cleanItem(src.slice(m.range[1], end), { last: i + 1 === markers2.length }) };
10573
- });
10584
+ const span = sentenceSpan(src, view, opener2.range[0], closer.range[1]);
10585
+ if (markers2.length === 2) {
10586
+ const announced = /:\s*$/.test(src.slice(span[0], opener2.range[0]));
10587
+ const separated = /[,;]\s*$/.test(src.slice(opener2.range[1], closer.range[0]));
10588
+ if (!announced && !separated)
10589
+ continue;
10590
+ }
10574
10591
  const edit = promote({
10575
10592
  rule: RULE.INLINE_ENUM,
10576
10593
  src,
10577
10594
  view,
10578
10595
  span,
10579
- leadIn: cleanLeadIn(src.slice(span[0], first.range[0])),
10580
- items,
10596
+ leadIn: cleanLeadIn(src.slice(span[0], opener2.range[0])),
10597
+ items: itemsBetween(src, markers2.map((marker) => marker.range), span[1]).map((text4) => ({ text: text4 })),
10581
10598
  ordered: true
10582
10599
  });
10583
10600
  if (edit)
@@ -10585,77 +10602,65 @@ var fix6 = (doc) => {
10585
10602
  }
10586
10603
  return edits;
10587
10604
  };
10588
- var check3 = ({ doc, file }) => {
10589
- const findings = [];
10590
- for (const p of doc.paragraphs) {
10591
- if (p.inTable)
10592
- continue;
10593
- for (const [first, second] of ENUM_FAMILIES) {
10594
- if (p.prose.includes(first) && p.prose.includes(second)) {
10595
- findings.push({
10596
- file,
10597
- line: p.line,
10598
- rule: RULE.INLINE_ENUM,
10599
- message: `enumeration ${first} ${second} ... inlined in prose; promote to a bullet list`
10600
- });
10601
- break;
10602
- }
10603
- }
10604
- }
10605
- return findings;
10606
- };
10607
10605
  var pg007 = {
10608
10606
  id: RULE.INLINE_ENUM,
10609
10607
  category: "list",
10610
10608
  summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
10611
- check: check3,
10609
+ check: check7,
10612
10610
  fix: fix6
10613
10611
  };
10614
10612
 
10615
10613
  // src/rules/pg008-labelled-run.ts
10616
- var LABELLED_SEGMENT = /^(?:and\s+|or\s+)?(?:[A-Za-z][\w\s-]{0,24}:\s*)?(?:CODE|[A-Z][A-Za-z]*\d[\w/-]*)\s+\S/;
10614
+ var JOINER = "(?:and\\s+|or\\s+)?";
10615
+ var INTRO = "(?:[A-Za-z][\\w\\s-]{0,24}:\\s*)?";
10616
+ var LABEL = "(?:CODE|[A-Z][A-Za-z]*\\d[\\w/-]*)";
10617
+ var CONTENT = "\\s+\\S";
10618
+ var LABELLED_SEGMENT = new RegExp(`^${JOINER}${INTRO}${LABEL}${CONTENT}`);
10619
+ var INTRO_ONLY = /^[A-Za-z][\w\s-]{0,24}$/;
10617
10620
  var asProse = (src, view, range) => {
10618
10621
  let out = "";
10619
10622
  let at = range[0];
10620
- for (const [a, b] of view.codeRanges) {
10621
- if (b <= range[0] || a >= range[1])
10623
+ for (const [start, end] of view.codeRanges) {
10624
+ if (end <= range[0] || start >= range[1])
10622
10625
  continue;
10623
- out += `${src.slice(at, a)}CODE`;
10624
- at = b;
10626
+ out += `${src.slice(at, start)}CODE`;
10627
+ at = end;
10625
10628
  }
10626
10629
  return collapse(out + src.slice(at, range[1])).trim();
10627
10630
  };
10628
- var fix7 = (doc) => {
10631
+ var labelledSegments = (sentence) => sentence.split(/,\s+/).filter((segment) => LABELLED_SEGMENT.test(segment)).length;
10632
+ var check8 = ({ doc, file }) => proseSentences(doc).map(({ view, sentence }) => ({ view, labelled: labelledSegments(sentence) })).filter(({ labelled }) => labelled >= 3).map(({ view, labelled }) => ({
10633
+ file,
10634
+ line: view.line,
10635
+ rule: RULE.LABELLED_RUN,
10636
+ message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`
10637
+ }));
10638
+ var fix7 = ({ doc }) => {
10629
10639
  const { src } = doc;
10630
10640
  const edits = [];
10631
10641
  for (const view of doc.paragraphs) {
10632
10642
  const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
10633
- const colons = matchesInAll(src, view.directTexts, /:/);
10643
+ const colons = colonsIn(src, view);
10634
10644
  if (commas === null || colons === null)
10635
10645
  continue;
10636
10646
  for (const span of sentenceSpans(src, view)) {
10637
- const inside = commas.filter((r) => within(r, span));
10647
+ const inside = commas.filter((comma) => within(comma, span));
10638
10648
  if (inside.length < 2)
10639
10649
  continue;
10640
- const bounds = inside.map((c, i) => [i === 0 ? span[0] : inside[i - 1][1], c[0]]);
10641
- bounds.push([inside[inside.length - 1][1], span[1]]);
10642
- if (!bounds.every((b) => LABELLED_SEGMENT.test(asProse(src, view, b))))
10650
+ const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
10651
+ if (!segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment))))
10643
10652
  continue;
10644
- const firstBound = bounds[0];
10645
- const colon = colons.find((c) => within(c, firstBound));
10646
- const intro = colon && /^[A-Za-z][\w\s-]{0,24}$/.test(collapse(src.slice(span[0], colon[0])).trim());
10647
- if (intro && colon)
10648
- firstBound[0] = colon[1];
10649
- const items = bounds.map((b, i) => ({
10650
- text: cleanItem(src.slice(b[0], b[1]), { last: i + 1 === bounds.length })
10651
- }));
10653
+ const opening = first(segments);
10654
+ const colon = colons.find((c) => within(c, opening));
10655
+ const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
10656
+ const items = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
10652
10657
  const edit = promote({
10653
10658
  rule: RULE.LABELLED_RUN,
10654
10659
  src,
10655
10660
  view,
10656
10661
  span,
10657
10662
  leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
10658
- items,
10663
+ items: itemsOf(src, items).map((text4) => ({ text: text4 })),
10659
10664
  ordered: false
10660
10665
  });
10661
10666
  if (edit)
@@ -10665,66 +10670,53 @@ var fix7 = (doc) => {
10665
10670
  }
10666
10671
  return edits;
10667
10672
  };
10668
- var check4 = ({ doc, file }) => {
10669
- const findings = [];
10670
- for (const p of doc.paragraphs) {
10671
- if (p.inTable)
10672
- continue;
10673
- for (const s of sentences(p.prose)) {
10674
- const labelled = s.split(/,\s+/).filter((seg) => LABELLED_SEGMENT.test(seg)).length;
10675
- if (labelled >= 3) {
10676
- findings.push({
10677
- file,
10678
- line: p.line,
10679
- rule: RULE.LABELLED_RUN,
10680
- message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`
10681
- });
10682
- }
10683
- }
10684
- }
10685
- return findings;
10686
- };
10687
10673
  var pg008 = {
10688
10674
  id: RULE.LABELLED_RUN,
10689
10675
  category: "list",
10690
10676
  summary: "comma-joined labelled run (3+ labelled segments)",
10691
- check: check4,
10677
+ check: check8,
10692
10678
  fix: fix7
10693
10679
  };
10694
10680
 
10695
10681
  // src/rules/pg009-stacked-runs.ts
10696
- var fix8 = (doc) => {
10682
+ var linesOf = (view) => {
10683
+ const lines = [];
10684
+ let at = view.startOffset;
10685
+ for (const line of view.raw.split(`
10686
+ `)) {
10687
+ lines.push([at, at + line.length]);
10688
+ at += line.length + 1;
10689
+ }
10690
+ return lines;
10691
+ };
10692
+ var parentOf = (src, line, separators, colons) => {
10693
+ const inside = separators.filter((separator) => within(separator, line));
10694
+ if (inside.length === 0)
10695
+ return null;
10696
+ const colon = colonsBefore(colons, line, first(inside)[0])[0];
10697
+ if (!colon)
10698
+ return null;
10699
+ return {
10700
+ text: collapse(src.slice(line[0], colon[0])).trim(),
10701
+ children: itemsBetween(src, [colon, ...inside], line[1])
10702
+ };
10703
+ };
10704
+ var check9 = ({ file, runs }) => runs.filter(isStacked).map((run) => ({
10705
+ file,
10706
+ line: run.line,
10707
+ rule: RULE.STACKED_RUNS,
10708
+ message: `interpunct runs stacked on ${run.lines} lines; promote to a nested list`
10709
+ }));
10710
+ var fix8 = ({ doc, runs }) => {
10697
10711
  const { src } = doc;
10698
10712
  const edits = [];
10699
- const runs = interpunctRuns(doc).filter((r) => r.lines >= 2);
10700
- for (const view of doc.paragraphs) {
10701
- if (!runs.some((r) => r.range[0] === view.startOffset))
10702
- continue;
10703
- const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
10704
- const colons = matchesInAll(src, view.directTexts, /:/);
10705
- if (seps === null || colons === null || seps.length !== (view.prose.match(/·/g) ?? []).length || seps.some(([s]) => escaped(src, s)))
10713
+ for (const view of paragraphsOf(doc, runs.filter(isStacked))) {
10714
+ const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
10715
+ const colons = colonsIn(src, view);
10716
+ if (separators === null || colons === null)
10706
10717
  continue;
10707
- const lines = [];
10708
- let at = view.startOffset;
10709
- for (const line of view.raw.split(`
10710
- `)) {
10711
- lines.push([at, at + line.length]);
10712
- at += line.length + 1;
10713
- }
10714
- const parents = [];
10715
- for (const line of lines) {
10716
- const lineSeps = seps.filter((s) => within(s, line));
10717
- const colon = colons.find((c) => within(c, line));
10718
- if (lineSeps.length === 0 || !colon || colon[1] > lineSeps[0][0])
10719
- break;
10720
- const label = collapse(src.slice(line[0], colon[0])).trim();
10721
- const cuts = [colon, ...lineSeps];
10722
- const children = cuts.map((cut, i) => {
10723
- const end = i + 1 < cuts.length ? cuts[i + 1][0] : line[1];
10724
- return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
10725
- });
10726
- parents.push({ text: label, children });
10727
- }
10718
+ const lines = linesOf(view);
10719
+ const parents = lines.map((line) => parentOf(src, line, separators, colons)).filter((parent) => parent !== null);
10728
10720
  if (parents.length !== lines.length)
10729
10721
  continue;
10730
10722
  const edit = promote({
@@ -10745,23 +10737,19 @@ var pg009 = {
10745
10737
  id: RULE.STACKED_RUNS,
10746
10738
  category: "list",
10747
10739
  summary: "interpunct runs stacked on several lines (nested list)",
10748
- check: ({ file, runs }) => runs.filter((r) => r.lines >= 2).map((r) => ({
10749
- file,
10750
- line: r.line,
10751
- rule: RULE.STACKED_RUNS,
10752
- message: `interpunct runs stacked on ${r.lines} lines; promote to a nested list`
10753
- })),
10740
+ check: check9,
10754
10741
  fix: fix8
10755
10742
  };
10756
10743
 
10757
10744
  // src/rules/index.ts
10758
10745
  var RULES = [pg001, pg002, pg003, pg004, pg005, pg006, pg007, pg008, pg009];
10746
+ var ruleContext = (doc) => ({ doc, runs: interpunctRuns(doc) });
10759
10747
  var FIX_ORDER = [pg009, pg007, pg008, pg006, pg003, pg005, pg004, pg001];
10760
10748
 
10761
10749
  // src/check.ts
10762
10750
  var DEFAULT_MAX_WORDS = 25;
10763
10751
  var checkModel = (doc, file, maxWords) => {
10764
- const ctx = { doc, file, maxWords, runs: interpunctRuns(doc) };
10752
+ const ctx = { ...ruleContext(doc), file, maxWords };
10765
10753
  const findings = RULES.flatMap((rule) => rule.check(ctx));
10766
10754
  for (const fence2 of doc.fences) {
10767
10755
  for (const f of checkModel(buildDocModel(fence2.value), file, maxWords)) {
@@ -10893,8 +10881,8 @@ var fixMarkdownReport = (src) => {
10893
10881
  const refusedKeys = new Set;
10894
10882
  let current = src;
10895
10883
  for (let pass = 0;pass < MAX_PASSES; pass++) {
10896
- const doc = buildDocModel(current);
10897
- const before = { src: current, tree: doc.tree };
10884
+ const ctx = ruleContext(buildDocModel(current));
10885
+ const before = { src: current, tree: ctx.doc.tree };
10898
10886
  const keyOf = (e) => `${e.rule}\x00${current.slice(e.start, e.end)}\x00${e.text}`;
10899
10887
  const attempt = (edits) => {
10900
10888
  const next = splice2(current, edits);
@@ -10908,7 +10896,7 @@ var fixMarkdownReport = (src) => {
10908
10896
  };
10909
10897
  let progressed = false;
10910
10898
  for (const rule of FIX_ORDER) {
10911
- const edits = (rule.fix?.(doc) ?? []).filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
10899
+ const edits = (rule.fix?.(ctx) ?? []).filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
10912
10900
  if (edits.length === 0)
10913
10901
  continue;
10914
10902
  const batchable = edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");