@kernhq/module-tracker 0.1.1 → 0.1.3
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/dist/server/services/planning.d.ts.map +1 -1
- package/dist/server/services/planning.js +4 -1
- package/dist/server/services/planning.js.map +1 -1
- package/package.json +4 -2
- package/src/client/api.ts +1 -1
- package/src/client/format.ts +1 -1
- package/src/client/group.ts +1 -1
- package/src/client/kql.ts +4 -4
- package/src/client/types.ts +1 -1
- package/src/contract/events.ts +94 -0
- package/src/contract/index.ts +5 -0
- package/src/contract/models.ts +1422 -0
- package/src/contract/notifications.ts +47 -0
- package/src/contract/permissions.ts +197 -0
- package/src/contract/router.ts +857 -0
- package/src/kql/ast.ts +136 -0
- package/src/kql/dates.ts +62 -0
- package/src/kql/fields.ts +150 -0
- package/src/kql/index.ts +15 -0
- package/src/kql/kql.test.ts +371 -0
- package/src/kql/lexer.ts +172 -0
- package/src/kql/parser.ts +300 -0
- package/src/kql/suggest.ts +102 -0
- package/src/kql/validate.ts +89 -0
|
@@ -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
|
+
})
|
package/src/kql/lexer.ts
ADDED
|
@@ -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
|
+
}
|