grammatik 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.
@@ -0,0 +1,353 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ /**
5
+ * Static analysis of a regex source to find which characters a match can begin with.
6
+ *
7
+ * This drives the lexer's first-character dispatch: at each offset we only try
8
+ * the patterns that could possibly match the character sitting there. Profiling
9
+ * showed ~44% of lexing time was `RegExp.exec`, most of it on patterns that
10
+ * could never have matched — the JSON string pattern alone was being run at
11
+ * every single offset.
12
+ *
13
+ * The one invariant that matters for correctness: the returned set must be a
14
+ * **superset** of the characters the pattern can really start with. Anything
15
+ * this analyzer does not fully understand makes it return `null`, meaning
16
+ * "unknown — always try this pattern". Being wrong in that direction only costs
17
+ * speed; being wrong in the other direction would silently drop tokens.
18
+ */
19
+
20
+ const DIGITS = []
21
+ for (let c = 0x30; c <= 0x39; c++) DIGITS.push(c)
22
+
23
+ const WORD = DIGITS.concat([0x5f])
24
+ for (let c = 0x41; c <= 0x5a; c++) WORD.push(c)
25
+ for (let c = 0x61; c <= 0x7a; c++) WORD.push(c)
26
+
27
+ const SPACE = [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff]
28
+ for (let c = 0x2000; c <= 0x200a; c++) SPACE.push(c)
29
+
30
+ const SIMPLE_ESCAPES = {
31
+ n: 0x0a, r: 0x0d, t: 0x09, f: 0x0c, v: 0x0b, 0: 0x00
32
+ }
33
+
34
+ /**
35
+ * @param {string} source Regex source text.
36
+ * @param {boolean} ignoreCase
37
+ * @returns {Set<number>|null} Possible first char codes, or null if undetermined.
38
+ */
39
+ export function firstCharSet (source, ignoreCase) {
40
+ /** @type {Set<number>} */
41
+ const found = new Set()
42
+ let pos = 0
43
+ let bailed = false
44
+
45
+ const bail = () => { bailed = true }
46
+
47
+ /** Adds a char code, plus its case counterpart when the pattern is case-insensitive. */
48
+ function add (code) {
49
+ found.add(code)
50
+ if (!ignoreCase) return
51
+ const char = String.fromCharCode(code)
52
+ const upper = char.toUpperCase()
53
+ const lower = char.toLowerCase()
54
+ if (upper.length === 1) found.add(upper.charCodeAt(0))
55
+ if (lower.length === 1) found.add(lower.charCodeAt(0))
56
+ }
57
+
58
+ function addAll (codes) {
59
+ for (const code of codes) add(code)
60
+ }
61
+
62
+ /**
63
+ * Parses `a|b|c` up to a closing paren or end of input.
64
+ * @returns {boolean} whether the whole alternation can match empty
65
+ */
66
+ function alternation (collect) {
67
+ let nullable = false
68
+ for (;;) {
69
+ if (sequence(collect)) nullable = true
70
+ if (bailed) return true
71
+ if (source[pos] === '|') {
72
+ pos++
73
+ continue
74
+ }
75
+ break
76
+ }
77
+ return nullable
78
+ }
79
+
80
+ /**
81
+ * Parses a concatenation. Characters are only collected while every element so
82
+ * far could match empty — once something must consume, nothing after it can
83
+ * contribute to the first set.
84
+ * @returns {boolean} whether the sequence can match empty
85
+ */
86
+ function sequence (collect) {
87
+ let stillNullable = true
88
+ while (pos < source.length && source[pos] !== '|' && source[pos] !== ')') {
89
+ const nullable = element(collect && stillNullable)
90
+ if (bailed) return true
91
+ if (!nullable) stillNullable = false
92
+ }
93
+ return stillNullable
94
+ }
95
+
96
+ /**
97
+ * Parses one atom plus its quantifier.
98
+ * @returns {boolean} whether it can match empty
99
+ */
100
+ function element (collect) {
101
+ const zeroWidth = atom(collect)
102
+ if (bailed) return true
103
+
104
+ const quantifier = source[pos]
105
+
106
+ if (quantifier === '?' || quantifier === '*') {
107
+ pos++
108
+ if (source[pos] === '?' || source[pos] === '+') pos++
109
+ return true
110
+ }
111
+
112
+ if (quantifier === '+') {
113
+ pos++
114
+ if (source[pos] === '?' || source[pos] === '+') pos++
115
+ return zeroWidth
116
+ }
117
+
118
+ if (quantifier === '{') {
119
+ const close = source.indexOf('}', pos)
120
+ if (close === -1) { bail(); return true }
121
+ const body = source.slice(pos + 1, close)
122
+ if (!/^\d+(,\d*)?$/.test(body)) { bail(); return true }
123
+ pos = close + 1
124
+ if (source[pos] === '?' || source[pos] === '+') pos++
125
+ const min = parseInt(body, 10)
126
+ return min === 0 ? true : zeroWidth
127
+ }
128
+
129
+ return zeroWidth
130
+ }
131
+
132
+ /**
133
+ * Parses a single atom.
134
+ * @returns {boolean} whether the atom is zero-width (an anchor)
135
+ */
136
+ function atom (collect) {
137
+ const char = source[pos]
138
+
139
+ if (char === '(') {
140
+ // A lookaround is zero-width. Treating it as such and letting whatever
141
+ // follows contribute its own first characters still yields a superset,
142
+ // which is all the invariant requires.
143
+ let lookaround = false
144
+ if (source.startsWith('(?:', pos)) pos += 3
145
+ else if (source.startsWith('(?=', pos) || source.startsWith('(?!', pos)) {
146
+ pos += 3
147
+ lookaround = true
148
+ } else if (source.startsWith('(?<=', pos) || source.startsWith('(?<!', pos)) {
149
+ pos += 4
150
+ lookaround = true
151
+ } else if (source.startsWith('(?<', pos)) {
152
+ // Named capture group: skip the name.
153
+ const close = source.indexOf('>', pos)
154
+ if (close === -1) { if (collect) bail(); return true }
155
+ pos = close + 1
156
+ } else pos += 1
157
+
158
+ const nullable = alternation(collect && !lookaround)
159
+ if (bailed) return true
160
+ if (source[pos] !== ')') { if (collect) bail(); return true }
161
+ pos++
162
+ return lookaround ? true : nullable
163
+ }
164
+
165
+ if (char === '[') return charClass(collect)
166
+
167
+ if (char === '\\') return escape(collect)
168
+
169
+ if (char === '.') {
170
+ // Matches nearly everything, so there is no useful set to report — but
171
+ // only when we are still collecting. Past the first mandatory element it
172
+ // is just another atom to step over.
173
+ if (collect) { bail(); return true }
174
+ pos++
175
+ return false
176
+ }
177
+
178
+ if (char === '^' || char === '$') {
179
+ pos++
180
+ return true
181
+ }
182
+
183
+ if (char === undefined) { bail(); return true }
184
+
185
+ // An ordinary literal character.
186
+ if (collect) add(source.charCodeAt(pos))
187
+ pos++
188
+ return false
189
+ }
190
+
191
+ /** Steps over a character class without interpreting it. */
192
+ function skipCharClass () {
193
+ pos++ // past '['
194
+ if (source[pos] === '^') pos++
195
+ if (source[pos] === ']') pos++ // a literal ']' first is allowed
196
+ while (pos < source.length && source[pos] !== ']') {
197
+ if (source[pos] === '\\') pos += 2
198
+ else pos++
199
+ }
200
+ if (source[pos] !== ']') { bail(); return false }
201
+ pos++
202
+ return false
203
+ }
204
+
205
+ /** @returns {boolean} always false (a class consumes one character) */
206
+ function charClass (collect) {
207
+ // A negated class is an enormous set; not worth enumerating. Once we are
208
+ // past the first mandatory element we only need to step over it.
209
+ if (source[pos + 1] === '^') {
210
+ if (collect) { bail(); return false }
211
+ return skipCharClass()
212
+ }
213
+ if (!collect) return skipCharClass()
214
+
215
+ pos++ // past '['
216
+
217
+ while (pos < source.length && source[pos] !== ']') {
218
+ let low
219
+ if (source[pos] === '\\') {
220
+ const before = pos
221
+ const zero = escape(collect, true)
222
+ if (bailed) return false
223
+ if (zero === null) {
224
+ // A class shorthand like \d inside the class: already collected.
225
+ low = null
226
+ } else {
227
+ low = zero
228
+ }
229
+ if (pos === before) { bail(); return false }
230
+ } else {
231
+ low = source.charCodeAt(pos)
232
+ pos++
233
+ }
234
+
235
+ if (low !== null && source[pos] === '-' && source[pos + 1] !== ']' && pos + 1 < source.length) {
236
+ pos++
237
+ let high
238
+ if (source[pos] === '\\') {
239
+ high = escape(collect, true)
240
+ if (bailed) return false
241
+ if (high === null) { bail(); return false }
242
+ } else {
243
+ high = source.charCodeAt(pos)
244
+ pos++
245
+ }
246
+ if (high < low) { bail(); return false }
247
+ // Guard against absurd ranges blowing up memory.
248
+ if (high - low > 0x1000) { bail(); return false }
249
+ if (collect) for (let c = low; c <= high; c++) add(c)
250
+ } else if (low !== null && collect) {
251
+ add(low)
252
+ }
253
+ }
254
+
255
+ if (source[pos] !== ']') { bail(); return false }
256
+ pos++
257
+ return false
258
+ }
259
+
260
+ /**
261
+ * Parses an escape sequence.
262
+ * @param {boolean} collect
263
+ * @param {boolean} [inClass]
264
+ * @returns {any} In a class: the char code, or null when it was a shorthand
265
+ * set (already collected). Outside: whether it is zero-width.
266
+ */
267
+ function escape (collect, inClass = false) {
268
+ pos++ // past '\'
269
+ const char = source[pos]
270
+ if (char === undefined) { bail(); return inClass ? null : true }
271
+
272
+ if (char === 'd' || char === 'w' || char === 's') {
273
+ pos++
274
+ if (collect) addAll(char === 'd' ? DIGITS : char === 'w' ? WORD : SPACE)
275
+ return inClass ? null : false
276
+ }
277
+
278
+ if (char === 'D' || char === 'W' || char === 'S' || char === 'p' || char === 'P') {
279
+ // Complement / unicode property classes: too large to enumerate. Only a
280
+ // problem while collecting; otherwise just step over it.
281
+ if (collect) { bail(); return inClass ? null : false }
282
+ pos++
283
+ if ((char === 'p' || char === 'P') && source[pos] === '{') {
284
+ const close = source.indexOf('}', pos)
285
+ if (close === -1) { bail(); return inClass ? null : false }
286
+ pos = close + 1
287
+ }
288
+ return inClass ? null : false
289
+ }
290
+
291
+ if (char === 'b' || char === 'B') {
292
+ pos++
293
+ if (inClass) {
294
+ // \b inside a class is a backspace.
295
+ if (collect) add(0x08)
296
+ return 0x08
297
+ }
298
+ return true // zero-width assertion
299
+ }
300
+
301
+ if (char === 'u') {
302
+ if (source[pos + 1] === '{') {
303
+ const close = source.indexOf('}', pos)
304
+ if (close === -1) { bail(); return inClass ? null : false }
305
+ const code = parseInt(source.slice(pos + 2, close), 16)
306
+ pos = close + 1
307
+ if (Number.isNaN(code)) { bail(); return inClass ? null : false }
308
+ if (collect) add(code)
309
+ return inClass ? code : false
310
+ }
311
+ const hex = source.slice(pos + 1, pos + 5)
312
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) { bail(); return inClass ? null : false }
313
+ pos += 5
314
+ const code = parseInt(hex, 16)
315
+ if (collect) add(code)
316
+ return inClass ? code : false
317
+ }
318
+
319
+ if (char === 'x') {
320
+ const hex = source.slice(pos + 1, pos + 3)
321
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) { bail(); return inClass ? null : false }
322
+ pos += 3
323
+ const code = parseInt(hex, 16)
324
+ if (collect) add(code)
325
+ return inClass ? code : false
326
+ }
327
+
328
+ if (char >= '1' && char <= '9' && !inClass) {
329
+ // A backreference can start with anything the referenced group can.
330
+ if (collect) { bail(); return false }
331
+ while (source[pos] >= '0' && source[pos] <= '9') pos++
332
+ return false
333
+ }
334
+
335
+ if (char in SIMPLE_ESCAPES) {
336
+ pos++
337
+ const code = SIMPLE_ESCAPES[char]
338
+ if (collect) add(code)
339
+ return inClass ? code : false
340
+ }
341
+
342
+ // An escaped literal such as \. or \[
343
+ pos++
344
+ const code = char.charCodeAt(0)
345
+ if (collect) add(code)
346
+ return inClass ? code : false
347
+ }
348
+
349
+ alternation(true)
350
+
351
+ if (bailed || pos !== source.length || found.size === 0) return null
352
+ return found
353
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { emitModule } from "./emit.js";
2
+ export { createParser, GrammarError } from "./parser.js";
3
+ export { createLexer, LexError } from "./lexer.js";
4
+ export { analyze, buildDispatch, firstOf, PARSER_OPS } from "./analyze.js";
5
+ export { createMethods, OPERATORS } from "./methods.js";
6
+ export { definitionGrammar, definitionTokens, definitionRules, CONSTRUCTOR_NAMES, createDefinitionParser, parseDefinition, createParserFromSource } from "./definition.js";
7
+ export { FAIL, ParseError, createState } from "./state.js";
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ export { createParser, GrammarError } from './parser.js'
5
+ export { createLexer, LexError } from './lexer.js'
6
+ export { analyze, buildDispatch, firstOf, PARSER_OPS } from './analyze.js'
7
+ export { createMethods, OPERATORS } from './methods.js'
8
+ export { createValueMethods } from './values.js'
9
+ export {
10
+ definitionGrammar, definitionTokens, definitionRules, CONSTRUCTOR_NAMES,
11
+ createDefinitionParser, parseDefinition, createParserFromSource
12
+ } from './definition.js'
13
+ export { emitModule } from './emit.js'
14
+ export { splitMethodsBlock, analyzeMethodsBlock, evaluateMethodsBlock } from './methodsblock.js'
15
+ export { FAIL, ParseError, createState } from './state.js'
package/src/lexer.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Compiles token definitions into a fast sticky-regex lexer.
3
+ *
4
+ * Literal tokens are matched with `startsWith` rather than a regex, which is a
5
+ * large win for punctuation-heavy grammars (JSON is ~60% punctuation by token count).
6
+ *
7
+ * @param {TokenDef[]} defs
8
+ * @param {{ positions?: 'full' | 'offset' }} [options]
9
+ * `full` (default) records line and column on every token. `offset` records
10
+ * only `start`/`end`, which skips a scan of the entire input for newlines —
11
+ * worth taking when errors do not need to name a line, or when positions are
12
+ * resolved lazily from the offset afterwards.
13
+ * @returns {{ tokenize: (text: string) => Token[], tokenNames: string[], positions: string }}
14
+ */
15
+ export function createLexer(defs: TokenDef[], options?: {
16
+ positions?: "full" | "offset";
17
+ }): {
18
+ tokenize: (text: string) => Token[];
19
+ tokenNames: string[];
20
+ positions: string;
21
+ };
22
+ /**
23
+ * A Chevrotain-style lexer. Token definitions are plain JSON, so a whole
24
+ * grammar (tokens + rules) stays serializable.
25
+ *
26
+ * @typedef {object} TokenDef
27
+ * @property {string} name The token type produced.
28
+ * @property {string} [pattern] A regex source string, matched sticky at the current offset.
29
+ * @property {string} [literal] A literal string to match. Cheaper than a pattern; prefer it.
30
+ * @property {boolean} [skip] When true the match is consumed but produces no token (whitespace, comments).
31
+ * @property {string} [longerAlt] Name of another token to prefer when it matches a longer string
32
+ * at the same offset (the classic `if` / `identifier` problem).
33
+ * @property {boolean} [ignoreCase] Case-insensitive matching.
34
+ */
35
+ /**
36
+ * @typedef {object} Token
37
+ * @property {string} type
38
+ * @property {string} image
39
+ * @property {number} start
40
+ * @property {number} end
41
+ * @property {number} line
42
+ * @property {number} col
43
+ */
44
+ export class LexError extends Error {
45
+ /**
46
+ * @param {string} message
47
+ * @param {{ offset: number, line: number, col: number }} position
48
+ */
49
+ constructor(message: string, position: {
50
+ offset: number;
51
+ line: number;
52
+ col: number;
53
+ });
54
+ offset: number;
55
+ line: number;
56
+ col: number;
57
+ }
58
+ /**
59
+ * A Chevrotain-style lexer. Token definitions are plain JSON, so a whole
60
+ * grammar (tokens + rules) stays serializable.
61
+ */
62
+ export type TokenDef = {
63
+ /**
64
+ * The token type produced.
65
+ */
66
+ name: string;
67
+ /**
68
+ * A regex source string, matched sticky at the current offset.
69
+ */
70
+ pattern?: string;
71
+ /**
72
+ * A literal string to match. Cheaper than a pattern; prefer it.
73
+ */
74
+ literal?: string;
75
+ /**
76
+ * When true the match is consumed but produces no token (whitespace, comments).
77
+ */
78
+ skip?: boolean;
79
+ /**
80
+ * Name of another token to prefer when it matches a longer string
81
+ * at the same offset (the classic `if` / `identifier` problem).
82
+ */
83
+ longerAlt?: string;
84
+ /**
85
+ * Case-insensitive matching.
86
+ */
87
+ ignoreCase?: boolean;
88
+ };
89
+ export type Token = {
90
+ type: string;
91
+ image: string;
92
+ start: number;
93
+ end: number;
94
+ line: number;
95
+ col: number;
96
+ };