@syncular/typegen 0.4.0 → 0.5.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,514 @@
1
+ /**
2
+ * Lossless lexer for the revision-1 SYQL frontend (`docs/SYQL.md` §2).
3
+ *
4
+ * The lexer owns the SQLite atomic-token boundary used by parsing, formatting,
5
+ * bind discovery, predicate expansion, and editor tooling. It never rewrites
6
+ * source text: concatenating every non-EOF token reproduces the input exactly.
7
+ */
8
+ import { TypegenError } from './errors';
9
+
10
+ export interface SyqlSourcePosition {
11
+ /** UTF-16 source offset, suitable for slicing the JavaScript source string. */
12
+ readonly offset: number;
13
+ /** One-based source line. */
14
+ readonly line: number;
15
+ /** One-based Unicode-scalar column. */
16
+ readonly column: number;
17
+ }
18
+
19
+ export interface SyqlSourceSpan {
20
+ readonly file: string;
21
+ readonly start: SyqlSourcePosition;
22
+ /** Exclusive end position. */
23
+ readonly end: SyqlSourcePosition;
24
+ }
25
+
26
+ export type SyqlTokenKind =
27
+ | 'whitespace'
28
+ | 'line-comment'
29
+ | 'block-comment'
30
+ | 'identifier'
31
+ | 'number'
32
+ | 'string'
33
+ | 'quoted-identifier'
34
+ | 'import-path'
35
+ | 'blob'
36
+ | 'bind'
37
+ | 'at-identifier'
38
+ | 'punctuation'
39
+ | 'operator'
40
+ | 'eof';
41
+
42
+ export interface SyqlToken {
43
+ readonly kind: SyqlTokenKind;
44
+ /** Exact source spelling. */
45
+ readonly text: string;
46
+ readonly span: SyqlSourceSpan;
47
+ }
48
+
49
+ export type SyqlLexErrorCode =
50
+ | 'SYQL1001_UNTERMINATED_STRING'
51
+ | 'SYQL1002_UNTERMINATED_IDENTIFIER'
52
+ | 'SYQL1003_UNTERMINATED_COMMENT'
53
+ | 'SYQL1004_UNTERMINATED_IMPORT_PATH';
54
+
55
+ /** A source-spanned, stable-code error shared by the lexer and parser. */
56
+ export class SyqlFrontendError extends TypegenError {
57
+ readonly code: string;
58
+ readonly span: SyqlSourceSpan;
59
+ readonly sourceFile: string;
60
+
61
+ constructor(code: string, span: SyqlSourceSpan, message: string) {
62
+ super(
63
+ `${span.file}:${span.start.line}:${span.start.column}`,
64
+ `${code}: ${message}`,
65
+ );
66
+ this.name = 'SyqlFrontendError';
67
+ this.code = code;
68
+ this.span = span;
69
+ this.sourceFile = span.file;
70
+ }
71
+ }
72
+
73
+ export function isSyqlTrivia(token: SyqlToken): boolean {
74
+ return (
75
+ token.kind === 'whitespace' ||
76
+ token.kind === 'line-comment' ||
77
+ token.kind === 'block-comment'
78
+ );
79
+ }
80
+
81
+ function isAsciiDigit(code: number): boolean {
82
+ return code >= 0x30 && code <= 0x39;
83
+ }
84
+
85
+ function isAsciiHex(code: number): boolean {
86
+ return (
87
+ isAsciiDigit(code) ||
88
+ (code >= 0x41 && code <= 0x46) ||
89
+ (code >= 0x61 && code <= 0x66)
90
+ );
91
+ }
92
+
93
+ function isAsciiLetter(code: number): boolean {
94
+ return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
95
+ }
96
+
97
+ function isIdentifierStart(code: number): boolean {
98
+ // SQLite treats non-ASCII characters as alphabetic identifier characters.
99
+ return code === 0x5f || isAsciiLetter(code) || code >= 0x80;
100
+ }
101
+
102
+ function isIdentifierContinue(code: number): boolean {
103
+ return isIdentifierStart(code) || isAsciiDigit(code);
104
+ }
105
+
106
+ function isAsciiWhitespace(code: number): boolean {
107
+ return code === 0x20 || (code >= 0x09 && code <= 0x0d);
108
+ }
109
+
110
+ function isPunctuation(ch: string): boolean {
111
+ return '(){}[],.;?'.includes(ch);
112
+ }
113
+
114
+ const THREE_CHAR_OPERATORS = new Set(['->>']);
115
+ const TWO_CHAR_OPERATORS = new Set([
116
+ '->',
117
+ '||',
118
+ '<<',
119
+ '>>',
120
+ '<=',
121
+ '>=',
122
+ '==',
123
+ '!=',
124
+ '<>',
125
+ ]);
126
+
127
+ function scanDigitRun(
128
+ source: string,
129
+ start: number,
130
+ predicate: (code: number) => boolean,
131
+ ): number {
132
+ let i = start;
133
+ let consumed = false;
134
+ while (i < source.length) {
135
+ const code = source.charCodeAt(i);
136
+ if (predicate(code)) {
137
+ consumed = true;
138
+ i += 1;
139
+ continue;
140
+ }
141
+ // SQLite 3.46 permits one underscore between digits.
142
+ if (
143
+ consumed &&
144
+ code === 0x5f &&
145
+ i + 1 < source.length &&
146
+ predicate(source.charCodeAt(i + 1))
147
+ ) {
148
+ i += 2;
149
+ consumed = true;
150
+ continue;
151
+ }
152
+ break;
153
+ }
154
+ return i;
155
+ }
156
+
157
+ class Lexer {
158
+ readonly #source: string;
159
+ readonly #file: string;
160
+ readonly #tokens: SyqlToken[] = [];
161
+ #offset = 0;
162
+ #line = 1;
163
+ #column = 1;
164
+ #braceDepth = 0;
165
+ #expectImportPath = false;
166
+
167
+ constructor(file: string, source: string) {
168
+ this.#file = file;
169
+ this.#source = source;
170
+ }
171
+
172
+ lex(): readonly SyqlToken[] {
173
+ while (this.#offset < this.#source.length) this.#next();
174
+ const atEnd = this.#position();
175
+ this.#tokens.push({
176
+ kind: 'eof',
177
+ text: '',
178
+ span: { file: this.#file, start: atEnd, end: atEnd },
179
+ });
180
+ return this.#tokens;
181
+ }
182
+
183
+ #position(): SyqlSourcePosition {
184
+ return {
185
+ offset: this.#offset,
186
+ line: this.#line,
187
+ column: this.#column,
188
+ };
189
+ }
190
+
191
+ #advanceTo(end: number): void {
192
+ while (this.#offset < end) {
193
+ const code = this.#source.charCodeAt(this.#offset);
194
+ if (code === 0x0d) {
195
+ if (
196
+ this.#offset + 1 < end &&
197
+ this.#source.charCodeAt(this.#offset + 1) === 0x0a
198
+ ) {
199
+ this.#offset += 2;
200
+ } else {
201
+ this.#offset += 1;
202
+ }
203
+ this.#line += 1;
204
+ this.#column = 1;
205
+ } else if (code === 0x0a) {
206
+ this.#offset += 1;
207
+ this.#line += 1;
208
+ this.#column = 1;
209
+ } else {
210
+ const point = this.#source.codePointAt(this.#offset) as number;
211
+ this.#offset += point > 0xffff ? 2 : 1;
212
+ this.#column += 1;
213
+ }
214
+ }
215
+ }
216
+
217
+ #emit(kind: SyqlTokenKind, end: number): void {
218
+ const start = this.#position();
219
+ const text = this.#source.slice(this.#offset, end);
220
+ this.#advanceTo(end);
221
+ const token: SyqlToken = {
222
+ kind,
223
+ text,
224
+ span: { file: this.#file, start, end: this.#position() },
225
+ };
226
+ this.#tokens.push(token);
227
+ this.#afterSignificant(token);
228
+ }
229
+
230
+ #afterSignificant(token: SyqlToken): void {
231
+ if (isSyqlTrivia(token)) return;
232
+
233
+ if (token.kind === 'punctuation') {
234
+ if (token.text === '{') this.#braceDepth += 1;
235
+ else if (token.text === '}')
236
+ this.#braceDepth = Math.max(0, this.#braceDepth - 1);
237
+ }
238
+
239
+ if (
240
+ token.kind === 'identifier' &&
241
+ token.text === 'from' &&
242
+ this.#braceDepth === 0
243
+ ) {
244
+ this.#expectImportPath = true;
245
+ return;
246
+ }
247
+
248
+ if (this.#expectImportPath) this.#expectImportPath = false;
249
+ }
250
+
251
+ #fail(
252
+ code: SyqlLexErrorCode,
253
+ start: SyqlSourcePosition,
254
+ end: number,
255
+ message: string,
256
+ ): never {
257
+ this.#advanceTo(end);
258
+ throw new SyqlFrontendError(
259
+ code,
260
+ { file: this.#file, start, end: this.#position() },
261
+ message,
262
+ );
263
+ }
264
+
265
+ #next(): void {
266
+ const startOffset = this.#offset;
267
+ const code = this.#source.charCodeAt(startOffset);
268
+ const ch = this.#source[startOffset] as string;
269
+
270
+ if (startOffset === 0 && code === 0xfeff) {
271
+ this.#emit('whitespace', startOffset + 1);
272
+ return;
273
+ }
274
+
275
+ if (isAsciiWhitespace(code)) {
276
+ let end = startOffset + 1;
277
+ while (
278
+ end < this.#source.length &&
279
+ isAsciiWhitespace(this.#source.charCodeAt(end))
280
+ ) {
281
+ end += 1;
282
+ }
283
+ this.#emit('whitespace', end);
284
+ return;
285
+ }
286
+
287
+ if (this.#source.startsWith('--', startOffset)) {
288
+ let end = startOffset + 2;
289
+ while (end < this.#source.length) {
290
+ const current = this.#source.charCodeAt(end);
291
+ if (current === 0x0a || current === 0x0d) break;
292
+ end += 1;
293
+ }
294
+ this.#emit('line-comment', end);
295
+ return;
296
+ }
297
+
298
+ if (this.#source.startsWith('/*', startOffset)) {
299
+ const close = this.#source.indexOf('*/', startOffset + 2);
300
+ if (close === -1) {
301
+ this.#fail(
302
+ 'SYQL1003_UNTERMINATED_COMMENT',
303
+ this.#position(),
304
+ this.#source.length,
305
+ 'unterminated block comment',
306
+ );
307
+ }
308
+ this.#emit('block-comment', close + 2);
309
+ return;
310
+ }
311
+
312
+ if (this.#expectImportPath && ch === '"') {
313
+ this.#scanImportPath();
314
+ return;
315
+ }
316
+
317
+ if ((ch === 'x' || ch === 'X') && this.#source[startOffset + 1] === "'") {
318
+ this.#scanDelimited("'", 'blob', 'SYQL1001_UNTERMINATED_STRING');
319
+ return;
320
+ }
321
+
322
+ if (ch === "'") {
323
+ this.#scanDelimited("'", 'string', 'SYQL1001_UNTERMINATED_STRING');
324
+ return;
325
+ }
326
+
327
+ if (ch === '"' || ch === '`') {
328
+ this.#scanDelimited(
329
+ ch,
330
+ 'quoted-identifier',
331
+ 'SYQL1002_UNTERMINATED_IDENTIFIER',
332
+ );
333
+ return;
334
+ }
335
+
336
+ if (ch === '[') {
337
+ const close = this.#source.indexOf(']', startOffset + 1);
338
+ if (close === -1) {
339
+ this.#fail(
340
+ 'SYQL1002_UNTERMINATED_IDENTIFIER',
341
+ this.#position(),
342
+ this.#source.length,
343
+ 'unterminated bracketed identifier',
344
+ );
345
+ }
346
+ this.#emit('quoted-identifier', close + 1);
347
+ return;
348
+ }
349
+
350
+ if (ch === ':') {
351
+ const next = this.#source.charCodeAt(startOffset + 1);
352
+ if (isIdentifierStart(next)) {
353
+ let end = startOffset + 2;
354
+ while (
355
+ end < this.#source.length &&
356
+ isIdentifierContinue(this.#source.charCodeAt(end))
357
+ ) {
358
+ end += 1;
359
+ }
360
+ this.#emit('bind', end);
361
+ } else {
362
+ this.#emit('operator', startOffset + 1);
363
+ }
364
+ return;
365
+ }
366
+
367
+ if (ch === '@') {
368
+ const next = this.#source.charCodeAt(startOffset + 1);
369
+ if (isIdentifierStart(next)) {
370
+ let end = startOffset + 2;
371
+ while (
372
+ end < this.#source.length &&
373
+ isIdentifierContinue(this.#source.charCodeAt(end))
374
+ ) {
375
+ end += 1;
376
+ }
377
+ this.#emit('at-identifier', end);
378
+ } else {
379
+ this.#emit('operator', startOffset + 1);
380
+ }
381
+ return;
382
+ }
383
+
384
+ if (
385
+ isAsciiDigit(code) ||
386
+ (ch === '.' && isAsciiDigit(this.#source.charCodeAt(startOffset + 1)))
387
+ ) {
388
+ this.#emit('number', this.#scanNumber(startOffset));
389
+ return;
390
+ }
391
+
392
+ if (isIdentifierStart(code)) {
393
+ let end = startOffset + 1;
394
+ while (
395
+ end < this.#source.length &&
396
+ isIdentifierContinue(this.#source.charCodeAt(end))
397
+ ) {
398
+ end += 1;
399
+ }
400
+ this.#emit('identifier', end);
401
+ return;
402
+ }
403
+
404
+ if (isPunctuation(ch)) {
405
+ this.#emit('punctuation', startOffset + 1);
406
+ return;
407
+ }
408
+
409
+ const three = this.#source.slice(startOffset, startOffset + 3);
410
+ if (THREE_CHAR_OPERATORS.has(three)) {
411
+ this.#emit('operator', startOffset + 3);
412
+ return;
413
+ }
414
+ const two = this.#source.slice(startOffset, startOffset + 2);
415
+ if (TWO_CHAR_OPERATORS.has(two)) {
416
+ this.#emit('operator', startOffset + 2);
417
+ return;
418
+ }
419
+
420
+ // Keep unknown SQLite punctuation lossless and let the parser/reference
421
+ // SQLite validator issue the contextual error later.
422
+ this.#emit('operator', startOffset + 1);
423
+ }
424
+
425
+ #scanDelimited(
426
+ delimiter: "'" | '"' | '`',
427
+ kind: 'string' | 'quoted-identifier' | 'blob',
428
+ code: SyqlLexErrorCode,
429
+ ): void {
430
+ const start = this.#position();
431
+ const contentStart = kind === 'blob' ? this.#offset + 2 : this.#offset + 1;
432
+ let end = contentStart;
433
+ while (end < this.#source.length) {
434
+ if (this.#source[end] !== delimiter) {
435
+ end += 1;
436
+ continue;
437
+ }
438
+ if (this.#source[end + 1] === delimiter) {
439
+ end += 2;
440
+ continue;
441
+ }
442
+ this.#emit(kind, end + 1);
443
+ return;
444
+ }
445
+ this.#fail(
446
+ code,
447
+ start,
448
+ this.#source.length,
449
+ kind === 'string' || kind === 'blob'
450
+ ? 'unterminated SQL string literal'
451
+ : 'unterminated quoted identifier',
452
+ );
453
+ }
454
+
455
+ #scanImportPath(): void {
456
+ const start = this.#position();
457
+ let end = this.#offset + 1;
458
+ while (end < this.#source.length) {
459
+ const code = this.#source.charCodeAt(end);
460
+ if (code === 0x0a || code === 0x0d) break;
461
+ if (code === 0x5c) {
462
+ end += 2;
463
+ continue;
464
+ }
465
+ if (code === 0x22) {
466
+ this.#emit('import-path', end + 1);
467
+ return;
468
+ }
469
+ end += 1;
470
+ }
471
+ this.#fail(
472
+ 'SYQL1004_UNTERMINATED_IMPORT_PATH',
473
+ start,
474
+ Math.min(end, this.#source.length),
475
+ 'unterminated JSON import path',
476
+ );
477
+ }
478
+
479
+ #scanNumber(start: number): number {
480
+ if (
481
+ this.#source[start] === '0' &&
482
+ (this.#source[start + 1] === 'x' || this.#source[start + 1] === 'X') &&
483
+ isAsciiHex(this.#source.charCodeAt(start + 2))
484
+ ) {
485
+ return scanDigitRun(this.#source, start + 2, isAsciiHex);
486
+ }
487
+
488
+ let end = start;
489
+ if (this.#source[end] !== '.') {
490
+ end = scanDigitRun(this.#source, end, isAsciiDigit);
491
+ }
492
+ if (this.#source[end] === '.') {
493
+ end += 1;
494
+ end = scanDigitRun(this.#source, end, isAsciiDigit);
495
+ }
496
+ if (this.#source[end] === 'e' || this.#source[end] === 'E') {
497
+ let exponent = end + 1;
498
+ if (this.#source[exponent] === '+' || this.#source[exponent] === '-') {
499
+ exponent += 1;
500
+ }
501
+ const exponentEnd = scanDigitRun(this.#source, exponent, isAsciiDigit);
502
+ if (exponentEnd > exponent) end = exponentEnd;
503
+ }
504
+ return end;
505
+ }
506
+ }
507
+
508
+ /** Lex one complete `.syql` source file, including trivia and an EOF token. */
509
+ export function lexSyqlSource(
510
+ file: string,
511
+ source: string,
512
+ ): readonly SyqlToken[] {
513
+ return new Lexer(file, source).lex();
514
+ }