punctilio 5.0.10 → 5.1.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/dashes.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { cachedRegExp, FOLDED_WORD_CHARS, isWordLike, LATIN_LETTER_RE, LATIN_LETTERS, MAX_BOUNDARY_SEPARATORS, NBSP_CHARS, SPACE_CHAR_RE, UNICODE_SYMBOLS } from "./constants.js"
2
- import { boundaryCountAt, exceedsSingleBoundary, makeProsePass, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } from "./prose-view.js"
3
- import { namedGroups } from "./utils.js"
2
+ import { boundaryCountAt, exceedsSingleBoundary, firstInteriorBoundary, lastBoundaryBefore, makeProsePass, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } from "./prose-view.js"
4
3
 
5
4
  export const DASH_STYLES = ["american", "british", "none"] as const
6
5
  export type DashStyle = (typeof DASH_STYLES)[number]
@@ -28,7 +27,7 @@ const months: readonly string[] = [
28
27
  "July", "August", "September", "October", "November", "December",
29
28
  // Abbreviated "May" omitted — the full name is already 3 letters
30
29
  "Jan", "Feb", "Mar", "Apr", "Jun",
31
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
30
+ "Jul", "Aug", "Sept", "Sep", "Oct", "Nov", "Dec",
32
31
  ]
33
32
 
34
33
  const monthPattern = months.join("|")
@@ -191,12 +190,46 @@ function convertPositiveRanges(view: ProseView): void {
191
190
  const text = view.text
192
191
  let i = 0
193
192
  while (i < text.length) {
193
+ // A structural match can only begin at a digit, `p`, or `(` (the area-code
194
+ // and start-run readers reject every other first character), so other
195
+ // offsets skip the match attempt entirely.
196
+ if (!RANGE_START_CHAR_RE.test(text[i])) { i++; continue }
194
197
  const span = matchPositiveRangeAt(view, i)
195
- i = span === null ? i + 1 : Math.max(span, i + 1)
198
+ i = span === null ? failedRangeStartSkip(view, i) : Math.max(span, i + 1)
196
199
  }
197
200
  view.commit()
198
201
  }
199
202
 
