parseman 0.58.2 → 0.59.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.
Files changed (90) hide show
  1. package/dist/analysis/diagnostics.js +344 -130
  2. package/dist/analysis/diagnostics.js.map +3 -3
  3. package/dist/cli/index.js +346 -131
  4. package/dist/cli/index.js.map +3 -3
  5. package/dist/combinators/dispatch.d.ts.map +1 -1
  6. package/dist/combinators/field-trivia.d.ts.map +1 -1
  7. package/dist/combinators/first-set.d.ts.map +1 -1
  8. package/dist/combinators/keywords.d.ts.map +1 -1
  9. package/dist/combinators/literal.d.ts.map +1 -1
  10. package/dist/combinators/node.d.ts.map +1 -1
  11. package/dist/combinators/regex.d.ts.map +1 -1
  12. package/dist/combinators/scanTo.d.ts.map +1 -1
  13. package/dist/combinators/token.d.ts.map +1 -1
  14. package/dist/compiler/linker.d.ts.map +1 -1
  15. package/dist/compiler/token-capability.js +28 -4
  16. package/dist/compiler/token-capability.js.map +2 -2
  17. package/dist/compiler/token-scanner.d.ts.map +1 -1
  18. package/dist/cst/capture-buffer.d.ts.map +1 -1
  19. package/dist/cst/plain.js +69 -0
  20. package/dist/cst/plain.js.map +6 -0
  21. package/dist/index.js +350 -138
  22. package/dist/index.js.map +3 -3
  23. package/dist/language-service/index.js +29 -5
  24. package/dist/language-service/index.js.map +3 -3
  25. package/dist/lead-ranges.d.ts +26 -0
  26. package/dist/lead-ranges.d.ts.map +1 -0
  27. package/dist/line-index.d.ts +13 -0
  28. package/dist/line-index.d.ts.map +1 -1
  29. package/dist/plugin/entry-supercompiler.d.ts.map +1 -1
  30. package/dist/plugin/index.js +352 -140
  31. package/dist/plugin/index.js.map +3 -3
  32. package/dist/recovery/scan.d.ts.map +1 -1
  33. package/dist/regex/classes.d.ts +5 -3
  34. package/dist/regex/classes.d.ts.map +1 -1
  35. package/dist/regex/first-set.d.ts +7 -1
  36. package/dist/regex/first-set.d.ts.map +1 -1
  37. package/dist/run/index.js +14 -4
  38. package/dist/run/index.js.map +3 -3
  39. package/dist/spec/index.js +18 -1
  40. package/dist/spec/index.js.map +2 -2
  41. package/dist/table/assemble.d.ts.map +1 -1
  42. package/dist/table/compile-rule-map.d.ts.map +1 -1
  43. package/dist/table/emit-assembly.d.ts +9 -2
  44. package/dist/table/emit-assembly.d.ts.map +1 -1
  45. package/dist/table/emit.d.ts +6 -0
  46. package/dist/table/emit.d.ts.map +1 -1
  47. package/dist/table/encode-baseline.d.ts.map +1 -1
  48. package/dist/table/index.js +330 -129
  49. package/dist/table/index.js.map +3 -3
  50. package/dist/table/program.d.ts.map +1 -1
  51. package/dist/table/run-support.d.ts.map +1 -1
  52. package/dist/table/scan-shapes.d.ts +1 -1
  53. package/dist/table/scan-shapes.d.ts.map +1 -1
  54. package/dist/table/site-labels.d.ts.map +1 -1
  55. package/dist/table/trivia-gap.d.ts +11 -1
  56. package/dist/table/trivia-gap.d.ts.map +1 -1
  57. package/dist/version.d.ts +1 -1
  58. package/package.json +2 -1
  59. package/src/combinators/choice.ts +4 -0
  60. package/src/combinators/dispatch.ts +2 -1
  61. package/src/combinators/field-trivia.ts +6 -5
  62. package/src/combinators/first-set.ts +13 -4
  63. package/src/combinators/keywords.ts +3 -2
  64. package/src/combinators/literal.ts +5 -4
  65. package/src/combinators/node.ts +3 -1
  66. package/src/combinators/regex.ts +33 -13
  67. package/src/combinators/scanTo.ts +2 -1
  68. package/src/combinators/token.ts +3 -6
  69. package/src/compiler/linker.ts +4 -2
  70. package/src/compiler/token-scanner.ts +4 -1
  71. package/src/cst/capture-buffer.ts +13 -5
  72. package/src/cst/plain.d.ts +35 -0
  73. package/src/cst/plain.js +74 -0
  74. package/src/lead-ranges.ts +53 -0
  75. package/src/line-index.ts +37 -0
  76. package/src/plugin/entry-supercompiler.ts +3 -2
  77. package/src/recovery/scan.ts +4 -4
  78. package/src/regex/classes.ts +22 -7
  79. package/src/regex/first-set.ts +67 -12
  80. package/src/table/assemble.ts +75 -38
  81. package/src/table/compile-rule-map.ts +5 -3
  82. package/src/table/emit-assembly.ts +69 -32
  83. package/src/table/emit.ts +9 -1
  84. package/src/table/encode-baseline.ts +2 -1
  85. package/src/table/program.ts +2 -1
  86. package/src/table/run-support.ts +4 -7
  87. package/src/table/scan-shapes.ts +5 -2
  88. package/src/table/site-labels.ts +2 -1
  89. package/src/table/trivia-gap.ts +19 -0
  90. package/src/version.ts +1 -1
