@gblikas/querykit 0.2.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 +114 -1
- package/dist/parser/parser.js +716 -0
- 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.ts +872 -0
- 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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Suggestions & Recovery features of parseWithContext:
|
|
3
|
+
* - Autocomplete suggestions
|
|
4
|
+
* - Error recovery hints
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { QueryParser } from './parser';
|
|
8
|
+
import { IFieldSchema } from './types';
|
|
9
|
+
|
|
10
|
+
describe('QueryParser.parseWithContext - Suggestions & Recovery', () => {
|
|
11
|
+
let parser: QueryParser;
|
|
12
|
+
|
|
13
|
+
const testSchema: Record<string, IFieldSchema> = {
|
|
14
|
+
status: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
allowedValues: ['todo', 'doing', 'done'],
|
|
17
|
+
description: 'Task status'
|
|
18
|
+
},
|
|
19
|
+
priority: { type: 'number', description: 'Priority level' },
|
|
20
|
+
name: { type: 'string', description: 'Task name' },
|
|
21
|
+
createdAt: { type: 'date', description: 'Creation date' },
|
|
22
|
+
isActive: { type: 'boolean', description: 'Whether active' },
|
|
23
|
+
assignee: { type: 'string', description: 'Assigned user' }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
parser = new QueryParser();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('Autocomplete Suggestions', () => {
|
|
31
|
+
describe('empty context', () => {
|
|
32
|
+
it('should suggest fields for empty input', () => {
|
|
33
|
+
const result = parser.parseWithContext('', {
|
|
34
|
+
cursorPosition: 0,
|
|
35
|
+
schema: testSchema
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result.suggestions).toBeDefined();
|
|
39
|
+
expect(result.suggestions?.context).toBe('empty');
|
|
40
|
+
expect(result.suggestions?.fields?.length).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should include all schema fields in suggestions', () => {
|
|
44
|
+
const result = parser.parseWithContext('', {
|
|
45
|
+
cursorPosition: 0,
|
|
46
|
+
schema: testSchema
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const suggestedFields = result.suggestions?.fields?.map(f => f.field);
|
|
50
|
+
expect(suggestedFields).toContain('status');
|
|
51
|
+
expect(suggestedFields).toContain('priority');
|
|
52
|
+
expect(suggestedFields).toContain('name');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should include field metadata in suggestions', () => {
|
|
56
|
+
const result = parser.parseWithContext('', {
|
|
57
|
+
cursorPosition: 0,
|
|
58
|
+
schema: testSchema
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const statusSuggestion = result.suggestions?.fields?.find(
|
|
62
|
+
f => f.field === 'status'
|
|
63
|
+
);
|
|
64
|
+
expect(statusSuggestion?.type).toBe('string');
|
|
65
|
+
expect(statusSuggestion?.description).toBe('Task status');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('field context', () => {
|
|
70
|
+
it('should suggest matching fields when typing field name', () => {
|
|
71
|
+
const result = parser.parseWithContext('sta', {
|
|
72
|
+
cursorPosition: 3,
|
|
73
|
+
schema: testSchema
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.suggestions?.context).toBe('field');
|
|
77
|
+
expect(result.suggestions?.fields?.[0].field).toBe('status');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should rank prefix matches higher', () => {
|
|
81
|
+
const result = parser.parseWithContext('pri', {
|
|
82
|
+
cursorPosition: 3,
|
|
83
|
+
schema: testSchema
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const fields = result.suggestions?.fields || [];
|
|
87
|
+
expect(fields[0].field).toBe('priority');
|
|
88
|
+
expect(fields[0].score).toBeGreaterThan(50);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should suggest similar fields for typos', () => {
|
|
92
|
+
const result = parser.parseWithContext('statis', {
|
|
93
|
+
cursorPosition: 6,
|
|
94
|
+
schema: testSchema
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const fields = result.suggestions?.fields || [];
|
|
98
|
+
const statusSuggestion = fields.find(f => f.field === 'status');
|
|
99
|
+
expect(statusSuggestion).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('value context', () => {
|
|
104
|
+
it('should suggest allowed values when in value position', () => {
|
|
105
|
+
const result = parser.parseWithContext('status:d', {
|
|
106
|
+
cursorPosition: 8,
|
|
107
|
+
schema: testSchema
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result.suggestions?.context).toBe('value');
|
|
111
|
+
expect(result.suggestions?.currentField).toBe('status');
|
|
112
|
+
expect(result.suggestions?.values).toBeDefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should filter values based on partial input', () => {
|
|
116
|
+
const result = parser.parseWithContext('status:do', {
|
|
117
|
+
cursorPosition: 9,
|
|
118
|
+
schema: testSchema
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const values = result.suggestions?.values || [];
|
|
122
|
+
// "doing" and "done" both start with "do"
|
|
123
|
+
expect(values.length).toBeGreaterThanOrEqual(2);
|
|
124
|
+
expect(values.some(v => v.value === 'doing')).toBe(true);
|
|
125
|
+
expect(values.some(v => v.value === 'done')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should suggest boolean values for boolean fields', () => {
|
|
129
|
+
const result = parser.parseWithContext('isActive:', {
|
|
130
|
+
cursorPosition: 9,
|
|
131
|
+
schema: testSchema
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.suggestions?.context).toBe('value');
|
|
135
|
+
const values = result.suggestions?.values || [];
|
|
136
|
+
expect(values.some(v => v.value === true)).toBe(true);
|
|
137
|
+
expect(values.some(v => v.value === false)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('operator context', () => {
|
|
142
|
+
it('should suggest operators', () => {
|
|
143
|
+
const result = parser.parseWithContext('priority:', {
|
|
144
|
+
cursorPosition: 9,
|
|
145
|
+
schema: testSchema
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// After field: we're in value context, but let's test operator suggestions
|
|
149
|
+
// by looking at the operators property
|
|
150
|
+
expect(result.suggestions).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should mark comparison operators as applicable for number fields', () => {
|
|
154
|
+
const result = parser.parseWithContext('priority', {
|
|
155
|
+
cursorPosition: 8,
|
|
156
|
+
schema: testSchema
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// When we're at the end of a field name, we might suggest operators
|
|
160
|
+
expect(result.suggestions).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('logical operator context', () => {
|
|
165
|
+
it('should suggest logical operators between terms', () => {
|
|
166
|
+
const result = parser.parseWithContext('status:done ', {
|
|
167
|
+
cursorPosition: 12,
|
|
168
|
+
schema: testSchema
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.suggestions?.logicalOperators).toContain('AND');
|
|
172
|
+
expect(result.suggestions?.logicalOperators).toContain('OR');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should suggest when cursor is in a logical operator', () => {
|
|
176
|
+
const result = parser.parseWithContext(
|
|
177
|
+
'status:done AND priority:high',
|
|
178
|
+
{
|
|
179
|
+
cursorPosition: 13, // in "AND"
|
|
180
|
+
schema: testSchema
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(result.suggestions?.context).toBe('logical_operator');
|
|
185
|
+
expect(result.suggestions?.logicalOperators).toContain('AND');
|
|
186
|
+
expect(result.suggestions?.logicalOperators).toContain('OR');
|
|
187
|
+
expect(result.suggestions?.logicalOperators).toContain('NOT');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('without schema', () => {
|
|
192
|
+
it('should return suggestions without field details when no schema', () => {
|
|
193
|
+
const result = parser.parseWithContext('sta', {
|
|
194
|
+
cursorPosition: 3
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(result.suggestions).toBeDefined();
|
|
198
|
+
expect(result.suggestions?.context).toBe('field');
|
|
199
|
+
expect(result.suggestions?.fields).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('without cursor position', () => {
|
|
204
|
+
it('should not include suggestions when no cursor position', () => {
|
|
205
|
+
const result = parser.parseWithContext('status:done', {
|
|
206
|
+
schema: testSchema
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.suggestions).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('Error Recovery', () => {
|
|
215
|
+
describe('unclosed quotes', () => {
|
|
216
|
+
it('should detect unclosed double quote', () => {
|
|
217
|
+
const result = parser.parseWithContext('name:"John');
|
|
218
|
+
|
|
219
|
+
expect(result.success).toBe(false);
|
|
220
|
+
expect(result.recovery).toBeDefined();
|
|
221
|
+
expect(result.recovery?.issue).toBe('unclosed_quote');
|
|
222
|
+
expect(result.recovery?.autofix).toBe('name:"John"');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should detect unclosed single quote', () => {
|
|
226
|
+
const result = parser.parseWithContext("name:'John");
|
|
227
|
+
|
|
228
|
+
expect(result.success).toBe(false);
|
|
229
|
+
expect(result.recovery?.issue).toBe('unclosed_quote');
|
|
230
|
+
expect(result.recovery?.autofix).toBe("name:'John'");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should include position of unclosed quote', () => {
|
|
234
|
+
const result = parser.parseWithContext('name:"John');
|
|
235
|
+
|
|
236
|
+
expect(result.recovery?.position).toBe(5); // position of "
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('unclosed parentheses', () => {
|
|
241
|
+
it('should detect missing closing parenthesis', () => {
|
|
242
|
+
const result = parser.parseWithContext('(status:done');
|
|
243
|
+
|
|
244
|
+
expect(result.success).toBe(false);
|
|
245
|
+
expect(result.recovery?.issue).toBe('unclosed_parenthesis');
|
|
246
|
+
expect(result.recovery?.autofix).toBe('(status:done)');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should detect multiple missing closing parentheses', () => {
|
|
250
|
+
const result = parser.parseWithContext('((status:done');
|
|
251
|
+
|
|
252
|
+
expect(result.recovery?.issue).toBe('unclosed_parenthesis');
|
|
253
|
+
expect(result.recovery?.autofix).toBe('((status:done))');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should detect extra closing parenthesis', () => {
|
|
257
|
+
const result = parser.parseWithContext('status:done))');
|
|
258
|
+
|
|
259
|
+
expect(result.recovery?.issue).toBe('unclosed_parenthesis');
|
|
260
|
+
expect(result.recovery?.message).toContain('Extra');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('trailing operator', () => {
|
|
265
|
+
it('should detect trailing AND', () => {
|
|
266
|
+
const result = parser.parseWithContext('status:done AND');
|
|
267
|
+
|
|
268
|
+
expect(result.success).toBe(false);
|
|
269
|
+
expect(result.recovery?.issue).toBe('trailing_operator');
|
|
270
|
+
expect(result.recovery?.autofix).toBe('status:done');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should detect trailing OR', () => {
|
|
274
|
+
const result = parser.parseWithContext('status:done OR');
|
|
275
|
+
|
|
276
|
+
expect(result.recovery?.issue).toBe('trailing_operator');
|
|
277
|
+
expect(result.recovery?.autofix).toBe('status:done');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should detect trailing NOT', () => {
|
|
281
|
+
const result = parser.parseWithContext('status:done NOT');
|
|
282
|
+
|
|
283
|
+
expect(result.recovery?.issue).toBe('trailing_operator');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should handle trailing operator with whitespace', () => {
|
|
287
|
+
const result = parser.parseWithContext('status:done AND ');
|
|
288
|
+
|
|
289
|
+
expect(result.recovery?.issue).toBe('trailing_operator');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('missing value', () => {
|
|
294
|
+
it('should detect missing value after colon', () => {
|
|
295
|
+
const result = parser.parseWithContext('status:');
|
|
296
|
+
|
|
297
|
+
expect(result.success).toBe(false);
|
|
298
|
+
expect(result.recovery?.issue).toBe('missing_value');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('syntax error', () => {
|
|
303
|
+
it('should provide generic recovery for other syntax errors', () => {
|
|
304
|
+
const result = parser.parseWithContext('status:AND'); // AND is a keyword
|
|
305
|
+
|
|
306
|
+
expect(result.success).toBe(false);
|
|
307
|
+
expect(result.recovery).toBeDefined();
|
|
308
|
+
// This might be detected as syntax_error or another issue
|
|
309
|
+
expect(result.recovery?.issue).toBeDefined();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('successful parse', () => {
|
|
314
|
+
it('should not include recovery when parsing succeeds', () => {
|
|
315
|
+
const result = parser.parseWithContext('status:done');
|
|
316
|
+
|
|
317
|
+
expect(result.success).toBe(true);
|
|
318
|
+
expect(result.recovery).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('Integration with Other Features', () => {
|
|
324
|
+
it('should include all features together', () => {
|
|
325
|
+
const result = parser.parseWithContext('status:do', {
|
|
326
|
+
cursorPosition: 9,
|
|
327
|
+
schema: testSchema,
|
|
328
|
+
securityOptions: {
|
|
329
|
+
maxClauseCount: 10
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Core Parsing
|
|
334
|
+
expect(result.tokens).toBeDefined();
|
|
335
|
+
expect(result.structure).toBeDefined();
|
|
336
|
+
|
|
337
|
+
// Validation & Security
|
|
338
|
+
expect(result.fieldValidation).toBeDefined();
|
|
339
|
+
expect(result.security).toBeDefined();
|
|
340
|
+
|
|
341
|
+
// Suggestions & Recovery
|
|
342
|
+
expect(result.suggestions).toBeDefined();
|
|
343
|
+
expect(result.suggestions?.values?.some(v => v.value === 'done')).toBe(
|
|
344
|
+
true
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should provide recovery and suggestions for failed parse', () => {
|
|
349
|
+
const result = parser.parseWithContext('status:', {
|
|
350
|
+
cursorPosition: 7,
|
|
351
|
+
schema: testSchema
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(result.success).toBe(false);
|
|
355
|
+
expect(result.recovery).toBeDefined();
|
|
356
|
+
expect(result.suggestions).toBeDefined();
|
|
357
|
+
expect(result.suggestions?.context).toBe('value');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|