@citisen/litearea 0.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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
@@ -0,0 +1,720 @@
1
+ // ─── scan: text in, painted tokens out ──────────────────────────────────────
2
+ //
3
+ // One left-to-right pass. At each position the rules are tried in the order the
4
+ // grammar declared them and the first one that matches wins, which is the whole
5
+ // of the precedence model: to make a rule win, put it higher.
6
+ //
7
+ // A few decisions here are what keep a hand-written grammar honest.
8
+ //
9
+ // A rule may never match the empty string. A pattern that can, such as `/\w*/`,
10
+ // is treated as if it did not match, so the scan cannot stall on one position
11
+ // forever. An infinite loop in a highlighter looks exactly like a frozen page.
12
+ //
13
+ // Consecutive tokens with the same scope are merged. Whitespace is most of a
14
+ // document, and one span per space would mean thousands of DOM nodes for a file
15
+ // nobody is looking at. Merging stops at a newline, because a token's line and
16
+ // column describe where it starts and a span across two lines would make that a
17
+ // lie.
18
+ //
19
+ // A non-nested region is found in one search rather than tested character by
20
+ // character, and its contents are painted as a single span without the rule list
21
+ // ever running inside it. That is what makes a string a string: the word
22
+ // `running` inside a quoted font name is text, not a state.
23
+
24
+ import type {
25
+ Diagnostic,
26
+ Grammar,
27
+ LineInfo,
28
+ RegionRule,
29
+ Rule,
30
+ RuleContext,
31
+ RuleMatch,
32
+ Scope,
33
+ ScopeSpec,
34
+ Token,
35
+ VocabularyContext,
36
+ WordsRule,
37
+ WordsSource,
38
+ } from './types.js'
39
+ import { listPhrase } from './format.js'
40
+ import { clamp, isWordChar, lineAt, lineIndexAt, lineStarts } from './text.js'
41
+ import { asResolvedVocabulary } from './vocabulary.js'
42
+
43
+ /** A grammar with its defaults filled in and its patterns made safe to reuse. */
44
+ export interface ResolvedGrammar<State = unknown> {
45
+ /**
46
+ * Marks this value as already resolved.
47
+ *
48
+ * Present so the two shapes can be told apart without guessing. The obvious test
49
+ * — "does it have a `fallbackScope`?" — is wrong, because a plain grammar is
50
+ * allowed to declare one, and the mistake surfaces as the engine reading `.grammar`
51
+ * off a value that has no such field and crashing at the first keystroke.
52
+ */
53
+ readonly __resolved: true
54
+ grammar: Grammar<State>
55
+ rules: readonly Rule<State>[]
56
+ /** The scope a character no rule claimed is painted with. */
57
+ fallbackScope: Scope
58
+ /** The single-character test that decides where words begin and end. */
59
+ wordChars: RegExp
60
+ }
61
+
62
+ /**
63
+ * Whether a value is a resolved grammar rather than one as written.
64
+ * @param value - the grammar, either shape.
65
+ * @returns whether it is already resolved.
66
+ */
67
+ export function isResolvedGrammar<State>(
68
+ value: Grammar<State> | ResolvedGrammar<State>,
69
+ ): value is ResolvedGrammar<State> {
70
+ return (value as { __resolved?: unknown }).__resolved === true
71
+ }
72
+
73
+ /** The whole result of one scan: the paint, what the scan noticed, and the analysis. */
74
+ export interface ScanResult<State = unknown> {
75
+ tokens: Token[]
76
+ /**
77
+ * What the lexical pass itself found: a word a vocabulary rejected, a region
78
+ * that never closed. A grammar's own `checks` and `validate` are added by
79
+ * `diagnose`, which builds on this.
80
+ */
81
+ diagnostics: Diagnostic[]
82
+ /** Whatever `Grammar.analyze` returned, or `initialState`. */
83
+ state: State
84
+ }
85
+
86
+ /** The word predicate a grammar that does not declare one gets. */
87
+ const DEFAULT_WORD_CHARS = /[\p{L}\p{N}_$]/u
88
+
89
+ /**
90
+ * A pattern that can be run at a position without ever skipping ahead.
91
+ *
92
+ * The sticky flag is added rather than demanded: a grammar author writing
93
+ * `/foo/` means "foo here", and one who forgot the `y` would otherwise get a
94
+ * pattern that quietly matches anywhere later in the document and paints the
95
+ * wrong text. Compiled patterns are cached per source pattern, so a scan does not
96
+ * recompile one per position.
97
+ */
98
+ const stickyCache = new WeakMap<RegExp, RegExp>()
99
+
100
+ /**
101
+ * A pattern that runs at a position and cannot skip ahead.
102
+ * @param pattern - the rule's pattern.
103
+ * @returns the same pattern with the sticky flag, cached.
104
+ */
105
+ function sticky(pattern: RegExp): RegExp {
106
+ const cached = stickyCache.get(pattern)
107
+ if (cached !== undefined) return cached
108
+ const flags = pattern.flags.replace(/[gy]/g, '')
109
+ const compiled = new RegExp(pattern.source, `${flags}y`)
110
+ stickyCache.set(pattern, compiled)
111
+ return compiled
112
+ }
113
+
114
+ /** Run a pattern at an exact position, or report that it did not match there. */
115
+ function execAt(pattern: RegExp, source: string, index: number): RegExpExecArray | null {
116
+ pattern.lastIndex = index
117
+ const match = pattern.exec(source)
118
+ return match !== null && match.index === index ? match : null
119
+ }
120
+
121
+ /**
122
+ * Strip the flags that would make a pattern stateful across calls.
123
+ *
124
+ * `RegExp.prototype.test` advances `lastIndex` on a global pattern, so a word
125
+ * predicate carrying one would answer correctly for the first character and then
126
+ * for every other one — a bug that looks like a typo in the grammar and cannot be
127
+ * seen by reading it.
128
+ * @param pattern - the grammar's pattern, if it declared one.
129
+ * @returns a pattern safe to call repeatedly.
130
+ */
131
+ function withoutStatefulFlags(pattern: RegExp): RegExp {
132
+ const flags = pattern.flags.replace(/[gy]/g, '')
133
+ return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags)
134
+ }
135
+
136
+ /**
137
+ * Fill in a grammar's defaults and make its patterns safe.
138
+ * @param grammar - the grammar as written.
139
+ * @returns the grammar the scanner runs.
140
+ */
141
+ export function resolveGrammar<State>(grammar: Grammar<State>): ResolvedGrammar<State> {
142
+ return {
143
+ __resolved: true,
144
+ grammar,
145
+ rules: grammar.rules,
146
+ fallbackScope: grammar.fallbackScope ?? 'text',
147
+ wordChars: withoutStatefulFlags(grammar.wordChars ?? DEFAULT_WORD_CHARS),
148
+ }
149
+ }
150
+
151
+ /** What a words source resolves to, once per scan. */
152
+ interface ResolvedWords {
153
+ list: readonly string[]
154
+ /** Folded member text, for lookup. */
155
+ set: Set<string>
156
+ /**
157
+ * Members containing a character the word predicate rejects, longest first.
158
+ * They cannot be reached by reading a word in, so they are matched literally.
159
+ */
160
+ literal: readonly string[]
161
+ caseSensitive: boolean
162
+ }
163
+
164
+ /**
165
+ * Scan a document into tokens.
166
+ * @param source - the document.
167
+ * @param grammar - the language, as written or already resolved.
168
+ * @returns the tokens, the lexical diagnostics, and the analysis.
169
+ */
170
+ export function scan<State>(
171
+ source: string,
172
+ grammar: Grammar<State> | ResolvedGrammar<State>,
173
+ ): ScanResult<State> {
174
+ const resolved: ResolvedGrammar<State> = isResolvedGrammar(grammar)
175
+ ? grammar
176
+ : resolveGrammar(grammar)
177
+ const { rules, fallbackScope, wordChars } = resolved
178
+ const declared = resolved.grammar
179
+ const sourceId = declared.id
180
+ const state: State =
181
+ declared.analyze === undefined ? (declared.initialState as State) : declared.analyze(source)
182
+ const vocabularyContext: VocabularyContext<State> = { text: source, state }
183
+
184
+ const tokens: Token[] = []
185
+ const diagnostics: Diagnostic[] = []
186
+ const starts = lineStarts(source)
187
+ const length = source.length
188
+
189
+ // ── per-scan caches ───────────────────────────────────────────────────────
190
+ // The vocabulary context is constant for a whole scan, so a vocabulary is
191
+ // resolved once rather than at every position that tests a word.
192
+ const wordsCache = new Map<WordsSource<State>, ResolvedWords>()
193
+ /**
194
+ * The line last asked for, so the scan does not re-slice per token.
195
+ *
196
+ * Only the line's BOUNDS are cached, never its column. A column depends on the
197
+ * position being asked about while the bounds do not, and caching the pair
198
+ * together is a bug with a very specific smell: the first token on a line caches
199
+ * column 0, every later token on that line reads it back, and every rule guarded
200
+ * by `firstOnLine` starts matching the whole line.
201
+ */
202
+ let cachedLineNumber = -1
203
+ let cachedLineBounds: { from: number; to: number; text: string } | undefined
204
+ /** Compiled `prevNot` character classes, per rule context. */
205
+ const prevNotCache = new WeakMap<RuleContext, RegExp>()
206
+
207
+ /** The line record for a position, with the column computed for that position. */
208
+ const lineInfoAt = (position: number): LineInfo => {
209
+ const number = lineIndexAt(starts, position)
210
+ if (number !== cachedLineNumber || cachedLineBounds === undefined) {
211
+ const info = lineAt(source, position, starts)
212
+ cachedLineBounds = { from: info.from, to: info.to, text: info.text }
213
+ cachedLineNumber = number
214
+ }
215
+ const column = clamp(position - cachedLineBounds.from, 0, cachedLineBounds.text.length)
216
+ return {
217
+ from: cachedLineBounds.from,
218
+ to: cachedLineBounds.to,
219
+ text: cachedLineBounds.text,
220
+ number,
221
+ column,
222
+ before: cachedLineBounds.text.slice(0, column),
223
+ after: cachedLineBounds.text.slice(column),
224
+ }
225
+ }
226
+
227
+ /** Resolve a words source, once per scan. */
228
+ const resolveWords = (from: WordsSource<State>): ResolvedWords => {
229
+ const cached = wordsCache.get(from)
230
+ if (cached !== undefined) return cached
231
+ const vocabulary = asResolvedVocabulary(from)
232
+ const caseSensitive = vocabulary?.caseSensitive === true
233
+ const raw =
234
+ vocabulary !== undefined
235
+ ? vocabulary.resolve(vocabularyContext)
236
+ : typeof from === 'function'
237
+ ? from(vocabularyContext)
238
+ : from
239
+ const list = Array.isArray(raw) ? raw.filter((word) => word !== '') : []
240
+ const fold = (word: string): string => (caseSensitive ? word : word.toLowerCase())
241
+ const entry: ResolvedWords = {
242
+ list,
243
+ set: new Set(list.map(fold)),
244
+ literal: list
245
+ .filter((word) => [...word].some((char) => !isWordChar(char, wordChars)))
246
+ .slice()
247
+ .sort((left, right) => right.length - left.length),
248
+ caseSensitive,
249
+ }
250
+ wordsCache.set(from, entry)
251
+ return entry
252
+ }
253
+
254
+ /** Name a match: a fixed scope, or the grammar's function. */
255
+ const scopeOf = (
256
+ spec: ScopeSpec<State> | undefined,
257
+ match: RuleMatch<State>,
258
+ fallback: Scope,
259
+ ): Scope => (spec === undefined ? fallback : typeof spec === 'function' ? spec(match) : spec)
260
+
261
+ /** Whether every predicate a rule declared holds at this position. */
262
+ const contextHolds = (
263
+ when: RuleContext | undefined,
264
+ index: number,
265
+ previousScope: Scope | undefined,
266
+ ): boolean => {
267
+ if (when === undefined) return true
268
+ const info = lineInfoAt(index)
269
+ if (when.firstOnLine === true && info.text.slice(0, info.column).trim() !== '') return false
270
+ if (when.after !== undefined) {
271
+ if (previousScope === undefined || !when.after.includes(previousScope)) return false
272
+ }
273
+ if (when.notAfter !== undefined && previousScope !== undefined) {
274
+ if (when.notAfter.includes(previousScope)) return false
275
+ }
276
+ if (when.line !== undefined && !when.line.test(info.text)) return false
277
+ if (when.minColumn !== undefined && info.column < when.minColumn) return false
278
+ if (when.maxColumn !== undefined && info.column > when.maxColumn) return false
279
+ if (when.prevNot !== undefined && index > 0) {
280
+ let pattern = prevNotCache.get(when)
281
+ if (pattern === undefined) {
282
+ pattern = new RegExp(`[${when.prevNot}]`)
283
+ prevNotCache.set(when, pattern)
284
+ }
285
+ // `prevNot` names characters that must NOT precede the match, so a hit here is
286
+ // a failure. At offset zero there is nothing in front of the match, so the
287
+ // predicate holds — a rule guarded this way is not meant to be disabled at the
288
+ // start of the document, which is what returning early there would do.
289
+ if (pattern.test(source.charAt(index - 1))) return false
290
+ }
291
+ return true
292
+ }
293
+
294
+ // ── emitting ─────────────────────────────────────────────────────────────
295
+
296
+ /** The scope of the innermost enclosing region, for a token's `region`. */
297
+ let regionScope: Scope | undefined
298
+ /** The scope of the nearest preceding non-whitespace token, maintained as we go. */
299
+ let previousScope: Scope | undefined
300
+
301
+ /**
302
+ * Append a token, merging it into the previous one when that is invisible.
303
+ * @param scope - the scope to paint.
304
+ * @param from - the first offset.
305
+ * @param to - the offset after the last.
306
+ * @param region - overrides the enclosing region scope, for a fast-path region.
307
+ */
308
+ const push = (scope: Scope, from: number, to: number, region?: Scope): void => {
309
+ if (to <= from) return
310
+ const text = source.slice(from, to)
311
+ const last = tokens[tokens.length - 1]
312
+ if (
313
+ last !== undefined &&
314
+ last.scope === scope &&
315
+ last.to === from &&
316
+ last.region === region &&
317
+ !/[\r\n]/.test(text) &&
318
+ !/[\r\n]/.test(last.text)
319
+ ) {
320
+ last.to = to
321
+ last.text = source.slice(last.from, to)
322
+ } else {
323
+ const info = lineInfoAt(from)
324
+ tokens.push({ from, to, scope, text, line: info.number, column: from - info.from, region })
325
+ }
326
+ if (text.trim() !== '') previousScope = scope
327
+ }
328
+
329
+ /** Raise a diagnostic, stamping the grammar's id on it. */
330
+ const report = (problem: {
331
+ from: number
332
+ to: number
333
+ message: string
334
+ severity?: Diagnostic['severity']
335
+ code?: string
336
+ }): void => {
337
+ diagnostics.push({
338
+ from: problem.from,
339
+ to: problem.to,
340
+ message: problem.message,
341
+ severity: problem.severity ?? 'error',
342
+ code: problem.code ?? 'lexical',
343
+ source: sourceId,
344
+ })
345
+ }
346
+
347
+ /** The `RuleMatch` a scope function is handed. */
348
+ const ruleMatch = (
349
+ text: string,
350
+ from: number,
351
+ groups: readonly (string | undefined)[],
352
+ ): RuleMatch<State> => ({ text, source, from, groups, state })
353
+
354
+ // ── reading words ────────────────────────────────────────────────────────
355
+
356
+ /** The offset after the run of word characters starting at a position. */
357
+ const readWordRun = (index: number): number => {
358
+ let to = index
359
+ while (to < length && isWordChar(source.charAt(to), wordChars)) to += 1
360
+ return to
361
+ }
362
+
363
+ /**
364
+ * The whitespace-separated segments a phrase may span.
365
+ *
366
+ * A phrase never crosses a line: a name split over two lines is two names, and
367
+ * letting a vocabulary reach across the break would paint a paragraph as one
368
+ * font.
369
+ */
370
+ const readSegments = (index: number, limit: number): Array<{ from: number; to: number }> => {
371
+ const segments: Array<{ from: number; to: number }> = []
372
+ let cursor = index
373
+ while (segments.length < limit && cursor < length) {
374
+ const to = readWordRun(cursor)
375
+ if (to === cursor) break
376
+ segments.push({ from: cursor, to })
377
+ cursor = to
378
+ const gap = /^[^\S\r\n]+/.exec(source.slice(cursor))?.[0]
379
+ if (gap === undefined) break
380
+ cursor += gap.length
381
+ }
382
+ return segments
383
+ }
384
+
385
+ /** The longest member a position spells, or undefined when it spells none. */
386
+ const matchWords = (
387
+ rule: WordsRule<State>,
388
+ words: ResolvedWords,
389
+ index: number,
390
+ ): { to: number; member: string } | undefined => {
391
+ const fold = (word: string): string => (words.caseSensitive ? word : word.toLowerCase())
392
+ const firstTo = readWordRun(index)
393
+ if (firstTo > index) {
394
+ if (rule.phrase !== undefined) {
395
+ const segments = readSegments(index, Math.max(rule.phrase.max ?? 4, 1))
396
+ // Longest first, so a catalogue holding both `IBM Plex` and `IBM Plex
397
+ // Mono` resolves the longer name.
398
+ for (let count = segments.length; count >= 1; count -= 1) {
399
+ const texts = segments.slice(0, count).map((segment) => source.slice(segment.from, segment.to))
400
+ const candidate = texts.join(' ')
401
+ if (words.set.has(fold(candidate))) {
402
+ const last = segments[count - 1]
403
+ if (last !== undefined) return { to: last.to, member: candidate }
404
+ }
405
+ }
406
+ } else {
407
+ const candidate = source.slice(index, firstTo)
408
+ if (words.set.has(fold(candidate))) return { to: firstTo, member: candidate }
409
+ }
410
+ }
411
+ // A member the word predicate cannot read, matched as written. Only tried when reading
412
+ // a word in failed, so the ordinary path pays nothing.
413
+ for (const member of words.literal) {
414
+ // A member containing whitespace is reachable through the phrase path, and matching
415
+ // it literally would ignore `phrase.max` — a cap that only applies on one of the two
416
+ // routes to the same member is not a cap.
417
+ if (rule.phrase !== undefined && /\s/.test(member)) continue
418
+ if (source.startsWith(member, index)) return { to: index + member.length, member }
419
+ }
420
+ return undefined
421
+ }
422
+
423
+ /** Whether any rule would claim a position, which is what ends an unclaimed run. */
424
+ const anyRuleClaims = (index: number, previous: Scope | undefined): boolean => {
425
+ // A nested region's closing delimiter is a claim even though no rule mentions
426
+ // it at this position. Without this the run would swallow the delimiter, the
427
+ // region would never close, and a perfectly well-formed document would be
428
+ // reported as unterminated.
429
+ const openRegion = openRegions[openRegions.length - 1]
430
+ if (openRegion !== undefined) {
431
+ const endMatch = execAt(sticky(openRegion.rule.end), source, index)
432
+ if (endMatch !== null && endMatch[0].length > 0) return true
433
+ }
434
+ for (const rule of rules) {
435
+ if (!contextHolds(rule.when, index, previous)) continue
436
+ if (rule.kind === 'region') {
437
+ const match = execAt(sticky(rule.begin), source, index)
438
+ if (match !== null && match[0].length > 0) return true
439
+ continue
440
+ }
441
+ if (rule.kind === 'words') {
442
+ if (matchWords(rule, resolveWords(rule.words), index) !== undefined) return true
443
+ // A rule that reports what it rejects also claims what it rejects:
444
+ // otherwise the word would be swallowed as plain text and the rejection
445
+ // would never be reached.
446
+ if (rule.unknown !== undefined && readWordRun(index) > index) return true
447
+ continue
448
+ }
449
+ const match = execAt(sticky(rule.pattern), source, index)
450
+ if (match !== null && match[0].length > 0) return true
451
+ }
452
+ return false
453
+ }
454
+
455
+ // ── the main loop ────────────────────────────────────────────────────────
456
+
457
+ /** The regions currently open, innermost last. Only a nested region uses the stack. */
458
+ const openRegions: Array<{
459
+ rule: RegionRule<State>
460
+ scope: Scope
461
+ /** Where the opening delimiter was, so an unterminated region can be reported. */
462
+ beginFrom: number
463
+ beginTo: number
464
+ }> = []
465
+
466
+ let index = 0
467
+ while (index < length) {
468
+ const open = openRegions[openRegions.length - 1]
469
+
470
+ if (open !== undefined) {
471
+ // The region's own end is always what is tried first, whatever else the rule allows.
472
+ const endMatch = execAt(sticky(open.rule.end), source, index)
473
+ if (endMatch !== null && endMatch[0].length > 0) {
474
+ const closeScope = scopeOf(
475
+ open.rule.closeScope ?? open.rule.scope,
476
+ ruleMatch(endMatch[0], index, [...endMatch]),
477
+ open.scope,
478
+ )
479
+ push(closeScope, index, index + endMatch[0].length, open.scope)
480
+ index += endMatch[0].length
481
+ openRegions.pop()
482
+ regionScope = openRegions[openRegions.length - 1]?.scope
483
+ continue
484
+ }
485
+
486
+ // ── an opaque region recognises nothing but its own delimiters ───────
487
+ // This is what makes a string a string: the word `running` inside a quoted name is
488
+ // text, not a state. Nesting does NOT lift it — a comment that nests is still a
489
+ // comment all the way down, and a language whose comments behave otherwise would be
490
+ // one nobody could write a comment in.
491
+ if (open.rule.transparent !== true) {
492
+ if (open.rule.nested === true) {
493
+ const inner = execAt(sticky(open.rule.begin), source, index)
494
+ if (inner !== null && inner[0].length > 0) {
495
+ const innerMatch = ruleMatch(inner[0], index, [...inner])
496
+ push(
497
+ scopeOf(open.rule.openScope ?? open.rule.scope, innerMatch, open.scope),
498
+ index,
499
+ index + inner[0].length,
500
+ open.scope,
501
+ )
502
+ openRegions.push({
503
+ rule: open.rule,
504
+ scope: open.scope,
505
+ beginFrom: index,
506
+ beginTo: index + inner[0].length,
507
+ })
508
+ index += inner[0].length
509
+ continue
510
+ }
511
+ }
512
+ push(open.scope, index, index + 1, open.scope)
513
+ index += 1
514
+ continue
515
+ }
516
+ // A transparent region falls through: the rule list runs inside it, which is the
517
+ // whole meaning of the flag.
518
+ }
519
+
520
+ let matched = false
521
+
522
+ for (const rule of rules) {
523
+ if (!contextHolds(rule.when, index, previousScope)) continue
524
+
525
+ if (rule.kind === 'region') {
526
+ const begin = execAt(sticky(rule.begin), source, index)
527
+ if (begin === null || begin[0].length === 0) continue
528
+ const beginMatch = ruleMatch(begin[0], index, [...begin])
529
+ const scope = scopeOf(rule.scope, beginMatch, 'text')
530
+ const openScope = scopeOf(rule.openScope ?? rule.scope, beginMatch, scope)
531
+ const contentScope = scopeOf(rule.contentScope ?? rule.scope, beginMatch, scope)
532
+ const closeScope = scopeOf(rule.closeScope ?? rule.scope, beginMatch, scope)
533
+ const beginEnd = index + begin[0].length
534
+ push(openScope, index, beginEnd, regionScope)
535
+
536
+ // A region that only nests, or that is transparent, is tracked on the stack; the
537
+ // stack is what its end is matched against, and what an unterminated one is reported
538
+ // from. Everything else takes the fast path below.
539
+ if (rule.nested === true || rule.transparent === true) {
540
+ openRegions.push({ rule, scope: contentScope, beginFrom: index, beginTo: beginEnd })
541
+ regionScope = contentScope
542
+ index = beginEnd
543
+ matched = true
544
+ break
545
+ }
546
+
547
+ // The common case: find the end in one search rather than re-testing at
548
+ // every character, and paint the whole middle as a single span.
549
+ const close = findRegionEnd(rule.end, source, beginEnd)
550
+ if (close === undefined) {
551
+ // An unterminated region may be painted apart from a terminated one: `unclosed.scope`
552
+ // is what lets a grammar say "this is not a string, it is a broken string", which is
553
+ // the difference between a reader trusting the colours and being misled by them.
554
+ push(
555
+ scopeOf(rule.unclosed?.scope === undefined ? undefined : rule.unclosed.scope, beginMatch, contentScope),
556
+ beginEnd,
557
+ length,
558
+ scope,
559
+ )
560
+ report({
561
+ from: index,
562
+ to: beginEnd,
563
+ message:
564
+ rule.unclosed?.message === undefined
565
+ ? `Unterminated ${scope}.`
566
+ : typeof rule.unclosed.message === 'function'
567
+ ? rule.unclosed.message(beginMatch)
568
+ : rule.unclosed.message,
569
+ severity: rule.unclosed?.severity ?? 'error',
570
+ code: rule.unclosed?.code ?? 'unclosed-region',
571
+ })
572
+ index = length
573
+ matched = true
574
+ break
575
+ }
576
+ push(contentScope, beginEnd, close.from, scope)
577
+ push(closeScope, close.from, close.to, scope)
578
+ index = close.to
579
+ matched = true
580
+ break
581
+ }
582
+
583
+ if (rule.kind === 'words') {
584
+ const words = resolveWords(rule.words)
585
+ const vocabulary = asResolvedVocabulary(rule.words)
586
+ const hit = matchWords(rule, words, index)
587
+ if (hit !== undefined) {
588
+ const text = source.slice(index, hit.to)
589
+ const match = ruleMatch(text, index, [text])
590
+ const scope =
591
+ rule.scope !== undefined
592
+ ? scopeOf(rule.scope, match, 'word')
593
+ : vocabulary?.scopeFor !== undefined
594
+ ? vocabulary.scopeFor(hit.member)
595
+ : 'word'
596
+ push(scope, index, hit.to, regionScope)
597
+ index = hit.to
598
+ matched = true
599
+ break
600
+ }
601
+ if (rule.unknown === undefined) continue
602
+ // Only the first word is consumed. The scanner has no way to know how far
603
+ // a name it has never seen was meant to reach, and guessing would swallow
604
+ // the words that follow it; a multi-word name that is merely misspelled is
605
+ // the grammar's validator's business, where the structure is known.
606
+ const candidateTo = readWordRun(index)
607
+ if (candidateTo <= index) continue
608
+ const word = source.slice(index, candidateTo)
609
+ const rejected = vocabulary?.reject?.(word, vocabularyContext)
610
+ const message =
611
+ rule.unknown.message === undefined
612
+ ? rejected?.message
613
+ : typeof rule.unknown.message === 'function'
614
+ ? rule.unknown.message(word, ruleMatch(word, index, [word]))
615
+ : rule.unknown.message
616
+ .replace(/\{word\}/g, word)
617
+ .replace(/\{allowed\}/g, listPhrase(words.list))
618
+ push(rule.unknown.scope ?? vocabulary?.unknownScope ?? 'invalid', index, candidateTo, regionScope)
619
+ if (message !== undefined) {
620
+ report({
621
+ from: index,
622
+ to: candidateTo,
623
+ message,
624
+ severity: rule.unknown.severity ?? rejected?.severity ?? 'error',
625
+ code: rule.unknown.code ?? rejected?.code ?? 'unknown-word',
626
+ })
627
+ }
628
+ index = candidateTo
629
+ matched = true
630
+ break
631
+ }
632
+
633
+ const match = execAt(sticky(rule.pattern), source, index)
634
+ if (match === null || match[0].length === 0) continue
635
+ const matchInfo = ruleMatch(match[0], index, [...match])
636
+ push(scopeOf(rule.scope, matchInfo, fallbackScope), index, index + match[0].length, regionScope)
637
+ index += match[0].length
638
+ matched = true
639
+ break
640
+ }
641
+
642
+ if (matched) continue
643
+
644
+ // ── nothing claimed this character ─────────────────────────────────────
645
+ // A run of unclaimed characters is taken at once. Whitespace is by far the
646
+ // most common thing no rule claims, and pushing it one character at a time
647
+ // would be both slower and a great many spans.
648
+ //
649
+ // The run carries its own `previous`: once it has consumed a non-whitespace
650
+ // character, the nearest preceding token is the run itself, not whatever came
651
+ // before it. Without that, a rule guarded by `after` would be asked the wrong
652
+ // question part way through and could let the run swallow a word it wanted.
653
+ // Inside a nested region, a character no rule claims is still region content, so
654
+ // it is painted with that region's scope rather than with the document fallback.
655
+ const unclaimed = regionScope ?? fallbackScope
656
+ let to = index
657
+ let runPrevious = previousScope
658
+ while (to < length) {
659
+ const char = source.charAt(to)
660
+ if (char === '\n' || char === '\r') break
661
+ if (anyRuleClaims(to, runPrevious)) break
662
+ if (/\S/.test(char)) runPrevious = unclaimed
663
+ to += 1
664
+ }
665
+ // `anyRuleClaims(index, ...)` is false here by construction, so this always
666
+ // advances; the guard is against a rule list that disagrees with itself.
667
+ push(unclaimed, index, Math.max(to, index + 1), regionScope)
668
+ index = Math.max(to, index + 1)
669
+ }
670
+
671
+ // ── regions the document ended inside ───────────────────────────────────
672
+ // A nested region is closed by the stack, not by a search, so running off the end of the
673
+ // document leaves its frame open with nothing said. Reporting it here is the same fact
674
+ // the opaque path reports when its search fails: a delimiter was opened and never closed.
675
+ // Innermost first, because that is the one the reader has to fix first.
676
+ for (let frame = openRegions.length - 1; frame >= 0; frame -= 1) {
677
+ const open = openRegions[frame]
678
+ if (open === undefined) continue
679
+ const message =
680
+ open.rule.unclosed?.message === undefined
681
+ ? `Unterminated ${open.scope}.`
682
+ : typeof open.rule.unclosed.message === 'function'
683
+ ? open.rule.unclosed.message(ruleMatch('', open.beginFrom, ['']))
684
+ : open.rule.unclosed.message
685
+ report({
686
+ from: open.beginFrom,
687
+ to: open.beginTo,
688
+ message,
689
+ severity: open.rule.unclosed?.severity ?? 'error',
690
+ code: open.rule.unclosed?.code ?? 'unclosed-region',
691
+ })
692
+ }
693
+
694
+ return { tokens, diagnostics, state }
695
+ }
696
+
697
+ /**
698
+ * Find a region's closing delimiter at or after a position.
699
+ *
700
+ * The search runs over the rest of the document rather than character by
701
+ * character. The pattern is deliberately stripped of the sticky flag for this one
702
+ * call: the end of a region is *searched for*, not anchored, which is the
703
+ * difference between a string and a string that must begin exactly where the last
704
+ * one ended.
705
+ * @param pattern - the rule's `end` pattern.
706
+ * @param source - the document.
707
+ * @param from - where to start looking.
708
+ * @returns the closing range, or undefined when the region never closes.
709
+ */
710
+ function findRegionEnd(
711
+ pattern: RegExp,
712
+ source: string,
713
+ from: number,
714
+ ): { from: number; to: number } | undefined {
715
+ const probe = new RegExp(pattern.source, `${pattern.flags.replace(/[gy]/g, '')}g`)
716
+ probe.lastIndex = from
717
+ const match = probe.exec(source)
718
+ if (match === null || match[0].length === 0) return undefined
719
+ return { from: match.index, to: match.index + match[0].length }
720
+ }