203
+ const RANGE_START_CHAR_RE = /[\dp(]/
204
+
205
+ /**
206
+ * Advance past a failed candidate at `i`. Advancing one character at a time
207
+ * re-reads the digit run at every offset — O(n²) on inputs like "1.1.1.…" or
208
+ * "111…1-x", a denial-of-service vector — so the scan jumps wherever a
209
+ * failure at `i` provably rules out interior starts:
210
+ *
211
+ * - A start-dependent failure (`\b`, not-after-dash — checked first in
212
+ * matchRangeBody, before any run read) says nothing about interior starts;
213
+ * advance one character. The gate order makes each such attempt cheap.
214
+ * - Otherwise the failure came after the gates, and everything there depends
215
+ * only on the run end, not the run start: every interior digit start reads
216
+ * to the same boundary-free run end and fails the same wall, `.`/`,` cannot
217
+ * start a run, and no dash-at-run-end fallback (dash extent, boundary
218
+ * counts, end run, tail resolution) sees a different picture. The single
219
+ * escape is the phone-area-code reading, which restarts a fresh match past
220
+ * a dash at the run end and therefore only exists at `runEnd - 3` — so
221
+ * jump straight there (or to the run end when no dash follows).
222
+ */
223
+ function failedRangeStartSkip(view: ProseView, i: number): number {
224
+ const text = view.text
225
+ if (!/\d/.test(text[i])) return i + 1
226
+ if (!wordBoundaryStartOk(view, i) || !notAfterDashOk(view, i)) return i + 1
227
+ let runEnd = i + 1
228
+ while (/[\d.,]/.test(text[runEnd] ?? "") && !view.hasBoundary(runEnd)) runEnd++
229
+ if (text[runEnd] !== "-") return runEnd
230
+ return Math.max(i + 1, runEnd - 3)
231
+ }
232
+
200
233
  /**
201
234
  * Attempts the `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
202
235
  * \b` structural match beginning at clean offset `i`, converting the dash when
@@ -243,6 +276,12 @@ function readPhoneAreaCode(view: ProseView, i: number): number | null {
243
276
  */
244
277
  function matchRangeBody(view: ProseView, startStart: number): number | null {
245
278
  const text = view.text
279
+ // Start-dependent gates first: they need no run read, and their position in
280
+ // the conjunction lets the scanner classify a failure — everything after
281
+ // them depends only on where the boundary-free start run ends, not on where
282
+ // it begins (see failedRangeStartSkip).
283
+ if (!wordBoundaryStartOk(view, startStart)) return null
284
+ if (!notAfterDashOk(view, startStart)) return null
246
285
  // rangeStart = `(?:p\.?|[currency])?\d[\d.,]*`, anchored at a `\b`.
247
286
  const start = readStartRun(view, startStart)
248
287
  if (start === null) return null
@@ -258,15 +297,20 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
258
297
 
259
298
  // `readStartRun` already stops at the first interior boundary, so the start
260
299
  // span is boundary-free and its clean digits are the whole run.
300
+ //
301
+ // INVARIANT (load-bearing for failedRangeStartSkip): past the gates above,
302
+ // `startDigits` is the ONLY value derived from where the run begins, and it
303
+ // feeds only the conversion decision, never the null/non-null return. The
304
+ // skip's jump is sound precisely because every interior start shares this
305
+ // function's post-gate outcome; threading the start position into the
306
+ // end/tail logic would silently break it.
261
307
  const startDigits = text.slice(startStart, startSpanEnd)
262
- if (!wordBoundaryStartOk(view, startStart)) return null
263
- if (!notAfterDashOk(view, startStart)) return null
264
-
265
308
  const end = readEndRun(view, dashEnd)
266
309
  if (end === null) return null
267
310
  // The greedy end `[\d.,]*` and greedy `following*` backtrack together; the
268
- // structural match commits at the longest end whose tail completes.
269
- for (let len = end.length; len >= end.minLen; len--) {
311
+ // structural match commits at the longest end whose tail completes. The
312
+ // mandatory `\d` caps the backtrack at one digit.
313
+ for (let len = end.length; len >= 1; len--) {
270
314
  const endPos = end.firstDigitStart + len
271
315
  const tail = resolvePositiveTail(view, endPos)
272
316
  if (tail === null) continue
@@ -313,8 +357,6 @@ interface EndRun {
313
357
  firstDigitStart: number
314
358
  /** Length of the `[\d.,]` run from `firstDigitStart`, boundary-truncated. */
315
359
  length: number
316
- /** Minimum end length: 1 digit (`\d` is required). */
317
- minLen: number
318
360
  }
319
361
 
320
362
  /** The `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
@@ -330,7 +372,7 @@ function readEndRun(view: ProseView, pos: number): EndRun | null {
330
372
  const firstDigitStart = i
331
373
  i++
332
374
  while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
333
- return { firstDigitStart, length: i - firstDigitStart, minLen: 1 }
375
+ return { firstDigitStart, length: i - firstDigitStart }
334
376
  }
335
377
 
336
378
  interface ResolvedTail {
@@ -608,6 +650,22 @@ export function enDashDateRange(input: string | ProseView, options: DashOptions
608
650
  return overInput(input, (view) => enDashDateRangeOverView(view, options.dashStyle ?? "american"))
609
651
  }
610
652
 
653
+ interface DateRangeGroups {
654
+ startMonth: string
655
+ startYear?: string
656
+ preSpace: string
657
+ postSpace: string
658
+ endMonth: string
659
+ endYear?: string
660
+ }
661
+
662
+ /** Clean offsets of the `[preSpace]-[postSpace]` span inside a date-range match. */
663
+ function dateRangeDashSpan(match: RegExpExecArray): { dashStart: number; dashEnd: number } {
664
+ const groups = match.groups as unknown as DateRangeGroups
665
+ const dashStart = match.index + groups.startMonth.length + (groups.startYear?.length ?? 0)
666
+ return { dashStart, dashEnd: dashStart + groups.preSpace.length + 1 + groups.postSpace.length }
667
+ }
668
+
611
669
  function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
612
670
  if (dashStyle === "none") return
613
671
  const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
@@ -630,37 +688,17 @@ function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
630
688
  // a word char that blocks the match ("March 2025x3"); re-check the end
631
689
  // boundary fold-stably.
632
690
  if (!wordBoundaryEndOk(v, match.index + match[0].length)) return null
633
- const groups = namedGroups<{
634
- startMonth: string
635
- startYear?: string
636
- preSpace: string
637
- postSpace: string
638
- endMonth: string
639
- endYear?: string
640
- }>(matchArgs(match))
641
691
  // Replace `[preSpace]-[postSpace]` (the dash and its spaces) with the
642
692
  // styled en-dash, leaving the months and any boundaries around them.
643
- const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
644
- const dashSpan = groups.preSpace.length + 1 + groups.postSpace.length
645
- const dashStart = match.index + startLen
646
- v.replace(dashStart, dashStart + dashSpan, `${pre}${EN_DASH}${post}`)
693
+ const { dashStart, dashEnd } = dateRangeDashSpan(match)
694
+ v.replace(dashStart, dashEnd, `${pre}${EN_DASH}${post}`)
647
695
  return null
648
696
  },
649
697
  {
650
698
  allowBoundaries: (match, v) => {
651
- const groups = namedGroups<{
652
- startMonth: string
653
- startYear?: string
654
- preSpace: string
655
- postSpace: string
656
- endMonth: string
657
- endYear?: string
658
- }>(matchArgs(match))
659
- const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
660
699
  // One boundary is tolerated after the start year and one before the end
661
700
  // month, bracketing the dash and spaces.
662
- const dashStart = match.index + startLen
663
- const dashEnd = dashStart + groups.preSpace.length + 1 + groups.postSpace.length
701
+ const { dashStart, dashEnd } = dateRangeDashSpan(match)
664
702
  const tolerated = new Map<number, number>()
665
703
  tolerated.set(dashStart, 1)
666
704
  tolerated.set(dashEnd, 1)
@@ -732,19 +770,14 @@ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefi
732
770
  // leading digit and the space; it tolerates at most one boundary.
733
771
  if (exceedsSingleBoundary(view, match.index)) return false
734
772
  const numStart = match.index + prefixLen
735
- for (const b of view.boundaries) {
736
- if (b > match.index && b < numStart) return false
737
- }
773
+ if (firstInteriorBoundary(view, match.index, numStart) >= 0) return false
738
774
  // The slot before `num` tolerates at most one boundary at the num head.
739
775
  if (exceedsSingleBoundary(view, numStart)) return false
740
776
  // `num` stops at the first interior boundary; the clean run up to it must
741
777
  // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
742
778
  const numEnd = match.index + match[0].length
743
- let truncatedEnd = numEnd
744
- for (const b of view.boundaries) {
745
- if (b > numStart && b < numEnd) { truncatedEnd = b; break }
746
- if (b >= numEnd) break
747
- }
779
+ const interior = firstInteriorBoundary(view, numStart, numEnd)
780
+ const truncatedEnd = interior >= 0 ? interior : numEnd
748
781
  return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd))
749
782
  }
750
783
 
@@ -783,11 +816,8 @@ function minusDirectNegative(view: ProseView): void {
783
816
  // ("-{b}5"), and one that strips the mandatory `\d+` ("-.{b}1") breaks the
784
817
  // match, but one past a valid number ("-5{b}5") leaves it intact.
785
818
  const numEnd = start + match[0].length
786
- let numTruncEnd = numEnd
787
- for (const b of v.boundaries) {
788
- if (b > start && b < numEnd) { numTruncEnd = b; break }
789
- if (b >= numEnd) break
790
- }
819
+ const interior = firstInteriorBoundary(v, start, numEnd)
820
+ const numTruncEnd = interior >= 0 ? interior : numEnd
791
821
  if (!NUM_BODY_RE.test(v.text.slice(start + 1, numTruncEnd))) return null
792
822
  // A boundary directly before the hyphen routes to Pattern 2b; otherwise
793
823
  // Pattern 2 examines the clean preceding char. (The two never overlap: a
@@ -864,7 +894,7 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
864
894
  cachedRegExp(pattern, "g"),
865
895
  (match, v) => {
866
896
  const text = v.text
867
- const groups = namedGroups<{ dashStart: string }>(matchArgs(match))
897
+ const groups = match.groups as { dashStart: string }
868
898
  const firstDash = match.index + match[0].length - groups.dashStart.length
869
899
  // The greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
870
900
  // holds: line/text start, a non-whitespace clean char, or a boundary. A
@@ -873,10 +903,10 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
873
903
  // consumed span is also a floor: leading spaces it already consumed are not
874
904
  // re-used.
875
905
  let matchStart = Math.max(match.index, consumedThrough)
876
- for (const b of view.boundaries) {
877
- if (b > matchStart && b <= firstDash - 1) matchStart = b
878
- else if (b > firstDash) break
879
- }
906
+ // The greatest boundary strictly left of the dash restarts the match just
907
+ // after it (binary search, not a per-match scan of every boundary).
908
+ const boundaryBeforeDash = lastBoundaryBefore(view, firstDash)
909
+ if (boundaryBeforeDash > matchStart) matchStart = boundaryBeforeDash
880
910
  /* istanbul ignore if -- defensive: the previous match's trailing `(?=\S|$)`
881
911
  stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
882
912
  if (matchStart >= firstDash + 1) return null
@@ -1217,7 +1247,7 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
1217
1247
  // One boundary is tolerated between the line start and the em-dash; two or
1218
1248
  // more exceed that slot and block the match.
1219
1249
  if (exceedsSingleBoundary(v, match.index)) return null
1220
- const groups = namedGroups<{ after: string }>(matchArgs(match))
1250
+ const groups = match.groups as { after: string }
1221
1251
  return `${EM_DASH} ${groups.after}`
1222
1252
  },
1223
1253
  )
@@ -1246,8 +1276,10 @@ const { WORD_JOINER } = UNICODE_SYMBOLS
1246
1276
  /**
1247
1277
  * Insert a word joiner (U+2060) immediately before each unspaced em or en
1248
1278
  * dash that has preceding content, preventing the dash from appearing as the
1249
- * first glyph on a wrapped line. Both dashes share Unicode line-break class
1250
- * B2, which permits a break before them. Does not insert before line-leading
1279
+ * first glyph on a wrapped line. The em dash (U+2014) is Unicode line-break
1280
+ * class B2, which permits a break on either side; the en dash (U+2013) is
1281
+ * class BA (break after). The word joiner glues either dash to the preceding
1282
+ * word so it cannot start a wrapped line. Does not insert before line-leading
1251
1283
  * dashes (preceded by whitespace or start-of-string) or dashes already glued
1252
1284
  * with a word joiner — including British-style spaced en dashes ("word – word"),
1253
1285
  * which are already protected by the surrounding spaces.
@@ -1262,8 +1294,3 @@ export const dashWordJoiner = makeProsePass((view) => {
1262
1294
  const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
1263
1295
  replaceAllInView(view, re, (match) => `${WORD_JOINER}${match[0]}`)
1264
1296
  })
1265
-
1266
- /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
1267
- function matchArgs(match: RegExpExecArray): unknown[] {
1268
- return [...match, match.index, match.input, match.groups]
1269
- }
package/src/nbsp.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cachedRegExp, LATIN_LETTER_RE, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
2
- import { boundaryCountAt, exceedsSingleBoundary, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
2
+ import { boundaryCountAt, exceedsSingleBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
3
3
 
4
4
  const {
5
5
  NBSP,
@@ -105,10 +105,7 @@ const COPYRIGHT_SYMBOLS = `[${COPYRIGHT}${REGISTERED}${TRADEMARK}]`
105
105
  * allowed slot offsets.
106
106
  */
107
107
  function boundariesOnlyAtSlots(view: ProseView, start: number, end: number, slots: number[]): boolean {
108
- for (const boundary of view.boundaries) {
109
- if (boundary <= start || boundary >= end) continue
110
- if (!slots.includes(boundary)) return false
111
- }
108
+ if (!interiorBoundariesWithin(view, start, end, slots)) return false
112
109
  for (const slot of slots) {
113
110
  if (exceedsSingleBoundary(view, slot)) return false
114
111
  }
package/src/prose-view.ts CHANGED
@@ -67,19 +67,8 @@ class ProseViewImpl implements ProseView {
67
67
 
68
68
  hasBoundary(offset: number): boolean {
69
69
  const boundaries = this.cachedBoundaries
70
- let low = 0
71
- let high = boundaries.length - 1
72
- while (low <= high) {
73
- const mid = (low + high) >>> 1
74
- const value = boundaries[mid]
75
- if (value === offset) return true
76
- if (value < offset) {
77
- low = mid + 1
78
- } else {
79
- high = mid - 1
80
- }
81
- }
82
- return false
70
+ const first = lowerBound(boundaries, offset)
71
+ return first < boundaries.length && boundaries[first] === offset
83
72
  }
84
73
 
85
74
  replace(start: number, end: number, text: string, opts?: { bind?: "left" | "right" }): void {
@@ -217,6 +206,28 @@ export function buildProseView(nodes: ProseNode[]): ProseView {
217
206
  return new ProseViewImpl(nodes)
218
207
  }
219
208
 
209
+ /**
210
+ * Partitions `items` into contiguous groups, breaking before every index in
211
+ * `breakBefore` (indices 1..n-1). With no break indices the whole list is one
212
+ * group; an empty list yields no groups. Used to isolate opaque-delimited
213
+ * segments so a pass never sees text as adjacent across removed content.
214
+ */
215
+ export function splitAtIndices<T>(items: readonly T[], breakBefore: ReadonlySet<number>): T[][] {
216
+ if (breakBefore.size === 0) {
217
+ return items.length > 0 ? [items.slice()] : []
218
+ }
219
+ const groups: T[][] = []
220
+ let start = 0
221
+ for (let i = 1; i < items.length; i++) {
222
+ if (breakBefore.has(i)) {
223
+ groups.push(items.slice(start, i))
224
+ start = i
225
+ }
226
+ }
227
+ groups.push(items.slice(start))
228
+ return groups
229
+ }
230
+
220
231
  export interface ReplaceAllOptions {
221
232
  /** Opt-in: allow a match that contains an interior node boundary. Default: such matches are skipped. */
222
233
  allowBoundaries?: (match: RegExpExecArray, view: ProseView) => boolean
@@ -224,22 +235,68 @@ export interface ReplaceAllOptions {
224
235
  bind?: "left" | "right"
225
236
  }
226
237
 
238
+ /**
239
+ * Index of the first element in sorted `values` that is >= `target`. The
240
+ * boundary helpers below run per candidate offset in the pass scanners, so a
241
+ * linear scan here would make inline-element-heavy views O(text × nodes).
242
+ */
243
+ function lowerBound(values: readonly number[], target: number): number {
244
+ let low = 0
245
+ let high = values.length
246
+ while (low < high) {
247
+ const mid = (low + high) >>> 1
248
+ if (values[mid] < target) low = mid + 1
249
+ else high = mid
250
+ }
251
+ return low
252
+ }
253
+
227
254
  /** True iff some interior boundary falls strictly inside the match span. */
228
255
  function matchContainsBoundary(match: RegExpExecArray, view: ProseView): boolean {
229
- const matchStart = match.index
230
- const matchEnd = match.index + match[0].length
231
- for (const boundary of view.boundaries) {
232
- if (boundary > matchStart && boundary < matchEnd) return true
256
+ return firstInteriorBoundary(view, match.index, match.index + match[0].length) >= 0
257
+ }
258
+
259
+ /** Smallest node boundary strictly inside (start, end), or -1 when none. */
260
+ export function firstInteriorBoundary(view: ProseView, start: number, end: number): number {
261
+ const boundaries = view.boundaries
262
+ const first = lowerBound(boundaries, start + 1)
263
+ return first < boundaries.length && boundaries[first] < end ? boundaries[first] : -1
264
+ }
265
+
266
+ /** Largest node boundary strictly less than `offset`, or -1 when none. O(log n). */
267
+ export function lastBoundaryBefore(view: ProseView, offset: number): number {
268
+ const boundaries = view.boundaries
269
+ const first = lowerBound(boundaries, offset)
270
+ return first > 0 ? boundaries[first - 1] : -1
271
+ }
272
+
273
+ /**
274
+ * True when every node boundary strictly inside (start, end) is one of
275
+ * `allowedSlots`. Seeks to the span with binary search, so it scans only the
276
+ * boundaries in range rather than the whole array on every call — the linear
277
+ * alternative is O(matches × nodes) on inline-element-heavy views.
278
+ */
279
+ export function interiorBoundariesWithin(
280
+ view: ProseView,
281
+ start: number,
282
+ end: number,
283
+ allowedSlots: readonly number[],
284
+ ): boolean {
285
+ const boundaries = view.boundaries
286
+ for (let i = lowerBound(boundaries, start + 1); i < boundaries.length && boundaries[i] < end; i++) {
287
+ if (!allowedSlots.includes(boundaries[i])) return false
233
288
  }
234
- return false
289
+ return true
235
290
  }
236
291
 
237
292
  /** Count of node boundaries that fall at exactly `offset` (empty nodes stack). */
238
293
  export function boundaryCountAt(view: ProseView, offset: number): number {
294
+ const boundaries = view.boundaries
295
+ let i = lowerBound(boundaries, offset)
239
296
  let count = 0
240
- for (const boundary of view.boundaries) {
241
- if (boundary === offset) count++
242
- else if (boundary > offset) break
297
+ while (i < boundaries.length && boundaries[i] === offset) {
298
+ count++
299
+ i++
243
300
  }
244
301
  return count
245
302
  }