@@ -0,0 +1,74 @@
1
+ // Constructors for the objects a parse keeps until it ends. Plain JavaScript on
2
+ // purpose: a function constructor whose `prototype` is `Object.prototype` is the only
3
+ // spelling that builds a plain object with a `new` call, and TypeScript cannot declare
4
+ // one in a `.ts` file without a cast. `plain.d.ts` types them. Why constructors at all,
5
+ // and why this is ONE module shared by every bundle: see `plain.d.ts`.
6
+
7
+ export function PlainSpan(start, end) {
8
+ this.start = start
9
+ this.end = end
10
+ }
11
+
12
+ export function PlainLeaf(value, span) {
13
+ this._tag = 'leaf'
14
+ this.value = value
15
+ this.span = span
16
+ }
17
+
18
+ export function PlainLineSpan(start, end, startLine, startColumn, endLine, endColumn) {
19
+ this.start = start
20
+ this.end = end
21
+ this.startLine = startLine
22
+ this.startColumn = startColumn
23
+ this.endLine = endLine
24
+ this.endColumn = endColumn
25
+ }
26
+
27
+ export function PlainNode(type, span, state, children) {
28
+ this._tag = 'node'
29
+ this.type = type
30
+ this.span = span
31
+ this.state = state
32
+ this.children = children
33
+ }
34
+
35
+ export function PlainTaggedNode(type, tags, span, state, children) {
36
+ this._tag = 'node'
37
+ this.type = type
38
+ this.tags = tags
39
+ this.span = span
40
+ this.state = state
41
+ this.children = children
42
+ }
43
+
44
+ export function PlainField(name, value, span) {
45
+ this.name = name
46
+ this.value = value
47
+ this.span = span
48
+ }
49
+
50
+ export function PlainTriviaField(name, value, span, leading, trailing) {
51
+ this.name = name
52
+ this.value = value
53
+ this.span = span
54
+ this.leading = leading
55
+ this.trailing = trailing
56
+ }
57
+
58
+ // Every field assigned, so the three buffer kinds share one shape. `rawLen` is always a
59
+ // count: a field that held `undefined` in most buffers and a number in some would
60
+ // generalize and deoptimize the pushes that read it.
61
+ export function PlainBuf(rawOnly, noRaw, rawLen) {
62
+ this.rawOnly = rawOnly
63
+ this.noRaw = noRaw
64
+ this.rawLen = rawLen
65
+ this.single = undefined
66
+ this.ch = undefined
67
+ this.rawSingle = undefined
68
+ this.raw = undefined
69
+ this.tl = undefined
70
+ }
71
+
72
+ for (const f of [PlainSpan, PlainLineSpan, PlainLeaf, PlainNode, PlainTaggedNode, PlainField, PlainTriviaField, PlainBuf]) {
73
+ f.prototype = Object.prototype
74
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * How a first set's ranges answer for an input's leading code point (`lead()`).
3
+ * A leaf module: the minimal run entry reaches it through recovery sentinels.
4
+ */
5
+ import type { CharRange } from './types.ts'
6
+
7
+ /**
8
+ * Whether `ranges` admit an input led by the code point `code`: in a range, or,
9
+ * for an astral lead, through its high surrogate. Outside Unicode mode a regex
10
+ * reads code units, so a set holding a high surrogate starts every astral code
11
+ * point that surrogate begins. Exact for such a set; for a `u` set holding a
12
+ * lone high surrogate it only over-approximates.
13
+ *
14
+ * The interpreter's two hot lead tests (`startsFirstSet`, choice `inFirstSet`)
15
+ * repeat this body inline: called, it costs them about 6% of the instructions per
16
+ * parse, measured on the CSS example.
17
+ */
18
+ export function rangesHaveLead(ranges: readonly CharRange[], code: number): boolean {
19
+ for (const r of ranges) if (code >= r.lo && code <= r.hi) return true
20
+ if (code <= 0xffff) return false
21
+ const high = 0xd800 + ((code - 0x10000) >> 10)
22
+ for (const r of ranges) if (high >= r.lo && high <= r.hi) return true
23
+ return false
24
+ }
25
+
26
+ /** `ranges` with the astral code points `rangesHaveLead` admits through their
27
+ * high surrogates, for a test that reads a lead by range alone. */
28
+ export function leadRanges(ranges: readonly CharRange[]): readonly CharRange[] {
29
+ let out: CharRange[] | undefined
30
+ for (const r of ranges) {
31
+ const lo = Math.max(r.lo, 0xd800), hi = Math.min(r.hi, 0xdbff)
32
+ if (lo <= hi) (out ??= [...ranges]).push({ lo: 0x10000 + ((lo - 0xd800) << 10), hi: 0x10000 + ((hi - 0xd800) << 10) + 0x3ff })
33
+ }
34
+ return out ?? ranges
35
+ }
36
+
37
+ /** The inverse of `leadRanges`: drop the astral code points that `ranges`' own high
38
+ * surrogates already admit, so a set read through `leadRanges` (an intersection)
39
+ * keeps the code-unit form a table encodes narrowly. `leadRanges` of the result
40
+ * equals `leadRanges` of the input. */
41
+ export function implicitLeadsDropped(ranges: readonly CharRange[]): CharRange[] {
42
+ const implied = leadRanges(ranges.filter(r => r.lo <= 0xffff)).filter(r => r.lo > 0xffff)
43
+ const out: CharRange[] = []
44
+ for (const r of ranges) {
45
+ let pieces: CharRange[] = [{ lo: r.lo, hi: r.hi }]
46
+ if (r.hi > 0xffff) for (const c of implied) {
47
+ pieces = pieces.flatMap(q => q.hi < c.lo || q.lo > c.hi ? [q]
48
+ : [...(q.lo < c.lo ? [{ lo: q.lo, hi: c.lo - 1 }] : []), ...(q.hi > c.hi ? [{ lo: c.hi + 1, hi: q.hi }] : [])])
49
+ }
50
+ out.push(...pieces)
51
+ }
52
+ return out
53
+ }
package/src/line-index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Span } from './types.ts'
2
+ import { PlainLineSpan, PlainSpan } from './cst/plain.js'
2
3
 
