@kernhq/module-tracker 0.1.1 → 0.1.2

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,371 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { KqlComparison, KqlExpr } from './ast.js'
3
+ import { fieldsUsed, printQuery, walkComparisons } from './ast.js'
4
+ import { dateOnly, parseDateLiteral, shift, startOfDay, startOfMonth, startOfWeek } from './dates.js'
5
+ import { customKqlField, operatorsFor, SYSTEM_FIELDS } from './fields.js'
6
+ import { tokenize } from './lexer.js'
7
+ import { parseKql } from './parser.js'
8
+ import { suggest } from './suggest.js'
9
+ import { validateQuery } from './validate.js'
10
+
11
+ const parse = (input: string) => {
12
+ const result = parseKql(input)
13
+ if (!result.query) throw new Error(`parse failed: ${result.errors.map((e) => e.message).join('; ')}`)
14
+ return result
15
+ }
16
+
17
+ const comparisons = (expr: KqlExpr | null): KqlComparison[] => {
18
+ const out: KqlComparison[] = []
19
+ walkComparisons(expr, (c) => out.push(c))
20
+ return out
21
+ }
22
+
23
+ const FIELDS = [
24
+ ...SYSTEM_FIELDS,
25
+ customKqlField('severity', 'select', 'Severity'),
26
+ customKqlField('story_points', 'number', 'Story points'),
27
+ customKqlField('teams', 'multiselect', 'Teams'),
28
+ ]
29
+
30
+ // =====================================================================================
31
+ // lexer
32
+ // =====================================================================================
33
+
34
+ describe('tokenize', () => {
35
+ it('splits operators, identifiers and punctuation', () => {
36
+ const { tokens, errors } = tokenize('status != done and priority in (high, urgent)')
37
+ expect(errors).toEqual([])
38
+ expect(tokens.map((t) => t.kind)).toEqual([
39
+ 'ident',
40
+ 'op',
41
+ 'ident',
42
+ 'ident',
43
+ 'ident',
44
+ 'ident',
45
+ 'lparen',
46
+ 'ident',
47
+ 'comma',
48
+ 'ident',
49
+ 'rparen',
50
+ 'eof',
51
+ ])
52
+ })
53
+
54
+ it('reads two-character operators before single-character ones', () => {
55
+ expect(tokenize('a <= 1').tokens[1]!.text).toBe('<=')
56
+ expect(tokenize('a >= 1').tokens[1]!.text).toBe('>=')
57
+ expect(tokenize('a !~ "x"').tokens[1]!.text).toBe('!~')
58
+ expect(tokenize('a<1').tokens[1]!.text).toBe('<')
59
+ })
60
+
61
+ it('reads quoted strings with escapes', () => {
62
+ const { tokens } = tokenize(String.raw`title ~ "a \"quoted\" word"`)
63
+ expect(tokens[2]!.value).toBe('a "quoted" word')
64
+ })
65
+
66
+ it('reports an unterminated string instead of throwing', () => {
67
+ const { errors } = tokenize('title ~ "open')
68
+ expect(errors[0]?.message).toBe('Unterminated string')
69
+ })
70
+
71
+ it('distinguishes dates, relative dates and plain numbers', () => {
72
+ expect(tokenize('due < 2026-08-22').tokens[2]).toMatchObject({ kind: 'date', value: '2026-08-22' })
73
+ expect(tokenize('updated > -7d').tokens[2]).toMatchObject({ kind: 'reldate', amount: -7, unit: 'd' })
74
+ expect(tokenize('due > +2w').tokens[2]).toMatchObject({ kind: 'reldate', amount: 2, unit: 'w' })
75
+ expect(tokenize('estimate > 5').tokens[2]).toMatchObject({ kind: 'number', value: 5 })
76
+ expect(tokenize('estimate > -3.5').tokens[2]).toMatchObject({ kind: 'number', value: -3.5 })
77
+ })
78
+
79
+ it('keeps issue keys and dotted custom field names as one identifier', () => {
80
+ expect(tokenize('key = KRN-123').tokens[2]!.text).toBe('KRN-123')
81
+ expect(tokenize('cf.story_points > 3').tokens[0]!.text).toBe('cf.story_points')
82
+ })
83
+
84
+ it('records spans so errors can be underlined in the editor', () => {
85
+ const { tokens } = tokenize('status = done')
86
+ expect(tokens[0]).toMatchObject({ start: 0, end: 6 })
87
+ expect(tokens[2]).toMatchObject({ start: 9, end: 13 })
88
+ })
89
+ })
90
+
91
+ // =====================================================================================
92
+ // parser
93
+ // =====================================================================================
94
+
95
+ describe('parseKql', () => {
96
+ it('parses an empty query as "everything"', () => {
97
+ const result = parseKql(' ')
98
+ expect(result.ok).toBe(true)
99
+ expect(result.query).toEqual({ where: null, orderBy: [] })
100
+ })
101
+
102
+ it('parses a single comparison', () => {
103
+ const { query } = parse('status = done')
104
+ expect(query!.where).toMatchObject({ kind: 'cmp', field: 'status', op: '=' })
105
+ expect((query!.where as KqlComparison).value).toMatchObject({ kind: 'ident', value: 'done' })
106
+ })
107
+
108
+ it('parses every comparison operator', () => {
109
+ for (const op of ['=', '!=', '<', '<=', '>', '>=', '~', '!~']) {
110
+ const { query } = parse(`estimate ${op} 3`)
111
+ expect((query!.where as KqlComparison).op).toBe(op)
112
+ }
113
+ })
114
+
115
+ it('gives "and" tighter binding than "or"', () => {
116
+ const { query } = parse('a = 1 or b = 2 and c = 3')
117
+ expect(query!.where!.kind).toBe('or')
118
+ const or = query!.where as Extract<KqlExpr, { kind: 'or' }>
119
+ expect(or.children).toHaveLength(2)
120
+ expect(or.children[1]!.kind).toBe('and')
121
+ })
122
+
123
+ it('honours parentheses', () => {
124
+ const { query } = parse('(a = 1 or b = 2) and c = 3')
125
+ expect(query!.where!.kind).toBe('and')
126
+ })
127
+
128
+ it('flattens repeated connectives into one node', () => {
129
+ const { query } = parse('a = 1 and b = 2 and c = 3')
130
+ expect((query!.where as Extract<KqlExpr, { kind: 'and' }>).children).toHaveLength(3)
131
+ })
132
+
133
+ it('parses "not" as a prefix and "not in" as an operator', () => {
134
+ const negation = parse('not status = done').query!
135
+ expect(negation.where!.kind).toBe('not')
136
+ const notIn = parse('priority not in (low, none)').query!
137
+ expect(notIn.where).toMatchObject({ kind: 'cmp', op: 'not-in' })
138
+ expect((notIn.where as KqlComparison).values).toHaveLength(2)
139
+ })
140
+
141
+ it('parses is empty / is not empty', () => {
142
+ expect(parse('assignee is empty').query!.where).toMatchObject({ op: 'is-empty' })
143
+ expect(parse('assignee is not empty').query!.where).toMatchObject({ op: 'is-not-empty' })
144
+ })
145
+
146
+ it('parses function calls with and without arguments', () => {
147
+ const { query } = parse('assignee = currentUser() and created > startOfWeek(-1)')
148
+ const [first, second] = comparisons(query!.where)
149
+ expect(first!.value).toMatchObject({ kind: 'func', name: 'currentUser', args: [] })
150
+ expect(second!.value).toMatchObject({ kind: 'func', name: 'startOfWeek' })
151
+ expect((second!.value as { args: unknown[] }).args).toHaveLength(1)
152
+ })
153
+
154
+ it('parses booleans and null', () => {
155
+ expect(parse('triage = true').query!.where).toMatchObject({
156
+ value: { kind: 'bool', value: true },
157
+ })
158
+ expect(parse('cycle = null').query!.where).toMatchObject({ value: { kind: 'null' } })
159
+ })
160
+
161
+ it('parses an order by clause with and without a where', () => {
162
+ expect(parse('order by priority desc, updated').query!.orderBy).toEqual([
163
+ { field: 'priority', dir: 'desc', span: expect.anything() },
164
+ { field: 'updated', dir: 'asc', span: expect.anything() },
165
+ ])
166
+ const both = parse('status = done order by updated desc').query!
167
+ expect(both.where).toBeTruthy()
168
+ expect(both.orderBy).toHaveLength(1)
169
+ })
170
+
171
+ it('is case-insensitive for keywords but not for values', () => {
172
+ const { query } = parse('status = Done AND priority IN (high)')
173
+ expect(query!.where!.kind).toBe('and')
174
+ expect(comparisons(query!.where)[0]!.value).toMatchObject({ value: 'Done' })
175
+ })
176
+
177
+ it('reports syntax errors with a span instead of throwing', () => {
178
+ const missingValue = parseKql('status =')
179
+ expect(missingValue.ok).toBe(false)
180
+ expect(missingValue.errors[0]).toMatchObject({ message: 'Expected a value' })
181
+
182
+ const missingOperator = parseKql('status done')
183
+ expect(missingOperator.ok).toBe(false)
184
+ expect(missingOperator.errors[0]!.message).toContain('Expected an operator')
185
+
186
+ const unbalanced = parseKql('(status = done')
187
+ expect(unbalanced.ok).toBe(false)
188
+ expect(unbalanced.errors[0]!.message).toContain('")"')
189
+ })
190
+
191
+ it('collects the fields a query touches', () => {
192
+ const { query } = parse('assignee = currentUser() and label = bug order by priority')
193
+ expect(fieldsUsed(query!).sort()).toEqual(['assignee', 'label', 'priority'])
194
+ })
195
+ })
196
+
197
+ // =====================================================================================
198
+ // printer — normalisation round-trips
199
+ // =====================================================================================
200
+
201
+ describe('printQuery', () => {
202
+ const roundTrip = (input: string) => printQuery(parse(input).query!)
203
+
204
+ it('normalises spacing and keyword case', () => {
205
+ expect(roundTrip('status=done AND priority = high')).toBe('status = done and priority = high')
206
+ })
207
+
208
+ it('keeps parentheses only where precedence needs them', () => {
209
+ expect(roundTrip('(a = 1 or b = 2) and c = 3')).toBe('(a = 1 or b = 2) and c = 3')
210
+ expect(roundTrip('a = 1 or b = 2 and c = 3')).toBe('a = 1 or b = 2 and c = 3')
211
+ })
212
+
213
+ it('re-parses to the same tree', () => {
214
+ for (const input of [
215
+ 'status != done',
216
+ 'priority in (high, urgent) and assignee is empty',
217
+ 'not (label = bug or label = regression)',
218
+ 'created > -30d order by updated desc, priority',
219
+ 'cf.story_points >= 5',
220
+ ]) {
221
+ const once = roundTrip(input)
222
+ expect(roundTrip(once)).toBe(once)
223
+ }
224
+ })
225
+
226
+ it('quotes values that are not bare words', () => {
227
+ expect(roundTrip('title ~ "two words"')).toBe('title ~ "two words"')
228
+ })
229
+ })
230
+
231
+ // =====================================================================================
232
+ // validation
233
+ // =====================================================================================
234
+
235
+ describe('validateQuery', () => {
236
+ it('accepts a well-formed query', () => {
237
+ expect(validateQuery(parse('status = done and priority = high').query!, FIELDS)).toEqual([])
238
+ })
239
+
240
+ it('rejects unknown fields', () => {
241
+ const issues = validateQuery(parse('nope = 1').query!, FIELDS)
242
+ expect(issues[0]!.message).toContain('Unknown field "nope"')
243
+ })
244
+
245
+ it('rejects operators a field does not support', () => {
246
+ const issues = validateQuery(parse('triage > true').query!, FIELDS)
247
+ expect(issues[0]!.message).toContain('is not supported')
248
+ })
249
+
250
+ it('rejects values of the wrong shape', () => {
251
+ expect(validateQuery(parse('estimate = "big"').query!, FIELDS)[0]!.message).toContain('expects a number')
252
+ expect(validateQuery(parse('due = 5').query!, FIELDS)[0]!.message).toContain('expects a date')
253
+ expect(validateQuery(parse('priority = enormous').query!, FIELDS)[0]!.message).toContain(
254
+ 'not a valid priority',
255
+ )
256
+ })
257
+
258
+ it('accepts relative dates and date functions on date fields', () => {
259
+ expect(validateQuery(parse('created > -7d').query!, FIELDS)).toEqual([])
260
+ expect(validateQuery(parse('created > startOfMonth()').query!, FIELDS)).toEqual([])
261
+ })
262
+
263
+ it('rejects unknown functions and wrong arity', () => {
264
+ expect(validateQuery(parse('assignee = nobody()').query!, FIELDS)[0]!.message).toContain(
265
+ 'Unknown function',
266
+ )
267
+ expect(validateQuery(parse('label = membersOf()').query!, FIELDS)[0]!.message).toContain(
268
+ 'expects 1 argument',
269
+ )
270
+ })
271
+
272
+ it('knows about custom fields', () => {
273
+ expect(validateQuery(parse('cf.story_points > 3').query!, FIELDS)).toEqual([])
274
+ expect(validateQuery(parse('cf.unknown > 3').query!, FIELDS)[0]!.message).toContain('Unknown field')
275
+ })
276
+
277
+ it('rejects sorting on a field that is not sortable', () => {
278
+ expect(validateQuery(parse('order by assignee').query!, FIELDS)[0]!.message).toContain('cannot be sorted')
279
+ })
280
+ })
281
+
282
+ describe('operatorsFor', () => {
283
+ it('offers containment operators for arrays and comparison for numbers', () => {
284
+ const assignee = SYSTEM_FIELDS.find((f) => f.name === 'assignee')!
285
+ expect(operatorsFor(assignee)).toContain('in')
286
+ expect(operatorsFor(assignee)).not.toContain('<')
287
+ const estimate = SYSTEM_FIELDS.find((f) => f.name === 'estimate')!
288
+ expect(operatorsFor(estimate)).toEqual(expect.arrayContaining(['<', '<=', '>', '>=', 'is-empty']))
289
+ })
290
+
291
+ it('maps custom field types onto the right kind', () => {
292
+ expect(customKqlField('a', 'number', 'A').kind).toBe('number')
293
+ expect(customKqlField('b', 'checkbox', 'B').kind).toBe('boolean')
294
+ expect(customKqlField('c', 'multiselect', 'C')).toMatchObject({ kind: 'enum', array: true })
295
+ expect(customKqlField('d', 'datetime', 'D').kind).toBe('datetime')
296
+ })
297
+ })
298
+
299
+ // =====================================================================================
300
+ // suggestions
301
+ // =====================================================================================
302
+
303
+ describe('suggest', () => {
304
+ it('offers fields on an empty query and after a connective', () => {
305
+ expect(suggest('', FIELDS).every((s) => s.kind === 'field')).toBe(true)
306
+ expect(suggest('status = done and ', FIELDS).every((s) => s.kind === 'field')).toBe(true)
307
+ })
308
+
309
+ it('filters fields by the prefix being typed', () => {
310
+ const labels = suggest('assi', FIELDS).map((s) => s.label)
311
+ expect(labels).toContain('assignee')
312
+ expect(labels).not.toContain('priority')
313
+ })
314
+
315
+ it('offers operators once a field is complete', () => {
316
+ expect(suggest('priority', FIELDS).every((s) => s.kind === 'operator')).toBe(true)
317
+ })
318
+
319
+ it('offers enum values after an operator', () => {
320
+ expect(suggest('priority = ', FIELDS).map((s) => s.label)).toContain('urgent')
321
+ expect(suggest('priority = ur', FIELDS).map((s) => s.label)).toEqual(['urgent'])
322
+ })
323
+
324
+ it('offers currentUser() for user fields and cycle functions for cycles', () => {
325
+ expect(suggest('assignee = ', FIELDS).map((s) => s.label)).toContain('currentUser()')
326
+ expect(suggest('cycle = ', FIELDS).map((s) => s.label)).toContain('activeCycle()')
327
+ })
328
+
329
+ it('offers connectives after a complete term', () => {
330
+ expect(suggest('status = done ', FIELDS).map((s) => s.label)).toEqual(['and', 'or', 'order by'])
331
+ })
332
+ })
333
+
334
+ // =====================================================================================
335
+ // date helpers
336
+ // =====================================================================================
337
+
338
+ describe('date maths', () => {
339
+ const base = new Date('2026-08-22T15:30:00.000Z') // a Saturday
340
+
341
+ it('shifts by hours, days and weeks', () => {
342
+ expect(shift(base, -7, 'd').toISOString()).toBe('2026-08-15T15:30:00.000Z')
343
+ expect(shift(base, 2, 'w').toISOString()).toBe('2026-09-05T15:30:00.000Z')
344
+ expect(shift(base, -3, 'h').toISOString()).toBe('2026-08-22T12:30:00.000Z')
345
+ })
346
+
347
+ it('clamps month arithmetic to the end of a short month', () => {
348
+ expect(dateOnly(shift(new Date('2026-01-31T00:00:00Z'), 1, 'm'))).toBe('2026-02-28')
349
+ expect(dateOnly(shift(new Date('2026-03-31T00:00:00Z'), -1, 'm'))).toBe('2026-02-28')
350
+ })
351
+
352
+ it('shifts by years', () => {
353
+ expect(dateOnly(shift(base, 1, 'y'))).toBe('2027-08-22')
354
+ })
355
+
356
+ it('computes boundaries in UTC', () => {
357
+ expect(startOfDay(base).toISOString()).toBe('2026-08-22T00:00:00.000Z')
358
+ expect(startOfDay(base, -1).toISOString()).toBe('2026-08-21T00:00:00.000Z')
359
+ // ISO weeks start on Monday
360
+ expect(startOfWeek(base).toISOString()).toBe('2026-08-17T00:00:00.000Z')
361
+ expect(startOfWeek(base, -1).toISOString()).toBe('2026-08-10T00:00:00.000Z')
362
+ expect(startOfMonth(base).toISOString()).toBe('2026-08-01T00:00:00.000Z')
363
+ expect(startOfMonth(base, -2).toISOString()).toBe('2026-06-01T00:00:00.000Z')
364
+ })
365
+
366
+ it('parses both plain days and full timestamps', () => {
367
+ expect(parseDateLiteral('2026-08-22')?.toISOString()).toBe('2026-08-22T00:00:00.000Z')
368
+ expect(parseDateLiteral('2026-08-22T10:00:00Z')?.toISOString()).toBe('2026-08-22T10:00:00.000Z')
369
+ expect(parseDateLiteral('not a date')).toBeNull()
370
+ })
371
+ })
@@ -0,0 +1,172 @@
1
+ import type { Span } from './ast.js'
2
+
3
+ export type TokenKind =
4
+ | 'ident'
5
+ | 'string'
6
+ | 'number'
7
+ | 'date'
8
+ | 'reldate'
9
+ | 'op'
10
+ | 'lparen'
11
+ | 'rparen'
12
+ | 'comma'
13
+ | 'eof'
14
+
15
+ export interface Token extends Span {
16
+ kind: TokenKind
17
+ /** raw source text of the token */
18
+ text: string
19
+ /** decoded value: strings without quotes, numbers as numbers */
20
+ value?: string | number
21
+ /** reldate only */
22
+ amount?: number
23
+ unit?: 'h' | 'd' | 'w' | 'm' | 'y'
24
+ }
25
+
26
+ export interface LexError {
27
+ message: string
28
+ start: number
29
+ end: number
30
+ }
31
+
32
+ export interface LexResult {
33
+ tokens: Token[]
34
+ errors: LexError[]
35
+ }
36
+
37
+ /** Two-character operators are matched before single-character ones. */
38
+ const OPS2 = ['!=', '<=', '>=', '!~'] as const
39
+ const OPS1 = ['=', '<', '>', '~'] as const
40
+
41
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?/
42
+ const RELDATE_RE = /^[+-]?\d+(?:\.\d+)?[hdwmy](?![\w.])/
43
+ const NUMBER_RE = /^[+-]?\d+(?:\.\d+)?/
44
+ /** Barewords may contain dots (`cf.severity`) and dashes (`KRN-12`), but must start with a letter. */
45
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*/
46
+
47
+ const isDigit = (c: string) => c >= '0' && c <= '9'
48
+ const isIdentStart = (c: string) => /[A-Za-z_]/.test(c)
49
+
50
+ /**
51
+ * Turn KQL source into a flat token list. The lexer never throws: anything it cannot read becomes an
52
+ * error with a span, and lexing continues at the next character so the parser can still report
53
+ * everything it understood.
54
+ */
55
+ export function tokenize(input: string): LexResult {
56
+ const tokens: Token[] = []
57
+ const errors: LexError[] = []
58
+ let i = 0
59
+ const push = (t: Token) => {
60
+ tokens.push(t)
61
+ }
62
+
63
+ while (i < input.length) {
64
+ const c = input[i]!
65
+ if (/\s/.test(c)) {
66
+ i++
67
+ continue
68
+ }
69
+ const start = i
70
+
71
+ if (c === '(') {
72
+ push({ kind: 'lparen', text: c, start, end: ++i })
73
+ continue
74
+ }
75
+ if (c === ')') {
76
+ push({ kind: 'rparen', text: c, start, end: ++i })
77
+ continue
78
+ }
79
+ if (c === ',') {
80
+ push({ kind: 'comma', text: c, start, end: ++i })
81
+ continue
82
+ }
83
+
84
+ const two = input.slice(i, i + 2)
85
+ if ((OPS2 as readonly string[]).includes(two)) {
86
+ i += 2
87
+ push({ kind: 'op', text: two, start, end: i })
88
+ continue
89
+ }
90
+ if ((OPS1 as readonly string[]).includes(c)) {
91
+ i += 1
92
+ push({ kind: 'op', text: c, start, end: i })
93
+ continue
94
+ }
95
+ if (c === '!') {
96
+ // a lone `!` is only meaningful in `!=` / `!~`
97
+ errors.push({ message: 'Expected "!=" or "!~"', start, end: start + 1 })
98
+ i++
99
+ continue
100
+ }
101
+
102
+ if (c === '"' || c === "'") {
103
+ const quote = c
104
+ let j = i + 1
105
+ let out = ''
106
+ let closed = false
107
+ while (j < input.length) {
108
+ const ch = input[j]!
109
+ if (ch === '\\' && j + 1 < input.length) {
110
+ const esc = input[j + 1]!
111
+ out += esc === 'n' ? '\n' : esc === 't' ? '\t' : esc
112
+ j += 2
113
+ continue
114
+ }
115
+ if (ch === quote) {
116
+ closed = true
117
+ j++
118
+ break
119
+ }
120
+ out += ch
121
+ j++
122
+ }
123
+ if (!closed) errors.push({ message: 'Unterminated string', start, end: j })
124
+ push({ kind: 'string', text: input.slice(start, j), value: out, start, end: j })
125
+ i = j
126
+ continue
127
+ }
128
+
129
+ if (isDigit(c) || ((c === '-' || c === '+') && isDigit(input[i + 1] ?? ''))) {
130
+ const rest = input.slice(i)
131
+ const dateM = DATE_RE.exec(rest)
132
+ if (dateM) {
133
+ i += dateM[0].length
134
+ push({ kind: 'date', text: dateM[0], value: dateM[0], start, end: i })
135
+ continue
136
+ }
137
+ const relM = RELDATE_RE.exec(rest)
138
+ if (relM) {
139
+ const raw = relM[0]
140
+ i += raw.length
141
+ push({
142
+ kind: 'reldate',
143
+ text: raw,
144
+ amount: Number(raw.slice(0, -1)),
145
+ unit: raw.slice(-1) as Token['unit'],
146
+ start,
147
+ end: i,
148
+ })
149
+ continue
150
+ }
151
+ const numM = NUMBER_RE.exec(rest)
152
+ if (numM) {
153
+ i += numM[0].length
154
+ push({ kind: 'number', text: numM[0], value: Number(numM[0]), start, end: i })
155
+ continue
156
+ }
157
+ }
158
+
159
+ if (isIdentStart(c)) {
160
+ const m = IDENT_RE.exec(input.slice(i))!
161
+ i += m[0].length
162
+ push({ kind: 'ident', text: m[0], value: m[0], start, end: i })
163
+ continue
164
+ }
165
+
166
+ errors.push({ message: `Unexpected character "${c}"`, start, end: start + 1 })
167
+ i++
168
+ }
169
+
170
+ push({ kind: 'eof', text: '', start: input.length, end: input.length })
171
+ return { tokens, errors }
172
+ }