@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.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
package/src/core/scan.ts
ADDED
|
@@ -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
|
+
}
|