3
4
  /**
4
5
  * Precomputed index of newline positions for O(log n) offset->line/col lookup.
@@ -98,6 +99,42 @@ export function annotateSpan(span: Span, index: LineIndex): Span {
98
99
  }
99
100
  }
100
101
 
102
+ const SPAN_KEYS = ['start', 'end', 'startLine', 'startColumn', 'endLine', 'endColumn'] as const
103
+
104
+ /**
105
+ * A copy of a span, exactly as given: the same keys in the same order. The two shapes
106
+ * parseman builds itself, `{ start, end }` and the six-key line span, are rebuilt by their
107
+ * constructors (`src/cst/plain.d.ts`); any other shape a caller passes (an extra key, a
108
+ * different order, a partial line span) is spread, as before.
109
+ */
110
+ export function copySpan(span: Span): Span {
111
+ // `for…in` walks the keys in order without allocating a key list.
112
+ let n = 0
113
+ for (const key in span) {
114
+ if (key !== SPAN_KEYS[n]) return { ...span }
115
+ n++
116
+ }
117
+ if (n === 2) return new PlainSpan(span.start, span.end)
118
+ if (n === 6 && span.startLine !== undefined && span.startColumn !== undefined && span.endLine !== undefined && span.endColumn !== undefined) {
119
+ return new PlainLineSpan(span.start, span.end, span.startLine, span.startColumn, span.endLine, span.endColumn)
120
+ }
121
+ return { ...span }
122
+ }
123
+
124
+ /**
125
+ * `annotateSpanFromLineContext` for a span parseman built itself (`{ start, end }` only),
126
+ * returning a constructor-built line span (`src/cst/plain.d.ts`). The public form keeps
127
+ * any extra keys a caller's span carries, so it spreads.
128
+ */
129
+ export function lineSpanFromContext(span: Span, ctx: LineTrackingContext): Span {
130
+ if (!ctx.trackLines) return span
131
+ const index = ctx._lineIndex ?? (ctx._lineStarts ? { lineStarts: ctx._lineStarts } : undefined)
132
+ if (!index) return span
133
+ const s = offsetToLineCol(index, span.start)
134
+ const e = offsetToLineCol(index, span.end)
135
+ return new PlainLineSpan(span.start, span.end, s.line, s.col, e.line, e.col)
136
+ }
137
+
101
138
  export function annotateSpanFromLineContext(
102
139
  span: Span,
103
140
  ctx: LineTrackingContext,
@@ -12,6 +12,7 @@ import { OP_ATTEMPT, OP_NODE, OP_NODE_TRACK, OP_REPV, OP_SCAN } from '../table/o
12
12
  import { emitAssemblySource, type EmitResult } from '../table/emit-assembly.ts'
13
13
  import { defaultAssemblyCfgs } from '../table/emit.ts'
14
14
  import { assemblyExtraIps, resolveTable, type TableProgram } from '../table/program.ts'
15
+ import { FACTORY_ARITY_GUARD } from '../table/emit.ts'
15
16
 
16
17
  type Ast = { type: string; start?: number; end?: number; [key: string]: unknown }
17
18
 
@@ -271,8 +272,8 @@ export function supercompileRuleMapReplacement(
271
272
  if (typeof body.start !== 'number' || typeof body.end !== 'number') return replacement
272
273
  const start = body.start - prefix.length + 1
273
274
  const end = body.end - prefix.length - 1
274
- if (replacement.slice(start, end) !== canonical.source + '\n') return replacement
275
- const transformed = replacement.slice(0, start) + candidate.source + '\n' + replacement.slice(end)
275
+ if (replacement.slice(start, end) !== FACTORY_ARITY_GUARD + canonical.source + '\n') return replacement
276
+ const transformed = replacement.slice(0, start) + FACTORY_ARITY_GUARD + candidate.source + '\n' + replacement.slice(end)
276
277
  const checked = parseSync('parseman-entry-supercompile-replacement-result.js', prefix + transformed)
277
278
  return checked.errors.length === 0 ? transformed : replacement
278
279
  }
@@ -1,6 +1,8 @@
1
1
  import type { Combinator, FirstSet, ParseContext, ParseError, ParseResult, RecoveryHelpers } from '../types.ts'
2
2
  import { cstCaptureActive, pushCstChild } from '../cst/capture-buffer.ts'
3
+ import { rangesHaveLead } from '../lead-ranges.ts'
3
4
  import { annotateSpanFromLineContext, recordLineRangeFromContext } from '../line-index.ts'
5
+ import { PlainSpan } from '../cst/plain.js'
4
6
 
5
7
  /**
6
8
  * Recovery MECHANISM — sync-source-agnostic. These primitives implement "scan
@@ -83,9 +85,7 @@ export function firstSetSentinel(fs: FirstSet): Combinator<null> | null {
83
85
  parse(input: string, pos: number): ParseResult<null> {
84
86
  const code = pos < input.length ? input.codePointAt(pos) : undefined
85
87
  if (code !== undefined) {
86
- for (const r of ranges) {
87
- if (code >= r.lo && code <= r.hi) return { ok: true, value: null, span: { start: pos, end: pos } }
88
- }
88
+ if (rangesHaveLead(ranges, code)) return { ok: true, value: null, span: { start: pos, end: pos } }
89
89
  }
90
90
  return { ok: false, expected: [], span: { start: pos, end: pos } }
91
91
  },
@@ -113,7 +113,7 @@ export function recoverScan(
113
113
  ): { error: ParseError; end: number } {
114
114
  let scanPos = from
115
115
  while (scanPos < input.length && !probeAt(sync, input, scanPos, ctx)) scanPos++
116
- const span = { start: from, end: scanPos }
116
+ const span = new PlainSpan(from, scanPos)
117
117
  if (ctx.trackLines) recordLineRangeFromContext(ctx, input, from, scanPos)
118
118
  const error: ParseError = { _tag: 'parseError', span: ctx.trackLines ? annotateSpanFromLineContext(span, ctx) : span, expected }
119
119
  ctx._errors?.push(error)
@@ -38,12 +38,20 @@ export function shorthandRanges(ch: 'd' | 'w' | 's'): Array<[number, number]> {
38
38
  return [[48, 57], [65, 90], [97, 122], [95, 95]]
39
39
  }
40
40
 
41
- /** `\uXXXX` at `body[i]` → its code point and the index past it, or null. */
42
- export function readUnicodeEscape(body: string, i: number): { cp: number; next: number } | null {
41
+ /** `\uXXXX` at `body[i]` → its code point and the index past it, or null. Under
42
+ * `unicode`, an escaped high surrogate followed by an escaped low one is one
43
+ * code point (`\uD83D\uDE00` is U+1F600). */
44
+ export function readUnicodeEscape(body: string, i: number, unicode = false): { cp: number; next: number } | null {
43
45
  if (body[i] !== '\\' || body[i + 1] !== 'u') return null
44
46
  const hex = body.slice(i + 2, i + 6)
45
47
  if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null
46
- return { cp: Number.parseInt(hex, 16), next: i + 6 }
48
+ const cp = Number.parseInt(hex, 16)
49
+ if (unicode && cp >= 0xd800 && cp <= 0xdbff && body[i + 6] === '\\' && body[i + 7] === 'u') {
50
+ const lowHex = body.slice(i + 8, i + 12)
51
+ const low = /^[0-9a-fA-F]{4}$/.test(lowHex) ? Number.parseInt(lowHex, 16) : -1
52
+ if (low >= 0xdc00 && low <= 0xdfff) return { cp: 0x10000 + ((cp - 0xd800) << 10) + (low - 0xdc00), next: i + 12 }
53
+ }
54
+ return { cp, next: i + 6 }
47
55
  }
48
56
 
49
57
  /** Regex syntax characters that cannot appear in a LITERAL fragment unescaped. */
@@ -66,6 +74,8 @@ export function literalCodePoints(frag: string): number[] | null {
66
74
  // Any other word-character escape is a class (`\d`), an assertion (`\b`), a
67
75
  // backreference or a hex, unicode or control escape, never the character itself.
68
76
  if (!(e in CLASS_ESCAPES) && /\w/.test(e)) return null
77
+ // `\0` then a digit is a legacy octal escape, not NUL followed by a digit.
78
+ if (e === '0' && frag[i + 2] !== undefined && frag[i + 2]! >= '0' && frag[i + 2]! <= '9') return null
69
79
  out.push(e in CLASS_ESCAPES ? CLASS_ESCAPES[e]! : e.codePointAt(0)!)
70
80
  i += 2
71
81
  continue
@@ -142,14 +152,14 @@ type ClassAtom = { cp: number } | { set: Array<[number, number]> }
142
152
  * than being mis-read as a literal letter, so callers fall back to a safe
143
153
  * over-approximation instead of a wrong set.
144
154
  */
145
- export function parseClassRanges(body: string): Array<[number, number]> | null {
155
+ export function parseClassRanges(body: string, unicode = false): Array<[number, number]> | null {
146
156
  const ranges: Array<[number, number]> = []
147
157
  let i = 0
148
158
  const readAtom = (): ClassAtom | null => {
149
159
  const ch = body[i]
150
160
  if (ch === undefined) return null
151
161
  if (ch === '\\') {
152
- const uni = readUnicodeEscape(body, i)
162
+ const uni = readUnicodeEscape(body, i, unicode)
153
163
  if (uni) {
154
164
  i = uni.next
155
165
  return { cp: uni.cp }
@@ -157,14 +167,19 @@ export function parseClassRanges(body: string): Array<[number, number]> | null {
157
167
  const e = body[i + 1]
158
168
  if (e === undefined) return null
159
169
  i += 2
170
+ // A digit after `\0`, or any other digit escape, is a legacy octal escape
171
+ // (`[\012]` holds a newline), which this does not decode.
172
+ if ((e === '0' && body[i] !== undefined && body[i]! >= '0' && body[i]! <= '9') || (e >= '1' && e <= '9')) return null
160
173
  if (e in CLASS_ESCAPES) return { cp: CLASS_ESCAPES[e]! }
161
174
  if (e === 'd' || e === 'w' || e === 's') return { set: shorthandRanges(e) }
162
175
  // Any other letter escape is a class we can't safely lower (\D, \W, \S, …).
163
176
  if ((e >= 'a' && e <= 'z') || (e >= 'A' && e <= 'Z')) return null
164
177
  return { cp: e.codePointAt(0)! }
165
178
  }
166
- i += ch.length
167
- return { cp: ch.codePointAt(0)! }
179
+ // Unicode mode reads a member as one code point; otherwise as one code unit.
180
+ const cp = unicode ? body.codePointAt(i)! : body.charCodeAt(i)
181
+ i += cp > 0xffff ? 2 : 1
182
+ return { cp }
168
183
  }
169
184
  while (i < body.length) {
170
185
  const lo = readAtom()
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import type { FirstSet } from '../types.ts'
25
25
  import { any, empty, fromRange, union } from '../combinators/first-set.ts'
26
- import { shorthandRanges, parseClassRanges } from './classes.ts'
26
+ import { shorthandRanges, parseClassRanges, readUnicodeEscape } from './classes.ts'
27
27
 
28
28
  // A minimal regex AST. Each node answers two questions: what first chars can a
29
29
  // non-empty match begin with (`firstSet`), and can the node match zero chars
@@ -72,7 +72,7 @@ const BAIL = Symbol('regex-bail')
72
72
  * Closure over a single cursor `i` — recursive-descent: alt → seq → quantified
73
73
  * → atom.
74
74
  */
75
- function parseRegex(src: string): Node {
75
+ function parseRegex(src: string, unicode = false, sets = false): Node {
76
76
  let i = 0
77
77
 
78
78
  const parseAlt = (): Node => {
@@ -128,9 +128,13 @@ function parseRegex(src: string): Node {
128
128
  case '^':
129
129
  case '$': i++; return EMPTY // anchors are zero-width
130
130
  case '{': i++; return charNode(fromRange(0x7b, 0x7b), false) // a `{` with nothing to quantify is a literal brace
131
- default:
132
- i += ch.length
133
- return charNode(fromRange(ch.codePointAt(0)!, ch.codePointAt(0)!), ch === '\n')
131
+ default: {
132
+ // Unicode mode reads a literal as one code point (an astral one is two
133
+ // code units); otherwise each code unit is its own atom.
134
+ const cp = unicode ? src.codePointAt(i)! : src.charCodeAt(i)
135
+ i += cp > 0xffff ? 2 : 1
136
+ return charNode(fromRange(cp, cp), ch === '\n')
137
+ }
134
138
  }
135
139
  }
136
140
 
@@ -163,6 +167,24 @@ function parseRegex(src: string): Node {
163
167
  let k = i + 1
164
168
  const negated = src[k] === '^'
165
169
  if (negated) k++
170
+ if (sets) {
171
+ // `v` mode nests classes and adds set operations (`[[^a]--b]`, `[a&&b]`) and
172
+ // string members (`\q{…}`). Only a flat class is enumerated here; the rest
173
+ // match one character (or string) of a set this does not compute.
174
+ let depth = 1
175
+ let nested = false
176
+ while (k < src.length && depth > 0) {
177
+ const c = src[k]!
178
+ if (c === '\\') { if (src[k + 1] === 'q') nested = true; k += 2; continue }
179
+ if (c === '[') { depth++; nested = true }
180
+ else if (c === ']') { depth--; if (depth === 0) break }
181
+ else if ((c === '-' || c === '&') && src[k + 1] === c) nested = true
182
+ k++
183
+ }
184
+ if (src[k] !== ']') throw BAIL
185
+ if (nested) { i = k + 1; return charNode(any(), true) }
186
+ k = i + 1 + (negated ? 1 : 0)
187
+ }
166
188
  let body = ''
167
189
  while (k < src.length && src[k] !== ']') {
168
190
  if (src[k] === '\\') { body += src[k]! + (src[k + 1] ?? ''); k += 2 }
@@ -170,7 +192,7 @@ function parseRegex(src: string): Node {
170
192
  }
171
193
  if (src[k] !== ']') throw BAIL
172
194
  i = k + 1
173
- const ranges = parseClassRanges(body)
195
+ const ranges = parseClassRanges(body, unicode)
174
196
  // A negated class can start with (almost) any char, so the first-set widens to
175
197
  // `any`, but its newline capability is still cheap and useful to know:
176
198
  // `[^,\r\n]*` is a workhorse "field until line end" regex and must not force
@@ -184,9 +206,19 @@ function parseRegex(src: string): Node {
184
206
  const e = src[i + 1]
185
207
  if (e === undefined) throw BAIL
186
208
  // \uXXXX
187
- if (e === 'u' && /^[0-9a-fA-F]{4}$/.test(src.slice(i + 2, i + 6))) {
188
- const cp = Number.parseInt(src.slice(i + 2, i + 6), 16)
189
- i += 6
209
+ const uni = readUnicodeEscape(src, i, unicode)
210
+ if (uni !== null) {
211
+ i = uni.next
212
+ return charNode(fromRange(uni.cp, uni.cp), uni.cp === 0x0a)
213
+ }
214
+ // \u{X…}: a code point in Unicode mode. Without `u` it is a literal `u` under
215
+ // a `{…}` quantifier, which may repeat it zero times, so it widens.
216
+ if (e === 'u' && src[i + 2] === '{') {
217
+ const close = src.indexOf('}', i + 3)
218
+ const hex = close < 0 ? '' : src.slice(i + 3, close)
219
+ if (!unicode || !/^[0-9a-fA-F]{1,6}$/.test(hex)) { i += 2; return ANY }
220
+ const cp = Number.parseInt(hex, 16)
221
+ i = close + 1
190
222
  return charNode(fromRange(cp, cp), cp === 0x0a)
191
223
  }
192
224
  // \xHH
@@ -195,6 +227,17 @@ function parseRegex(src: string): Node {
195
227
  i += 4
196
228
  return charNode(fromRange(cp, cp), cp === 0x0a)
197
229
  }
230
+ // \cX: the control character X names. Anything else after `\c` is not one.
231
+ if (e === 'c') {
232
+ const x = src[i + 2]
233
+ if (x !== undefined && /^[A-Za-z]$/.test(x)) {
234
+ const cp = x.charCodeAt(0) % 32
235
+ i += 3
236
+ return charNode(fromRange(cp, cp), cp === 0x0a)
237
+ }
238
+ i += 2
239
+ return ANY
240
+ }
198
241
  i += 2
199
242
  switch (e) {
200
243
  case 'd': return charNode(rangesToSet(shorthandRanges('d')), false)
@@ -207,7 +250,12 @@ function parseRegex(src: string): Node {
207
250
  case 't': return charNode(fromRange(0x09, 0x09), false)
208
251
  case 'f': return charNode(fromRange(0x0c, 0x0c), false)
209
252
  case 'v': return charNode(fromRange(0x0b, 0x0b), false)
210
- case '0': return charNode(fromRange(0, 0), false)
253
+ // `\0` then a digit is a legacy octal escape (`\012` is a newline), not NUL.
254
+ case '0': return src[i] !== undefined && src[i]! >= '0' && src[i]! <= '9' ? ANY : charNode(fromRange(0, 0), false)
255
+ // A property escape (`\p{…}`/`\P{…}`) matches one character of a class this
256
+ // analyzer does not enumerate; `\k<name>` is a backreference, which may match empty.
257
+ case 'p': case 'P': return charNode(any(), true)
258
+ case 'k': return ANY
211
259
  default:
212
260
  // A digit escape is a backreference we don't resolve → widen.
213
261
  if (e >= '1' && e <= '9') return ANY
@@ -300,10 +348,16 @@ export function regexCanMatchEmpty(source: string): boolean {
300
348
  try { return nullable(parseRegex(source)) } catch { return true }
301
349
  }
302
350
 
303
- export function firstSetFromRegex(source: string): { firstSet: FirstSet; canMatchNewline: boolean } {
351
+ /**
352
+ * `flags` decides how atoms read: under `u`/`v` each is one code point, else one
353
+ * code unit, so a non-`u` set holds a high surrogate where an astral character
354
+ * can start a match (`rangesHaveLead` reads an astral lead through it). Case
355
+ * folding under `i` is the caller's, since only it knows what it gates.
356
+ */
357
+ export function firstSetFromRegex(source: string, flags = ''): { firstSet: FirstSet; canMatchNewline: boolean } {
304
358
  let ast: Node
305
359
  try {
306
- ast = parseRegex(source)
360
+ ast = parseRegex(source, flags.includes('u') || flags.includes('v'), flags.includes('v'))
307
361
  } catch {
308
362
  return { firstSet: any(), canMatchNewline: true }
309
363
  }
@@ -314,3 +368,4 @@ export function firstSetFromRegex(source: string): { firstSet: FirstSet; canMatc
314
368
  if (nullable(ast)) return { firstSet: any(), canMatchNewline: canConsumeNewline(ast) }
315
369
  return { firstSet: firstSet(ast), canMatchNewline: canConsumeNewline(ast) }
316
370
  }
371
+