Skip to content

Commit 0acfcdf

Browse files
committed
fix(one-sentence-per-line): numbering labels, emphasis fix split, sentence module
- Ignore compact numeric labels (e.g. D1.) only when leading; periods after labels in normal prose still split sentences. - When fixing whole-line bold/italic/strike wrappers, emit one wrapped line per sentence. - Extract boundary scanning to one-sentence-per-line-sentence.js for ESLint limits; document both files for reuse. - Add md_test_files fixtures plus JS and Python tests.
1 parent a9b5104 commit 0acfcdf

8 files changed

Lines changed: 529 additions & 313 deletions

‎markdownlint-rules/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@ heading-numbering:
655655

656656
### `one-sentence-per-line`
657657

658-
**File:** `one-sentence-per-line.js`
658+
**Files:** `one-sentence-per-line.js` (rule entry point), `one-sentence-per-line-sentence.js` (sentence-boundary helpers; copy both when reusing this rule).
659659

660660
**Description:** Enforce one sentence per line in prose and list content.
661661
The rule runs two checks:
@@ -700,7 +700,10 @@ one-sentence-per-line:
700700
When set, replaces the built-in set; when omitted, the rule uses a default set (e.g., i.e., etc., Dr., Mr., U.S., ...).
701701
- **`checkCrossLine`** (boolean, default `false`): When `true`, also flag single sentences that are hard-wrapped across multiple non-blank lines inside the same prose block.
702702
A trailing colon (`:`) still closes a line; a trailing two-space or `<br>` does **not** opt out.
703-
- **`maxBlockLinesForFix`** (number, default `8`): Maximum number of physical lines a single cross-line fix may collapse (open line + continuation chain). Wraps whose fix would join more than this many lines are still reported, but `fixInfo` is omitted so `--fix` leaves them alone. The guard is scoped per wrap, so dense bullet lists with many short 2-line wraps all remain fixable. Set higher at your own risk.
703+
- **`maxBlockLinesForFix`** (number, default `8`): Maximum number of physical lines a single cross-line fix may collapse (open line + continuation chain).
704+
Wraps whose fix would join more than this many lines are still reported, but `fixInfo` is omitted so `--fix` leaves them alone.
705+
The guard is scoped per wrap, so dense bullet lists with many short 2-line wraps all remain fixable.
706+
Set higher at your own risk.
704707
- **`maxFileLinesForCrossLine`** (number, default `1500`): Skip the cross-line check on files longer than this; the per-line check is unaffected.
705708
Set to `0` or negative to disable the guard.
706709
- **`excludePathPatterns`** (array of globs, optional): Skip this rule for matching file paths.
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
"use strict";
2+
3+
const { stripInlineCode } = require("./utils.js");
4+
5+
/** Abbreviations that do not end a sentence (no trailing period in value). */
6+
const DEFAULT_ABBREVIATIONS = new Set([
7+
"e.g", "i.e", "etc", "vs", "Dr", "Mr", "Mrs", "Ms", "Prof", "Sr", "Jr",
8+
"U.S", "U.K", "a.m", "p.m", "No", "al", "fig",
9+
]);
10+
11+
function updateBracketDepth(ch, inBracket, inParen) {
12+
if (ch === "[" && inParen === 0) return { inBracket: inBracket + 1, inParen };
13+
if (ch === "]" && inParen === 0) return { inBracket: inBracket - 1, inParen };
14+
if (ch === "(" && inBracket === 0) return { inBracket, inParen: inParen + 1 };
15+
if (ch === ")" && inBracket === 0) return { inBracket, inParen: inParen - 1 };
16+
return null;
17+
}
18+
19+
/** Toggle inDoubleQuote on unescaped "; return new state or null to leave unchanged. */
20+
function updateDoubleQuote(ch, inDoubleQuote, i, scanned) {
21+
if (ch !== '"') return null;
22+
if (i > 0 && scanned[i - 1] === "\\") return null;
23+
return !inDoubleQuote;
24+
}
25+
26+
function isSentenceEndChar(ch) {
27+
return ch === "." || ch === "?" || ch === "!";
28+
}
29+
30+
/** True when the period at i is part of an ellipsis (two or more consecutive dots). */
31+
function isPartOfEllipsis(scanned, i) {
32+
if (scanned[i] !== ".") return false;
33+
return (i > 0 && scanned[i - 1] === ".") || (i + 1 < scanned.length && scanned[i + 1] === ".");
34+
}
35+
36+
/** Index of the last character of a run of dots starting at i (i is a dot). */
37+
function endOfEllipsisRun(scanned, i) {
38+
let end = i;
39+
while (end < scanned.length && scanned[end] === ".") end++;
40+
return end;
41+
}
42+
43+
/** True when the content after the ellipsis (after optional space) starts with an uppercase letter. */
44+
function capitalAfterEllipsis(scanned, lastDotIndex) {
45+
const spaceStart = skipQuotesThenSpace(scanned, lastDotIndex);
46+
if (spaceStart === null) return false;
47+
const after = spaceStartAndNextWordPos(scanned, spaceStart);
48+
if (!after) return false;
49+
return /[A-Z]/.test(scanned[after.j]);
50+
}
51+
52+
function skipQuotesAndSpaces(scanned, j) {
53+
let pos = j;
54+
while (pos < scanned.length && (scanned[pos] === "*" || scanned[pos] === "_")) {
55+
pos++;
56+
}
57+
while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) {
58+
pos++;
59+
}
60+
const spaceStart = pos;
61+
while (pos < scanned.length && scanned[pos] === " ") {
62+
pos++;
63+
}
64+
return { spaceStart, nextPos: pos };
65+
}
66+
67+
function getWordBefore(scanned, endIndex) {
68+
let k = endIndex - 1;
69+
while (k >= 0 && /[a-zA-Z.]/.test(scanned[k])) {
70+
k--;
71+
}
72+
return scanned.slice(k + 1, endIndex);
73+
}
74+
75+
function getNextToken(scanned, startIndex) {
76+
let m = startIndex;
77+
while (m < scanned.length && /[a-zA-Z.]/.test(scanned[m])) {
78+
m++;
79+
}
80+
return scanned.slice(startIndex, m).replace(/\.+$/, "");
81+
}
82+
83+
/** Token before position i that may be a number (digits, optional .digits). */
84+
function getNumberTokenBefore(scanned, i) {
85+
let k = i - 1;
86+
while (k >= 0 && /[\d.]/.test(scanned[k])) {
87+
k--;
88+
}
89+
return scanned.slice(k + 1, i);
90+
}
91+
92+
/** Compact token bounds ending at i (exclusive): [start, end=i). */
93+
function getCompactTokenBoundsBefore(scanned, i) {
94+
let k = i - 1;
95+
while (k >= 0 && /[A-Za-z0-9_.-]/.test(scanned[k])) {
96+
k--;
97+
}
98+
return { start: k + 1, end: i };
99+
}
100+
101+
/** Previous non-space character index before `pos`, or -1. */
102+
function previousNonSpaceIndex(scanned, pos) {
103+
let k = pos - 1;
104+
while (k >= 0 && scanned[k] === " ") k--;
105+
return k;
106+
}
107+
108+
/**
109+
* Compact number labels are only treated as non-boundaries when they look like
110+
* leading labels (line/sentence start, optional emphasis/quote/open-bracket).
111+
*/
112+
function looksLikeLeadingLabel(scanned, tokenStart) {
113+
const prev = previousNonSpaceIndex(scanned, tokenStart);
114+
if (prev < 0) return true;
115+
return /[*_"'([{]/.test(scanned[prev]);
116+
}
117+
118+
/** True when period at i follows a compact label token containing digits (e.g. D1., 2.1.). */
119+
function isCompactNumberLabel(scanned, i) {
120+
if (scanned[i] !== ".") return false;
121+
const bounds = getCompactTokenBoundsBefore(scanned, i);
122+
const token = scanned.slice(bounds.start, bounds.end);
123+
if (token.length === 0 || !/\d/.test(token)) return false;
124+
if (token.includes("..") || token.startsWith(".") || token.endsWith(".")) return false;
125+
if (!/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(token)) return false;
126+
return looksLikeLeadingLabel(scanned, bounds.start);
127+
}
128+
129+
/** True when period at i is part of a numbering label (e.g. "1. Overview", "1.1 Scope") inside quotes. */
130+
function isNumberingLabel(scanned, i) {
131+
if (scanned[i] !== "." || i <= 0 || !/\d/.test(scanned[i - 1])) return false;
132+
const spaceStart = skipQuotesThenSpace(scanned, i);
133+
if (spaceStart === null) return false;
134+
const after = spaceStartAndNextWordPos(scanned, spaceStart);
135+
if (!after) return false;
136+
const nextToken = getNextToken(scanned, after.j);
137+
const numToken = getNumberTokenBefore(scanned, i);
138+
return /^\d+(\.\d+)*$/.test(numToken) && /^[A-Z]/.test(nextToken);
139+
}
140+
141+
function isAbbreviation(scanned, i, j, abbreviations) {
142+
if (scanned[i] === "." && i > 0 && /\d/.test(scanned[i - 1])) {
143+
const nextToken = getNextToken(scanned, j);
144+
if (/^\d/.test(nextToken)) return true;
145+
return false;
146+
}
147+
const word = getWordBefore(scanned, i);
148+
if (word.length === 0) return false;
149+
const nextToken = getNextToken(scanned, j);
150+
const wordWithNext = word + "." + nextToken;
151+
const wordLower = word.toLowerCase();
152+
const wordWithNextLower = wordWithNext.toLowerCase();
153+
return abbreviations.has(word) || abbreviations.has(wordLower)
154+
|| abbreviations.has(wordWithNext) || abbreviations.has(wordWithNextLower);
155+
}
156+
157+
/** Skip optional emphasis (* _), then optional quotes after position i; return position of first space, or null. Period only counts as sentence end when followed by space (not e.g. CYNAI.PROJCT or file.name). */
158+
function skipQuotesThenSpace(scanned, i) {
159+
let pos = i + 1;
160+
while (pos < scanned.length && (scanned[pos] === "*" || scanned[pos] === "_")) {
161+
pos++;
162+
}
163+
while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) {
164+
pos++;
165+
}
166+
if (pos >= scanned.length || scanned[pos] !== " ") return null;
167+
return pos;
168+
}
169+
170+
/** From start of spaces, skip spaces and return { spaceStart, j } or null if no word char follows. */
171+
function spaceStartAndNextWordPos(scanned, spaceStart) {
172+
let pos = spaceStart;
173+
while (pos < scanned.length && scanned[pos] === " ") {
174+
pos++;
175+
}
176+
const j = nextWordPosAfterEmphasis(scanned, pos);
177+
if (j === null) return null;
178+
return { spaceStart, j };
179+
}
180+
181+
/** @returns {{ run: string, end: number }} */
182+
function consumeBacktickRun(scanned, k) {
183+
const runStart = k;
184+
while (k < scanned.length && scanned[k] === "`") {
185+
k++;
186+
}
187+
return { run: scanned.slice(runStart, k), end: k };
188+
}
189+
190+
/**
191+
* Advance past inline code if `pos` is on opening backticks; return index after closing fence.
192+
* Matches utils.stripInlineCode fence pairing.
193+
*/
194+
function skipPastInlineCode(scanned, pos) {
195+
if (pos >= scanned.length || scanned[pos] !== "`") return pos;
196+
const open = consumeBacktickRun(scanned, pos);
197+
const fence = open.run;
198+
let k = open.end;
199+
let inCode = true;
200+
while (k < scanned.length && inCode) {
201+
if (scanned[k] === "`") {
202+
const close = consumeBacktickRun(scanned, k);
203+
if (close.run === fence) inCode = false;
204+
k = close.end;
205+
} else {
206+
k++;
207+
}
208+
}
209+
return k;
210+
}
211+
212+
function skipInlineSpaces(scanned, pos) {
213+
while (pos < scanned.length && scanned[pos] === " ") {
214+
pos++;
215+
}
216+
return pos;
217+
}
218+
219+
function skipEmphasisAndQuoteChars(scanned, pos) {
220+
while (pos < scanned.length && (scanned[pos] === "*" || scanned[pos] === "_")) {
221+
pos++;
222+
}
223+
while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) {
224+
pos++;
225+
}
226+
return pos;
227+
}
228+
229+
/**
230+
* After position start, skip emphasis, quotes, and inline code spans; return position of first
231+
* [a-zA-Z0-9] starting the next word, or null.
232+
*/
233+
function nextWordPosAfterEmphasis(scanned, start) {
234+
let pos = start;
235+
while (pos < scanned.length) {
236+
pos = skipInlineSpaces(scanned, pos);
237+
if (pos >= scanned.length || scanned[pos] === "\n") return null;
238+
pos = skipEmphasisAndQuoteChars(scanned, pos);
239+
if (pos >= scanned.length) return null;
240+
if (scanned[pos] === "`") {
241+
pos = skipPastInlineCode(scanned, pos);
242+
continue;
243+
}
244+
if (scanned[pos] === "\n" || !/[a-zA-Z0-9]/.test(scanned[pos])) return null;
245+
return pos;
246+
}
247+
return null;
248+
}
249+
250+
/**
251+
* Find index of the first sentence boundary (space before second sentence) in content.
252+
* Uses stripInlineCode; skips link/paren context; avoids decimals and abbreviations.
253+
* @param {string} content - Prose content (no list marker)
254+
* @param {{ abbreviations?: Set<string> }} opts - Optional abbreviations set
255+
* @returns {number|null} Index of space before second sentence, or null if at most one sentence
256+
*/
257+
function trySentenceBoundary(scanned, i, abbreviations) {
258+
const spaceStart = skipQuotesThenSpace(scanned, i);
259+
if (spaceStart === null) return null;
260+
const after = spaceStartAndNextWordPos(scanned, spaceStart);
261+
if (!after) return null;
262+
if (scanned[i] === "." && isCompactNumberLabel(scanned, i)) return null;
263+
if (isAbbreviation(scanned, i, after.j, abbreviations)) return null;
264+
return i > 0 ? after.spaceStart : null;
265+
}
266+
267+
function getFirstSentenceBoundary(content, opts) {
268+
const all = getAllSentenceBoundaries(content, opts);
269+
return all.length > 0 ? all[0] : null;
270+
}
271+
272+
/** True when position i should be skipped for sentence-boundary scanning (inside brackets/parens or quoted numbering). */
273+
function shouldSkipForSentenceBoundary(ctx) {
274+
const { ch, scanned, inBracket, inParen, inDoubleQuote } = ctx;
275+
if (inBracket > 0 || inParen > 0) return true;
276+
if (inDoubleQuote && ch === "." && isNumberingLabel(scanned, ctx.i)) return true;
277+
return false;
278+
}
279+
280+
/**
281+
* Process a potential sentence-end at i; return next index and optional boundary.
282+
* @returns {{ nextI: number, boundary: number|null }}
283+
*/
284+
function processSentenceEnd(scanned, i, abbreviations) {
285+
const ch = scanned[i];
286+
if (!isSentenceEndChar(ch)) return { nextI: i, boundary: null };
287+
if (ch === "." && isPartOfEllipsis(scanned, i)) {
288+
const end = endOfEllipsisRun(scanned, i);
289+
const lastDotIndex = end - 1;
290+
if (capitalAfterEllipsis(scanned, lastDotIndex)) {
291+
const boundary = trySentenceBoundary(scanned, lastDotIndex, abbreviations);
292+
if (boundary !== null) {
293+
const { nextPos: j } = skipQuotesAndSpaces(scanned, lastDotIndex + 1);
294+
return { nextI: j - 1, boundary };
295+
}
296+
}
297+
return { nextI: end - 1, boundary: null };
298+
}
299+
const boundary = trySentenceBoundary(scanned, i, abbreviations);
300+
if (boundary !== null) {
301+
const { nextPos: j } = skipQuotesAndSpaces(scanned, i + 1);
302+
return { nextI: j - 1, boundary };
303+
}
304+
return { nextI: i, boundary: null };
305+
}
306+
307+
/**
308+
* Find all sentence boundary indices (space before each sentence after the first).
309+
* @param {string} content - Prose content (no list marker)
310+
* @param {{ abbreviations?: Set<string> }} opts - Optional abbreviations set
311+
* @returns {number[]} Indices of space before second, third, ... sentence (empty if at most one sentence)
312+
*/
313+
function getAllSentenceBoundaries(content, opts) {
314+
const abbreviations = opts?.abbreviations ?? DEFAULT_ABBREVIATIONS;
315+
const scanned = stripInlineCode(content);
316+
const boundaries = [];
317+
let i = 0;
318+
let inBracket = 0;
319+
let inParen = 0;
320+
let inDoubleQuote = false;
321+
322+
while (i < scanned.length) {
323+
const ch = scanned[i];
324+
const dq = updateDoubleQuote(ch, inDoubleQuote, i, scanned);
325+
if (dq !== null) {
326+
inDoubleQuote = dq;
327+
i++;
328+
continue;
329+
}
330+
const depth = updateBracketDepth(ch, inBracket, inParen);
331+
if (depth !== null) {
332+
inBracket = depth.inBracket;
333+
inParen = depth.inParen;
334+
i++;
335+
continue;
336+
}
337+
if (shouldSkipForSentenceBoundary({ ch, i, scanned, inBracket, inParen, inDoubleQuote })) {
338+
i++;
339+
continue;
340+
}
341+
const { nextI, boundary } = processSentenceEnd(scanned, i, abbreviations);
342+
if (boundary !== null) boundaries.push(boundary);
343+
i = nextI + 1;
344+
}
345+
return boundaries;
346+
}
347+
348+
module.exports = {
349+
DEFAULT_ABBREVIATIONS,
350+
getFirstSentenceBoundary,
351+
getAllSentenceBoundaries,
352+
getWordBefore,
353+
};

0 commit comments

Comments
 (0)