@gblikas/querykit 0.1.0 → 0.3.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/.cursor/BUGBOT.md +65 -2
- package/README.md +163 -1
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.js +1 -0
- package/dist/parser/input-parser.d.ts +215 -0
- package/dist/parser/input-parser.js +493 -0
- package/dist/parser/parser.d.ts +148 -1
- package/dist/parser/parser.js +880 -6
- package/dist/parser/types.d.ts +432 -0
- package/examples/qk-next/app/page.tsx +6 -1
- package/package.json +1 -1
- package/src/parser/divergence.test.ts +357 -0
- package/src/parser/index.ts +2 -1
- package/src/parser/input-parser.test.ts +770 -0
- package/src/parser/input-parser.ts +697 -0
- package/src/parser/parse-with-context-suggestions.test.ts +360 -0
- package/src/parser/parse-with-context-validation.test.ts +447 -0
- package/src/parser/parse-with-context.test.ts +325 -0
- package/src/parser/parser.test.ts +209 -1
- package/src/parser/parser.ts +1106 -25
- package/src/parser/token-consistency.test.ts +341 -0
- package/src/parser/types.ts +545 -23
- package/examples/qk-next/pnpm-lock.yaml +0 -5623
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseQueryInput,
|
|
3
|
+
getTermAtPosition,
|
|
4
|
+
isInputComplete,
|
|
5
|
+
extractKeyValue,
|
|
6
|
+
parseQueryTokens
|
|
7
|
+
} from './input-parser';
|
|
8
|
+
|
|
9
|
+
describe('Input Parser', () => {
|
|
10
|
+
describe('parseQueryInput', () => {
|
|
11
|
+
describe('basic key:value parsing', () => {
|
|
12
|
+
it('should parse a simple key:value input', () => {
|
|
13
|
+
const result = parseQueryInput('status:done');
|
|
14
|
+
|
|
15
|
+
expect(result.terms).toHaveLength(1);
|
|
16
|
+
expect(result.terms[0]).toMatchObject({
|
|
17
|
+
key: 'status',
|
|
18
|
+
operator: ':',
|
|
19
|
+
value: 'done'
|
|
20
|
+
});
|
|
21
|
+
expect(result.activeTerm).toEqual(result.terms[0]);
|
|
22
|
+
expect(result.cursorContext).toBe('value');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should parse an incomplete key:value input (user typing value)', () => {
|
|
26
|
+
const result = parseQueryInput('status:d');
|
|
27
|
+
|
|
28
|
+
expect(result.terms).toHaveLength(1);
|
|
29
|
+
expect(result.terms[0]).toMatchObject({
|
|
30
|
+
key: 'status',
|
|
31
|
+
operator: ':',
|
|
32
|
+
value: 'd'
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should parse a key with no value yet', () => {
|
|
37
|
+
const result = parseQueryInput('status:');
|
|
38
|
+
|
|
39
|
+
expect(result.terms).toHaveLength(1);
|
|
40
|
+
expect(result.terms[0]).toMatchObject({
|
|
41
|
+
key: 'status',
|
|
42
|
+
operator: ':',
|
|
43
|
+
value: null
|
|
44
|
+
});
|
|
45
|
+
expect(result.cursorContext).toBe('value');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should handle bare values (no key)', () => {
|
|
49
|
+
const result = parseQueryInput('hello');
|
|
50
|
+
|
|
51
|
+
expect(result.terms).toHaveLength(1);
|
|
52
|
+
expect(result.terms[0]).toMatchObject({
|
|
53
|
+
key: null,
|
|
54
|
+
operator: null,
|
|
55
|
+
value: 'hello'
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should handle empty input', () => {
|
|
60
|
+
const result = parseQueryInput('');
|
|
61
|
+
|
|
62
|
+
expect(result.terms).toHaveLength(0);
|
|
63
|
+
expect(result.activeTerm).toBeNull();
|
|
64
|
+
expect(result.cursorContext).toBe('empty');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should handle whitespace-only input', () => {
|
|
68
|
+
const result = parseQueryInput(' ');
|
|
69
|
+
|
|
70
|
+
expect(result.terms).toHaveLength(0);
|
|
71
|
+
expect(result.cursorContext).toBe('empty');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('comparison operators', () => {
|
|
76
|
+
it('should parse greater than operator', () => {
|
|
77
|
+
const result = parseQueryInput('priority:>5');
|
|
78
|
+
|
|
79
|
+
expect(result.terms[0]).toMatchObject({
|
|
80
|
+
key: 'priority',
|
|
81
|
+
operator: ':>',
|
|
82
|
+
value: '5'
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should parse greater than or equal operator', () => {
|
|
87
|
+
const result = parseQueryInput('priority:>=5');
|
|
88
|
+
|
|
89
|
+
expect(result.terms[0]).toMatchObject({
|
|
90
|
+
key: 'priority',
|
|
91
|
+
operator: ':>=',
|
|
92
|
+
value: '5'
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should parse less than operator', () => {
|
|
97
|
+
const result = parseQueryInput('priority:<5');
|
|
98
|
+
|
|
99
|
+
expect(result.terms[0]).toMatchObject({
|
|
100
|
+
key: 'priority',
|
|
101
|
+
operator: ':<',
|
|
102
|
+
value: '5'
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should parse less than or equal operator', () => {
|
|
107
|
+
const result = parseQueryInput('priority:<=5');
|
|
108
|
+
|
|
109
|
+
expect(result.terms[0]).toMatchObject({
|
|
110
|
+
key: 'priority',
|
|
111
|
+
operator: ':<=',
|
|
112
|
+
value: '5'
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should parse not equal operator', () => {
|
|
117
|
+
const result = parseQueryInput('status:!=active');
|
|
118
|
+
|
|
119
|
+
expect(result.terms[0]).toMatchObject({
|
|
120
|
+
key: 'status',
|
|
121
|
+
operator: ':!=',
|
|
122
|
+
value: 'active'
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should parse equal operator with colon-equals', () => {
|
|
127
|
+
const result = parseQueryInput('count:=10');
|
|
128
|
+
|
|
129
|
+
expect(result.terms[0]).toMatchObject({
|
|
130
|
+
key: 'count',
|
|
131
|
+
operator: ':=',
|
|
132
|
+
value: '10'
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('multiple terms', () => {
|
|
138
|
+
it('should parse multiple space-separated terms', () => {
|
|
139
|
+
const result = parseQueryInput('status:done priority:high');
|
|
140
|
+
|
|
141
|
+
expect(result.terms).toHaveLength(2);
|
|
142
|
+
expect(result.terms[0]).toMatchObject({
|
|
143
|
+
key: 'status',
|
|
144
|
+
operator: ':',
|
|
145
|
+
value: 'done'
|
|
146
|
+
});
|
|
147
|
+
expect(result.terms[1]).toMatchObject({
|
|
148
|
+
key: 'priority',
|
|
149
|
+
operator: ':',
|
|
150
|
+
value: 'high'
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should parse terms with logical operators', () => {
|
|
155
|
+
const result = parseQueryInput('status:done AND priority:high');
|
|
156
|
+
|
|
157
|
+
expect(result.terms).toHaveLength(2);
|
|
158
|
+
expect(result.logicalOperators).toHaveLength(1);
|
|
159
|
+
expect(result.logicalOperators[0]).toMatchObject({
|
|
160
|
+
operator: 'AND'
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should parse terms with OR operator', () => {
|
|
165
|
+
const result = parseQueryInput('status:todo OR status:doing');
|
|
166
|
+
|
|
167
|
+
expect(result.terms).toHaveLength(2);
|
|
168
|
+
expect(result.logicalOperators).toHaveLength(1);
|
|
169
|
+
expect(result.logicalOperators[0].operator).toBe('OR');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should parse terms with NOT operator', () => {
|
|
173
|
+
const result = parseQueryInput('status:done NOT priority:low');
|
|
174
|
+
|
|
175
|
+
expect(result.terms).toHaveLength(2);
|
|
176
|
+
expect(result.logicalOperators).toHaveLength(1);
|
|
177
|
+
expect(result.logicalOperators[0].operator).toBe('NOT');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should handle mixed bare values and key:value terms', () => {
|
|
181
|
+
const result = parseQueryInput('hello status:active world');
|
|
182
|
+
|
|
183
|
+
expect(result.terms).toHaveLength(3);
|
|
184
|
+
expect(result.terms[0]).toMatchObject({ key: null, value: 'hello' });
|
|
185
|
+
expect(result.terms[1]).toMatchObject({
|
|
186
|
+
key: 'status',
|
|
187
|
+
value: 'active'
|
|
188
|
+
});
|
|
189
|
+
expect(result.terms[2]).toMatchObject({ key: null, value: 'world' });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('quoted values', () => {
|
|
194
|
+
it('should parse double-quoted values', () => {
|
|
195
|
+
const result = parseQueryInput('name:"John Doe"');
|
|
196
|
+
|
|
197
|
+
expect(result.terms[0]).toMatchObject({
|
|
198
|
+
key: 'name',
|
|
199
|
+
operator: ':',
|
|
200
|
+
value: '"John Doe"'
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should parse single-quoted values', () => {
|
|
205
|
+
const result = parseQueryInput("name:'Jane Doe'");
|
|
206
|
+
|
|
207
|
+
expect(result.terms[0]).toMatchObject({
|
|
208
|
+
key: 'name',
|
|
209
|
+
operator: ':',
|
|
210
|
+
value: "'Jane Doe'"
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should parse bare quoted values', () => {
|
|
215
|
+
const result = parseQueryInput('"hello world"');
|
|
216
|
+
|
|
217
|
+
expect(result.terms[0]).toMatchObject({
|
|
218
|
+
key: null,
|
|
219
|
+
operator: null,
|
|
220
|
+
value: '"hello world"'
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('negation', () => {
|
|
226
|
+
it('should handle negation prefix on key:value', () => {
|
|
227
|
+
const result = parseQueryInput('-status:active');
|
|
228
|
+
|
|
229
|
+
expect(result.terms[0]).toMatchObject({
|
|
230
|
+
key: '-status',
|
|
231
|
+
operator: ':',
|
|
232
|
+
value: 'active'
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should handle negation prefix on bare value', () => {
|
|
237
|
+
const result = parseQueryInput('-important');
|
|
238
|
+
|
|
239
|
+
expect(result.terms[0]).toMatchObject({
|
|
240
|
+
key: null,
|
|
241
|
+
value: '-important'
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('cursor position tracking', () => {
|
|
247
|
+
it('should identify active term based on cursor position', () => {
|
|
248
|
+
const input = 'status:done priority:high';
|
|
249
|
+
const result = parseQueryInput(input, 5); // cursor in "status"
|
|
250
|
+
|
|
251
|
+
expect(result.activeTerm?.key).toBe('status');
|
|
252
|
+
expect(result.cursorContext).toBe('key');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should identify cursor in value position', () => {
|
|
256
|
+
const input = 'status:done';
|
|
257
|
+
const result = parseQueryInput(input, 9); // cursor in "done"
|
|
258
|
+
|
|
259
|
+
expect(result.activeTerm?.key).toBe('status');
|
|
260
|
+
expect(result.cursorContext).toBe('value');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should identify cursor at end of input', () => {
|
|
264
|
+
const input = 'status:done';
|
|
265
|
+
const result = parseQueryInput(input, 11);
|
|
266
|
+
|
|
267
|
+
expect(result.activeTerm?.key).toBe('status');
|
|
268
|
+
expect(result.cursorContext).toBe('value');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should identify cursor between terms', () => {
|
|
272
|
+
const input = 'status:done priority:high';
|
|
273
|
+
const result = parseQueryInput(input, 13); // cursor in whitespace
|
|
274
|
+
|
|
275
|
+
expect(result.cursorContext).toBe('between');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should handle cursor at operator position', () => {
|
|
279
|
+
const input = 'status:done';
|
|
280
|
+
const result = parseQueryInput(input, 6); // cursor at ":"
|
|
281
|
+
|
|
282
|
+
expect(result.activeTerm?.key).toBe('status');
|
|
283
|
+
expect(result.cursorContext).toBe('operator');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('position tracking', () => {
|
|
288
|
+
it('should track start and end positions of terms', () => {
|
|
289
|
+
const result = parseQueryInput('status:done');
|
|
290
|
+
|
|
291
|
+
expect(result.terms[0].startPosition).toBe(0);
|
|
292
|
+
expect(result.terms[0].endPosition).toBe(11);
|
|
293
|
+
expect(result.terms[0].raw).toBe('status:done');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should track positions for multiple terms', () => {
|
|
297
|
+
const result = parseQueryInput('a:1 b:2');
|
|
298
|
+
|
|
299
|
+
expect(result.terms[0].startPosition).toBe(0);
|
|
300
|
+
expect(result.terms[0].endPosition).toBe(3);
|
|
301
|
+
expect(result.terms[1].startPosition).toBe(4);
|
|
302
|
+
expect(result.terms[1].endPosition).toBe(7);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('options', () => {
|
|
307
|
+
it('should apply case-insensitive keys when option is set', () => {
|
|
308
|
+
const result = parseQueryInput('STATUS:done', undefined, {
|
|
309
|
+
caseInsensitiveKeys: true
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.terms[0].key).toBe('status');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should preserve key case by default', () => {
|
|
316
|
+
const result = parseQueryInput('STATUS:done');
|
|
317
|
+
|
|
318
|
+
expect(result.terms[0].key).toBe('STATUS');
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('parentheses handling', () => {
|
|
323
|
+
it('should handle parentheses in input', () => {
|
|
324
|
+
const result = parseQueryInput('(status:done OR status:todo)');
|
|
325
|
+
|
|
326
|
+
expect(result.terms).toHaveLength(2);
|
|
327
|
+
expect(result.terms[0].key).toBe('status');
|
|
328
|
+
expect(result.terms[1].key).toBe('status');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should handle nested parentheses', () => {
|
|
332
|
+
const result = parseQueryInput('((a:1 OR b:2) AND c:3)');
|
|
333
|
+
|
|
334
|
+
expect(result.terms).toHaveLength(3);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('getTermAtPosition', () => {
|
|
340
|
+
it('should return the term at a given position', () => {
|
|
341
|
+
const term = getTermAtPosition('status:done priority:high', 5);
|
|
342
|
+
|
|
343
|
+
expect(term?.key).toBe('status');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should return null when position is between terms', () => {
|
|
347
|
+
const term = getTermAtPosition('a:1 b:2', 4);
|
|
348
|
+
|
|
349
|
+
expect(term).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should return the correct term when cursor is at the end', () => {
|
|
353
|
+
const term = getTermAtPosition('status:done', 11);
|
|
354
|
+
|
|
355
|
+
expect(term?.key).toBe('status');
|
|
356
|
+
expect(term?.value).toBe('done');
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('isInputComplete', () => {
|
|
361
|
+
it('should return true for complete key:value expression', () => {
|
|
362
|
+
expect(isInputComplete('status:done')).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should return true for multiple complete terms', () => {
|
|
366
|
+
expect(isInputComplete('status:done AND priority:high')).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should return false for incomplete key:value (no value)', () => {
|
|
370
|
+
expect(isInputComplete('status:')).toBe(false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should return false for empty input', () => {
|
|
374
|
+
expect(isInputComplete('')).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should return false for input ending with logical operator', () => {
|
|
378
|
+
expect(isInputComplete('status:done AND')).toBe(false);
|
|
379
|
+
expect(isInputComplete('status:done OR')).toBe(false);
|
|
380
|
+
expect(isInputComplete('status:done NOT')).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should return false for unclosed quotes', () => {
|
|
384
|
+
expect(isInputComplete('name:"John')).toBe(false);
|
|
385
|
+
expect(isInputComplete("name:'Jane")).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should return false for unclosed parentheses', () => {
|
|
389
|
+
expect(isInputComplete('(status:done')).toBe(false);
|
|
390
|
+
expect(isInputComplete('status:done)')).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should return true for complete bare value', () => {
|
|
394
|
+
expect(isInputComplete('hello')).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should return true for complete quoted value', () => {
|
|
398
|
+
expect(isInputComplete('"hello world"')).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('extractKeyValue', () => {
|
|
403
|
+
it('should extract key and value from simple input', () => {
|
|
404
|
+
const result = extractKeyValue('status:done');
|
|
405
|
+
|
|
406
|
+
expect(result).toEqual({ key: 'status', value: 'done' });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should extract key with null value for incomplete input', () => {
|
|
410
|
+
const result = extractKeyValue('status:');
|
|
411
|
+
|
|
412
|
+
expect(result).toEqual({ key: 'status', value: null });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should return null for bare value (no key)', () => {
|
|
416
|
+
const result = extractKeyValue('hello');
|
|
417
|
+
|
|
418
|
+
expect(result).toBeNull();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should return null for empty input', () => {
|
|
422
|
+
const result = extractKeyValue('');
|
|
423
|
+
|
|
424
|
+
expect(result).toBeNull();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should handle leading/trailing whitespace', () => {
|
|
428
|
+
const result = extractKeyValue(' status:done ');
|
|
429
|
+
|
|
430
|
+
expect(result).toEqual({ key: 'status', value: 'done' });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should return the first key:value when multiple are present', () => {
|
|
434
|
+
const result = extractKeyValue('status:done priority:high');
|
|
435
|
+
|
|
436
|
+
expect(result).toEqual({ key: 'status', value: 'done' });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should handle comparison operators', () => {
|
|
440
|
+
const result = extractKeyValue('priority:>5');
|
|
441
|
+
|
|
442
|
+
expect(result).toEqual({ key: 'priority', value: '5' });
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('parseQueryTokens - interleaved sequence', () => {
|
|
447
|
+
it('should return empty array for empty input', () => {
|
|
448
|
+
const result = parseQueryTokens('');
|
|
449
|
+
|
|
450
|
+
expect(result.tokens).toHaveLength(0);
|
|
451
|
+
expect(result.activeToken).toBeNull();
|
|
452
|
+
expect(result.activeTokenIndex).toBe(-1);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should parse a single term', () => {
|
|
456
|
+
const result = parseQueryTokens('status:done');
|
|
457
|
+
|
|
458
|
+
expect(result.tokens).toHaveLength(1);
|
|
459
|
+
expect(result.tokens[0]).toMatchObject({
|
|
460
|
+
type: 'term',
|
|
461
|
+
key: 'status',
|
|
462
|
+
value: 'done'
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should parse compound expression with AND', () => {
|
|
467
|
+
const result = parseQueryTokens('keyVal1:val1 AND keyVal2:val2');
|
|
468
|
+
|
|
469
|
+
expect(result.tokens).toHaveLength(3);
|
|
470
|
+
|
|
471
|
+
// First term
|
|
472
|
+
expect(result.tokens[0]).toMatchObject({
|
|
473
|
+
type: 'term',
|
|
474
|
+
key: 'keyVal1',
|
|
475
|
+
value: 'val1'
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// AND operator
|
|
479
|
+
expect(result.tokens[1]).toMatchObject({
|
|
480
|
+
type: 'operator',
|
|
481
|
+
operator: 'AND'
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Second term
|
|
485
|
+
expect(result.tokens[2]).toMatchObject({
|
|
486
|
+
type: 'term',
|
|
487
|
+
key: 'keyVal2',
|
|
488
|
+
value: 'val2'
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('should parse compound expression with OR', () => {
|
|
493
|
+
const result = parseQueryTokens(
|
|
494
|
+
'status:todo OR status:doing OR status:done'
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
expect(result.tokens).toHaveLength(5);
|
|
498
|
+
expect(result.tokens[0].type).toBe('term');
|
|
499
|
+
expect(result.tokens[1]).toMatchObject({
|
|
500
|
+
type: 'operator',
|
|
501
|
+
operator: 'OR'
|
|
502
|
+
});
|
|
503
|
+
expect(result.tokens[2].type).toBe('term');
|
|
504
|
+
expect(result.tokens[3]).toMatchObject({
|
|
505
|
+
type: 'operator',
|
|
506
|
+
operator: 'OR'
|
|
507
|
+
});
|
|
508
|
+
expect(result.tokens[4].type).toBe('term');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('should parse expression with NOT', () => {
|
|
512
|
+
const result = parseQueryTokens('status:active NOT priority:low');
|
|
513
|
+
|
|
514
|
+
expect(result.tokens).toHaveLength(3);
|
|
515
|
+
expect(result.tokens[0]).toMatchObject({
|
|
516
|
+
type: 'term',
|
|
517
|
+
key: 'status',
|
|
518
|
+
value: 'active'
|
|
519
|
+
});
|
|
520
|
+
expect(result.tokens[1]).toMatchObject({
|
|
521
|
+
type: 'operator',
|
|
522
|
+
operator: 'NOT'
|
|
523
|
+
});
|
|
524
|
+
expect(result.tokens[2]).toMatchObject({
|
|
525
|
+
type: 'term',
|
|
526
|
+
key: 'priority',
|
|
527
|
+
value: 'low'
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should include position information for all tokens', () => {
|
|
532
|
+
const input = 'a:1 AND b:2';
|
|
533
|
+
const result = parseQueryTokens(input);
|
|
534
|
+
|
|
535
|
+
expect(result.tokens).toHaveLength(3);
|
|
536
|
+
|
|
537
|
+
// First term: 'a:1' at positions 0-3
|
|
538
|
+
expect(result.tokens[0]).toMatchObject({
|
|
539
|
+
type: 'term',
|
|
540
|
+
startPosition: 0,
|
|
541
|
+
endPosition: 3,
|
|
542
|
+
raw: 'a:1'
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// AND operator at positions 4-7
|
|
546
|
+
expect(result.tokens[1]).toMatchObject({
|
|
547
|
+
type: 'operator',
|
|
548
|
+
operator: 'AND',
|
|
549
|
+
startPosition: 4,
|
|
550
|
+
endPosition: 7,
|
|
551
|
+
raw: 'AND'
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Second term: 'b:2' at positions 8-11
|
|
555
|
+
expect(result.tokens[2]).toMatchObject({
|
|
556
|
+
type: 'term',
|
|
557
|
+
startPosition: 8,
|
|
558
|
+
endPosition: 11,
|
|
559
|
+
raw: 'b:2'
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('should identify active token based on cursor position', () => {
|
|
564
|
+
const input = 'status:done AND priority:high';
|
|
565
|
+
|
|
566
|
+
// Cursor in first term
|
|
567
|
+
let result = parseQueryTokens(input, 5);
|
|
568
|
+
expect(result.activeToken?.type).toBe('term');
|
|
569
|
+
expect(result.activeTokenIndex).toBe(0);
|
|
570
|
+
|
|
571
|
+
// Cursor in AND operator
|
|
572
|
+
result = parseQueryTokens(input, 13);
|
|
573
|
+
expect(result.activeToken?.type).toBe('operator');
|
|
574
|
+
expect(result.activeTokenIndex).toBe(1);
|
|
575
|
+
|
|
576
|
+
// Cursor in second term
|
|
577
|
+
result = parseQueryTokens(input, 20);
|
|
578
|
+
expect(result.activeToken?.type).toBe('term');
|
|
579
|
+
expect(result.activeTokenIndex).toBe(2);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should handle incomplete expressions', () => {
|
|
583
|
+
const result = parseQueryTokens('status:done AND');
|
|
584
|
+
|
|
585
|
+
expect(result.tokens).toHaveLength(2);
|
|
586
|
+
expect(result.tokens[0]).toMatchObject({
|
|
587
|
+
type: 'term',
|
|
588
|
+
key: 'status',
|
|
589
|
+
value: 'done'
|
|
590
|
+
});
|
|
591
|
+
expect(result.tokens[1]).toMatchObject({
|
|
592
|
+
type: 'operator',
|
|
593
|
+
operator: 'AND'
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('should handle partial value in term', () => {
|
|
598
|
+
const result = parseQueryTokens('status:d');
|
|
599
|
+
|
|
600
|
+
expect(result.tokens).toHaveLength(1);
|
|
601
|
+
expect(result.tokens[0]).toMatchObject({
|
|
602
|
+
type: 'term',
|
|
603
|
+
key: 'status',
|
|
604
|
+
value: 'd'
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should handle term with no value yet', () => {
|
|
609
|
+
const result = parseQueryTokens('status:');
|
|
610
|
+
|
|
611
|
+
expect(result.tokens).toHaveLength(1);
|
|
612
|
+
expect(result.tokens[0]).toMatchObject({
|
|
613
|
+
type: 'term',
|
|
614
|
+
key: 'status',
|
|
615
|
+
value: null
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('should handle mixed operators', () => {
|
|
620
|
+
const result = parseQueryTokens('a:1 AND b:2 OR c:3');
|
|
621
|
+
|
|
622
|
+
expect(result.tokens).toHaveLength(5);
|
|
623
|
+
expect(result.tokens[0].type).toBe('term');
|
|
624
|
+
expect(result.tokens[1]).toMatchObject({
|
|
625
|
+
type: 'operator',
|
|
626
|
+
operator: 'AND'
|
|
627
|
+
});
|
|
628
|
+
expect(result.tokens[2].type).toBe('term');
|
|
629
|
+
expect(result.tokens[3]).toMatchObject({
|
|
630
|
+
type: 'operator',
|
|
631
|
+
operator: 'OR'
|
|
632
|
+
});
|
|
633
|
+
expect(result.tokens[4].type).toBe('term');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('should handle bare values mixed with key:value terms', () => {
|
|
637
|
+
const result = parseQueryTokens('hello AND status:active');
|
|
638
|
+
|
|
639
|
+
expect(result.tokens).toHaveLength(3);
|
|
640
|
+
expect(result.tokens[0]).toMatchObject({
|
|
641
|
+
type: 'term',
|
|
642
|
+
key: null,
|
|
643
|
+
value: 'hello'
|
|
644
|
+
});
|
|
645
|
+
expect(result.tokens[1]).toMatchObject({
|
|
646
|
+
type: 'operator',
|
|
647
|
+
operator: 'AND'
|
|
648
|
+
});
|
|
649
|
+
expect(result.tokens[2]).toMatchObject({
|
|
650
|
+
type: 'term',
|
|
651
|
+
key: 'status',
|
|
652
|
+
value: 'active'
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should handle complex real-world query', () => {
|
|
657
|
+
const result = parseQueryTokens(
|
|
658
|
+
'status:open AND priority:high OR assigned:me'
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
expect(result.tokens).toHaveLength(5);
|
|
662
|
+
|
|
663
|
+
// Verify order: term, AND, term, OR, term
|
|
664
|
+
expect(result.tokens.map(t => t.type)).toEqual([
|
|
665
|
+
'term',
|
|
666
|
+
'operator',
|
|
667
|
+
'term',
|
|
668
|
+
'operator',
|
|
669
|
+
'term'
|
|
670
|
+
]);
|
|
671
|
+
|
|
672
|
+
const operators = result.tokens
|
|
673
|
+
.filter(
|
|
674
|
+
(t): t is import('./types').IQueryOperatorToken =>
|
|
675
|
+
t.type === 'operator'
|
|
676
|
+
)
|
|
677
|
+
.map(t => t.operator);
|
|
678
|
+
expect(operators).toEqual(['AND', 'OR']);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('should find active token at end of input', () => {
|
|
682
|
+
const input = 'status:done';
|
|
683
|
+
const result = parseQueryTokens(input, input.length);
|
|
684
|
+
|
|
685
|
+
expect(result.activeToken).not.toBeNull();
|
|
686
|
+
expect(result.activeTokenIndex).toBe(0);
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe('edge cases and real-world scenarios', () => {
|
|
691
|
+
it('should handle the example from the issue: status:d', () => {
|
|
692
|
+
const result = parseQueryInput('status:d');
|
|
693
|
+
|
|
694
|
+
expect(result.terms[0]).toMatchObject({
|
|
695
|
+
key: 'status',
|
|
696
|
+
value: 'd'
|
|
697
|
+
});
|
|
698
|
+
expect(extractKeyValue('status:d')).toEqual({
|
|
699
|
+
key: 'status',
|
|
700
|
+
value: 'd'
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('should handle autocomplete scenario: user typing status:do', () => {
|
|
705
|
+
const result = parseQueryInput('status:do');
|
|
706
|
+
|
|
707
|
+
expect(result.terms[0].key).toBe('status');
|
|
708
|
+
expect(result.terms[0].value).toBe('do');
|
|
709
|
+
expect(result.activeTerm?.value).toBe('do');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should handle multiple fields for highlighting', () => {
|
|
713
|
+
const result = parseQueryInput(
|
|
714
|
+
'status:active priority:high assigned:john'
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
expect(result.terms).toHaveLength(3);
|
|
718
|
+
|
|
719
|
+
// Verify each term has position info for highlighting
|
|
720
|
+
result.terms.forEach(term => {
|
|
721
|
+
expect(term.startPosition).toBeGreaterThanOrEqual(0);
|
|
722
|
+
expect(term.endPosition).toBeGreaterThan(term.startPosition);
|
|
723
|
+
expect(term.raw.length).toBeGreaterThan(0);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('should handle field names with dots (nested properties)', () => {
|
|
728
|
+
const result = parseQueryInput('user.name:john');
|
|
729
|
+
|
|
730
|
+
expect(result.terms[0]).toMatchObject({
|
|
731
|
+
key: 'user.name',
|
|
732
|
+
operator: ':',
|
|
733
|
+
value: 'john'
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('should handle field names with underscores', () => {
|
|
738
|
+
const result = parseQueryInput('created_at:today');
|
|
739
|
+
|
|
740
|
+
expect(result.terms[0].key).toBe('created_at');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('should handle field names with hyphens', () => {
|
|
744
|
+
const result = parseQueryInput('last-updated:yesterday');
|
|
745
|
+
|
|
746
|
+
expect(result.terms[0].key).toBe('last-updated');
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('should handle numeric values', () => {
|
|
750
|
+
const result = parseQueryInput('count:42');
|
|
751
|
+
|
|
752
|
+
expect(result.terms[0].value).toBe('42');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('should handle decimal values', () => {
|
|
756
|
+
const result = parseQueryInput('price:19.99');
|
|
757
|
+
|
|
758
|
+
expect(result.terms[0].value).toBe('19.99');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('should handle complex real-world query', () => {
|
|
762
|
+
const result = parseQueryInput(
|
|
763
|
+
'status:open AND (priority:high OR priority:critical) -assigned:nobody'
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
expect(result.terms.length).toBeGreaterThanOrEqual(3);
|
|
767
|
+
expect(result.logicalOperators.length).toBeGreaterThanOrEqual(1);
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
});
|