@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,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Parser for QueryKit
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for parsing partial/in-progress query input
|
|
5
|
+
* from search bars, enabling features like:
|
|
6
|
+
* - Key-value highlighting
|
|
7
|
+
* - Autocomplete suggestions
|
|
8
|
+
* - Real-time validation feedback
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Represents the context of where the cursor is within a query term
|
|
13
|
+
*/
|
|
14
|
+
export type CursorContext = 'key' | 'operator' | 'value' | 'empty' | 'between';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Represents the parsed context of a single query term
|
|
18
|
+
*/
|
|
19
|
+
export interface IQueryInputTerm {
|
|
20
|
+
/**
|
|
21
|
+
* The field/key being typed (e.g., "status" in "status:done")
|
|
22
|
+
* Will be null if only a bare value is being typed
|
|
23
|
+
*/
|
|
24
|
+
key: string | null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The operator being used (e.g., ":", ">", ">=", "<", "<=", "!=")
|
|
28
|
+
* Will be null if no operator has been typed yet
|
|
29
|
+
*/
|
|
30
|
+
operator: string | null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The value being typed (e.g., "done" in "status:done")
|
|
34
|
+
* Will be null if no value has been typed yet
|
|
35
|
+
*/
|
|
36
|
+
value: string | null;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The start position of this term in the original input string
|
|
40
|
+
*/
|
|
41
|
+
startPosition: number;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The end position of this term in the original input string
|
|
45
|
+
*/
|
|
46
|
+
endPosition: number;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The original raw text of this term
|
|
50
|
+
*/
|
|
51
|
+
raw: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Represents the result of parsing query input
|
|
56
|
+
*/
|
|
57
|
+
export interface IQueryInputContext {
|
|
58
|
+
/**
|
|
59
|
+
* All terms found in the input
|
|
60
|
+
*/
|
|
61
|
+
terms: IQueryInputTerm[];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The term where the cursor is currently positioned (if cursorPosition was provided)
|
|
65
|
+
* Will be null if cursor is not within any term
|
|
66
|
+
*/
|
|
67
|
+
activeTerm: IQueryInputTerm | null;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the cursor is within the active term
|
|
71
|
+
*/
|
|
72
|
+
cursorContext: CursorContext;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The original input string
|
|
76
|
+
*/
|
|
77
|
+
input: string;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The cursor position (if provided)
|
|
81
|
+
*/
|
|
82
|
+
cursorPosition: number | null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Logical operators found between terms (AND, OR, NOT)
|
|
86
|
+
*/
|
|
87
|
+
logicalOperators: Array<{
|
|
88
|
+
operator: string;
|
|
89
|
+
position: number;
|
|
90
|
+
}>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Options for parsing query input
|
|
95
|
+
*/
|
|
96
|
+
export interface IQueryInputParserOptions {
|
|
97
|
+
/**
|
|
98
|
+
* Whether to treat the input as case-insensitive for keys
|
|
99
|
+
* @default false
|
|
100
|
+
*/
|
|
101
|
+
caseInsensitiveKeys?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Regular expression patterns for parsing
|
|
106
|
+
*/
|
|
107
|
+
const PATTERNS = {
|
|
108
|
+
// Matches logical operators (AND, OR, NOT) with word boundaries
|
|
109
|
+
LOGICAL_OPERATOR: /\b(AND|OR|NOT)\b/gi,
|
|
110
|
+
|
|
111
|
+
// Matches comparison operators: :, :>, :>=, :<, :<=, :!=, :=
|
|
112
|
+
COMPARISON_OPERATOR: /^(:>=|:<=|:!=|:>|:<|:=|:)/,
|
|
113
|
+
|
|
114
|
+
// Matches a quoted string (single or double quotes)
|
|
115
|
+
QUOTED_STRING: /^(["'])(?:\\.|[^\\])*?\1/,
|
|
116
|
+
|
|
117
|
+
// Matches word characters and some special chars (for keys/values)
|
|
118
|
+
WORD_CHARS: /^[a-zA-Z0-9_.-]+/,
|
|
119
|
+
|
|
120
|
+
// Matches whitespace
|
|
121
|
+
WHITESPACE: /^\s+/,
|
|
122
|
+
|
|
123
|
+
// Matches parentheses
|
|
124
|
+
PAREN_OPEN: /^\(/,
|
|
125
|
+
PAREN_CLOSE: /^\)/,
|
|
126
|
+
|
|
127
|
+
// Matches negation prefix
|
|
128
|
+
NEGATION: /^-/
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse a single term (key:value, key:>value, or just value)
|
|
133
|
+
*/
|
|
134
|
+
function parseTerm(
|
|
135
|
+
input: string,
|
|
136
|
+
startPosition: number
|
|
137
|
+
): IQueryInputTerm | null {
|
|
138
|
+
if (!input || input.length === 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let key: string | null = null;
|
|
143
|
+
let operator: string | null = null;
|
|
144
|
+
let value: string | null = null;
|
|
145
|
+
let remaining = input;
|
|
146
|
+
let currentPos = 0;
|
|
147
|
+
|
|
148
|
+
// Handle negation prefix (e.g., -status:active)
|
|
149
|
+
let hasNegation = false;
|
|
150
|
+
const negationMatch = remaining.match(PATTERNS.NEGATION);
|
|
151
|
+
if (negationMatch) {
|
|
152
|
+
hasNegation = true;
|
|
153
|
+
remaining = remaining.substring(1);
|
|
154
|
+
currentPos += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try to match a key (word before operator)
|
|
158
|
+
const keyMatch = remaining.match(PATTERNS.WORD_CHARS);
|
|
159
|
+
if (keyMatch) {
|
|
160
|
+
const potentialKey = keyMatch[0];
|
|
161
|
+
const afterKey = remaining.substring(potentialKey.length);
|
|
162
|
+
|
|
163
|
+
// Check if followed by an operator
|
|
164
|
+
const operatorMatch = afterKey.match(PATTERNS.COMPARISON_OPERATOR);
|
|
165
|
+
if (operatorMatch) {
|
|
166
|
+
// This is a key:value pattern
|
|
167
|
+
key = (hasNegation ? '-' : '') + potentialKey;
|
|
168
|
+
operator = operatorMatch[0];
|
|
169
|
+
currentPos += potentialKey.length + operator.length;
|
|
170
|
+
remaining = afterKey.substring(operator.length);
|
|
171
|
+
|
|
172
|
+
// Try to match the value
|
|
173
|
+
// First check for quoted string
|
|
174
|
+
const quotedMatch = remaining.match(PATTERNS.QUOTED_STRING);
|
|
175
|
+
if (quotedMatch) {
|
|
176
|
+
value = quotedMatch[0];
|
|
177
|
+
currentPos += value.length;
|
|
178
|
+
} else {
|
|
179
|
+
// Match unquoted value (until whitespace or logical operator)
|
|
180
|
+
const valueMatch = remaining.match(/^[^\s()]+/);
|
|
181
|
+
if (valueMatch) {
|
|
182
|
+
value = valueMatch[0];
|
|
183
|
+
currentPos += value.length;
|
|
184
|
+
} else {
|
|
185
|
+
// Operator present but no value yet
|
|
186
|
+
value = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
// No operator - this is a bare value (or incomplete key)
|
|
191
|
+
// Treat the whole thing as a potential key that could become key:value
|
|
192
|
+
// or as a bare value for full-text search
|
|
193
|
+
key = null;
|
|
194
|
+
operator = null;
|
|
195
|
+
value = (hasNegation ? '-' : '') + potentialKey;
|
|
196
|
+
currentPos += potentialKey.length;
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// Check for quoted string as bare value
|
|
200
|
+
const quotedMatch = remaining.match(PATTERNS.QUOTED_STRING);
|
|
201
|
+
if (quotedMatch) {
|
|
202
|
+
key = null;
|
|
203
|
+
operator = null;
|
|
204
|
+
value = (hasNegation ? '-' : '') + quotedMatch[0];
|
|
205
|
+
currentPos += quotedMatch[0].length;
|
|
206
|
+
} else {
|
|
207
|
+
// No recognizable token
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
key,
|
|
214
|
+
operator,
|
|
215
|
+
value,
|
|
216
|
+
startPosition,
|
|
217
|
+
endPosition: startPosition + currentPos,
|
|
218
|
+
raw: input.substring(0, currentPos)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Tokenize the input string into terms and logical operators
|
|
224
|
+
*/
|
|
225
|
+
function tokenize(input: string): {
|
|
226
|
+
terms: IQueryInputTerm[];
|
|
227
|
+
logicalOperators: Array<{ operator: string; position: number }>;
|
|
228
|
+
} {
|
|
229
|
+
const terms: IQueryInputTerm[] = [];
|
|
230
|
+
const logicalOperators: Array<{ operator: string; position: number }> = [];
|
|
231
|
+
|
|
232
|
+
let remaining = input;
|
|
233
|
+
let position = 0;
|
|
234
|
+
|
|
235
|
+
while (remaining.length > 0) {
|
|
236
|
+
// Skip whitespace
|
|
237
|
+
const wsMatch = remaining.match(PATTERNS.WHITESPACE);
|
|
238
|
+
if (wsMatch) {
|
|
239
|
+
position += wsMatch[0].length;
|
|
240
|
+
remaining = remaining.substring(wsMatch[0].length);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Skip parentheses (they're structural, not terms)
|
|
245
|
+
if (remaining.match(PATTERNS.PAREN_OPEN)) {
|
|
246
|
+
position += 1;
|
|
247
|
+
remaining = remaining.substring(1);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (remaining.match(PATTERNS.PAREN_CLOSE)) {
|
|
251
|
+
position += 1;
|
|
252
|
+
remaining = remaining.substring(1);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check for logical operators
|
|
257
|
+
const logicalMatch = remaining.match(/^(AND|OR|NOT)\b/i);
|
|
258
|
+
if (logicalMatch) {
|
|
259
|
+
logicalOperators.push({
|
|
260
|
+
operator: logicalMatch[0].toUpperCase(),
|
|
261
|
+
position
|
|
262
|
+
});
|
|
263
|
+
position += logicalMatch[0].length;
|
|
264
|
+
remaining = remaining.substring(logicalMatch[0].length);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Try to parse a term
|
|
269
|
+
const term = parseTerm(remaining, position);
|
|
270
|
+
if (term) {
|
|
271
|
+
terms.push(term);
|
|
272
|
+
position = term.endPosition;
|
|
273
|
+
remaining = input.substring(position);
|
|
274
|
+
} else {
|
|
275
|
+
// Skip unknown character
|
|
276
|
+
position += 1;
|
|
277
|
+
remaining = remaining.substring(1);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { terms, logicalOperators };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Determine the cursor context based on position within a term
|
|
286
|
+
*/
|
|
287
|
+
function determineCursorContext(
|
|
288
|
+
term: IQueryInputTerm,
|
|
289
|
+
cursorPosition: number
|
|
290
|
+
): CursorContext {
|
|
291
|
+
const relativePos = cursorPosition - term.startPosition;
|
|
292
|
+
|
|
293
|
+
if (term.key !== null && term.operator !== null) {
|
|
294
|
+
// Key and operator are present
|
|
295
|
+
const keyLength = term.key.length;
|
|
296
|
+
const operatorLength = term.operator.length;
|
|
297
|
+
const keyPlusOperatorLength = keyLength + operatorLength;
|
|
298
|
+
|
|
299
|
+
if (relativePos < keyLength) {
|
|
300
|
+
return 'key';
|
|
301
|
+
} else if (relativePos < keyPlusOperatorLength) {
|
|
302
|
+
return 'operator';
|
|
303
|
+
} else {
|
|
304
|
+
// Cursor is at or after the operator - this is the value position
|
|
305
|
+
// Even if value is null (user hasn't typed anything yet),
|
|
306
|
+
// they're positioned to type a value
|
|
307
|
+
return 'value';
|
|
308
|
+
}
|
|
309
|
+
} else if (term.key !== null) {
|
|
310
|
+
// Only key present (incomplete term)
|
|
311
|
+
return 'key';
|
|
312
|
+
} else if (term.value !== null) {
|
|
313
|
+
// Only value present (bare value)
|
|
314
|
+
return 'value';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return 'empty';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Find the term that contains the cursor position
|
|
322
|
+
*/
|
|
323
|
+
function findActiveTermAndContext(
|
|
324
|
+
terms: IQueryInputTerm[],
|
|
325
|
+
cursorPosition: number | null,
|
|
326
|
+
inputLength: number
|
|
327
|
+
): { activeTerm: IQueryInputTerm | null; cursorContext: CursorContext } {
|
|
328
|
+
if (cursorPosition === null) {
|
|
329
|
+
// If no cursor position provided, use the last term
|
|
330
|
+
if (terms.length > 0) {
|
|
331
|
+
const lastTerm = terms[terms.length - 1];
|
|
332
|
+
return {
|
|
333
|
+
activeTerm: lastTerm,
|
|
334
|
+
cursorContext: determineCursorContext(lastTerm, lastTerm.endPosition)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return { activeTerm: null, cursorContext: 'empty' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Find term containing cursor
|
|
341
|
+
for (const term of terms) {
|
|
342
|
+
if (
|
|
343
|
+
cursorPosition >= term.startPosition &&
|
|
344
|
+
cursorPosition <= term.endPosition
|
|
345
|
+
) {
|
|
346
|
+
return {
|
|
347
|
+
activeTerm: term,
|
|
348
|
+
cursorContext: determineCursorContext(term, cursorPosition)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Cursor is between terms or at the end
|
|
354
|
+
if (cursorPosition >= inputLength && terms.length > 0) {
|
|
355
|
+
// Cursor at the end - check if right after a term
|
|
356
|
+
const lastTerm = terms[terms.length - 1];
|
|
357
|
+
if (cursorPosition === lastTerm.endPosition) {
|
|
358
|
+
return {
|
|
359
|
+
activeTerm: lastTerm,
|
|
360
|
+
cursorContext: determineCursorContext(lastTerm, cursorPosition)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return { activeTerm: null, cursorContext: 'between' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Parse query input to extract structured information about the current search state.
|
|
370
|
+
*
|
|
371
|
+
* This function is designed for real-time parsing of user input in a search bar,
|
|
372
|
+
* allowing developers to:
|
|
373
|
+
* - Highlight keys and values differently
|
|
374
|
+
* - Provide autocomplete suggestions based on context
|
|
375
|
+
* - Validate input as the user types
|
|
376
|
+
*
|
|
377
|
+
* @param input The current search input string
|
|
378
|
+
* @param cursorPosition Optional cursor position to determine the active term
|
|
379
|
+
* @param options Optional parsing options
|
|
380
|
+
* @returns Structured information about the query input
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```typescript
|
|
384
|
+
* // User is typing "status:d" (intending to type "status:done")
|
|
385
|
+
* const result = parseQueryInput('status:d');
|
|
386
|
+
* // result.terms[0] = { key: 'status', operator: ':', value: 'd', ... }
|
|
387
|
+
* // result.activeTerm = { key: 'status', operator: ':', value: 'd', ... }
|
|
388
|
+
* // result.cursorContext = 'value'
|
|
389
|
+
*
|
|
390
|
+
* // User is typing "priority:>2 status:"
|
|
391
|
+
* const result = parseQueryInput('priority:>2 status:', 19);
|
|
392
|
+
* // result.terms[0] = { key: 'priority', operator: ':>', value: '2', ... }
|
|
393
|
+
* // result.terms[1] = { key: 'status', operator: ':', value: null, ... }
|
|
394
|
+
* // result.activeTerm = result.terms[1] (cursor is at position 19)
|
|
395
|
+
* // result.cursorContext = 'value' (waiting for value input)
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
export function parseQueryInput(
|
|
399
|
+
input: string,
|
|
400
|
+
cursorPosition?: number,
|
|
401
|
+
options?: IQueryInputParserOptions
|
|
402
|
+
): IQueryInputContext {
|
|
403
|
+
// Handle empty input
|
|
404
|
+
if (!input || input.trim().length === 0) {
|
|
405
|
+
return {
|
|
406
|
+
terms: [],
|
|
407
|
+
activeTerm: null,
|
|
408
|
+
cursorContext: 'empty',
|
|
409
|
+
input,
|
|
410
|
+
cursorPosition: cursorPosition ?? null,
|
|
411
|
+
logicalOperators: []
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Tokenize the input
|
|
416
|
+
const { terms, logicalOperators } = tokenize(input);
|
|
417
|
+
|
|
418
|
+
// Apply case-insensitivity to keys if requested
|
|
419
|
+
if (options?.caseInsensitiveKeys) {
|
|
420
|
+
for (const term of terms) {
|
|
421
|
+
if (term.key !== null) {
|
|
422
|
+
term.key = term.key.toLowerCase();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Find active term and cursor context
|
|
428
|
+
const { activeTerm, cursorContext } = findActiveTermAndContext(
|
|
429
|
+
terms,
|
|
430
|
+
cursorPosition ?? null,
|
|
431
|
+
input.length
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
terms,
|
|
436
|
+
activeTerm,
|
|
437
|
+
cursorContext,
|
|
438
|
+
input,
|
|
439
|
+
cursorPosition: cursorPosition ?? null,
|
|
440
|
+
logicalOperators
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Get the term at a specific cursor position.
|
|
446
|
+
* Convenience function for quick lookups.
|
|
447
|
+
*
|
|
448
|
+
* @param input The query input string
|
|
449
|
+
* @param cursorPosition The cursor position
|
|
450
|
+
* @returns The term at the cursor position, or null if none
|
|
451
|
+
*/
|
|
452
|
+
export function getTermAtPosition(
|
|
453
|
+
input: string,
|
|
454
|
+
cursorPosition: number
|
|
455
|
+
): IQueryInputTerm | null {
|
|
456
|
+
const result = parseQueryInput(input, cursorPosition);
|
|
457
|
+
return result.activeTerm;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Check if the input appears to be a complete, valid query expression.
|
|
462
|
+
* This is a lightweight check - it doesn't guarantee the query will parse successfully.
|
|
463
|
+
*
|
|
464
|
+
* @param input The query input string
|
|
465
|
+
* @returns true if the input appears complete, false if it looks incomplete
|
|
466
|
+
*/
|
|
467
|
+
export function isInputComplete(input: string): boolean {
|
|
468
|
+
if (!input || input.trim().length === 0) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const result = parseQueryInput(input);
|
|
473
|
+
|
|
474
|
+
// Check if any term is incomplete
|
|
475
|
+
for (const term of result.terms) {
|
|
476
|
+
// A key:value term is incomplete if it has an operator but no value
|
|
477
|
+
if (term.key !== null && term.operator !== null && term.value === null) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Check if the input ends with a logical operator
|
|
483
|
+
const trimmed = input.trim();
|
|
484
|
+
if (/\b(AND|OR|NOT)\s*$/i.test(trimmed)) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Check if there's an unclosed quote
|
|
489
|
+
const singleQuotes = (input.match(/'/g) || []).length;
|
|
490
|
+
const doubleQuotes = (input.match(/"/g) || []).length;
|
|
491
|
+
if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Check for unclosed parentheses
|
|
496
|
+
const openParens = (input.match(/\(/g) || []).length;
|
|
497
|
+
const closeParens = (input.match(/\)/g) || []).length;
|
|
498
|
+
if (openParens !== closeParens) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Extract just the key and value from a simple input.
|
|
507
|
+
* Convenience function for the most common use case.
|
|
508
|
+
*
|
|
509
|
+
* @param input The query input string (e.g., "status:done")
|
|
510
|
+
* @returns Object with key and value, or null if not a key:value pattern
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* ```typescript
|
|
514
|
+
* extractKeyValue('status:done');
|
|
515
|
+
* // { key: 'status', value: 'done' }
|
|
516
|
+
*
|
|
517
|
+
* extractKeyValue('status:');
|
|
518
|
+
* // { key: 'status', value: null }
|
|
519
|
+
*
|
|
520
|
+
* extractKeyValue('hello');
|
|
521
|
+
* // null (no key:value pattern)
|
|
522
|
+
* ```
|
|
523
|
+
*/
|
|
524
|
+
export function extractKeyValue(
|
|
525
|
+
input: string
|
|
526
|
+
): { key: string; value: string | null } | null {
|
|
527
|
+
const result = parseQueryInput(input.trim());
|
|
528
|
+
|
|
529
|
+
if (result.terms.length === 0) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const term = result.terms[0];
|
|
534
|
+
|
|
535
|
+
if (term.key === null) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
key: term.key,
|
|
541
|
+
value: term.value
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Import token types from types.ts (canonical location)
|
|
546
|
+
import type { IQueryToken } from './types';
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A token in the query sequence - either a term or a logical operator
|
|
550
|
+
* This is an alias for IQueryToken from types.ts
|
|
551
|
+
*/
|
|
552
|
+
export type QueryToken = IQueryToken;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Result of parsing query input into an interleaved token sequence
|
|
556
|
+
*/
|
|
557
|
+
export interface IQueryTokenSequence {
|
|
558
|
+
/**
|
|
559
|
+
* Ordered sequence of tokens (terms and operators interleaved)
|
|
560
|
+
*/
|
|
561
|
+
tokens: QueryToken[];
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The original input string
|
|
565
|
+
*/
|
|
566
|
+
input: string;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The token where the cursor is currently positioned (if cursorPosition was provided)
|
|
570
|
+
*/
|
|
571
|
+
activeToken: QueryToken | null;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Index of the active token in the tokens array (-1 if none)
|
|
575
|
+
*/
|
|
576
|
+
activeTokenIndex: number;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Parse query input into an interleaved sequence of terms and operators.
|
|
581
|
+
*
|
|
582
|
+
* This provides a flat, ordered representation ideal for:
|
|
583
|
+
* - Rendering query tokens as UI chips/tags
|
|
584
|
+
* - Building visual query builders
|
|
585
|
+
* - Syntax highlighting with proper ordering
|
|
586
|
+
*
|
|
587
|
+
* @param input The query input string
|
|
588
|
+
* @param cursorPosition Optional cursor position to identify active token
|
|
589
|
+
* @returns Ordered sequence of term and operator tokens
|
|
590
|
+
*
|
|
591
|
+
* @example
|
|
592
|
+
* ```typescript
|
|
593
|
+
* const result = parseQueryTokens('status:done AND priority:high');
|
|
594
|
+
* // result.tokens = [
|
|
595
|
+
* // { type: 'term', key: 'status', value: 'done', ... },
|
|
596
|
+
* // { type: 'operator', operator: 'AND', ... },
|
|
597
|
+
* // { type: 'term', key: 'priority', value: 'high', ... }
|
|
598
|
+
* // ]
|
|
599
|
+
*
|
|
600
|
+
* // For incomplete input like 'status:d'
|
|
601
|
+
* const result = parseQueryTokens('status:d');
|
|
602
|
+
* // result.tokens = [
|
|
603
|
+
* // { type: 'term', key: 'status', value: 'd', ... }
|
|
604
|
+
* // ]
|
|
605
|
+
* ```
|
|
606
|
+
*/
|
|
607
|
+
export function parseQueryTokens(
|
|
608
|
+
input: string,
|
|
609
|
+
cursorPosition?: number
|
|
610
|
+
): IQueryTokenSequence {
|
|
611
|
+
if (!input || input.trim().length === 0) {
|
|
612
|
+
return {
|
|
613
|
+
tokens: [],
|
|
614
|
+
input,
|
|
615
|
+
activeToken: null,
|
|
616
|
+
activeTokenIndex: -1
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const context = parseQueryInput(input, cursorPosition);
|
|
621
|
+
|
|
622
|
+
// Build a combined list of all tokens with their positions
|
|
623
|
+
const allTokens: Array<{
|
|
624
|
+
token: QueryToken;
|
|
625
|
+
position: number;
|
|
626
|
+
}> = [];
|
|
627
|
+
|
|
628
|
+
// Add terms
|
|
629
|
+
for (const term of context.terms) {
|
|
630
|
+
allTokens.push({
|
|
631
|
+
position: term.startPosition,
|
|
632
|
+
token: {
|
|
633
|
+
type: 'term',
|
|
634
|
+
key: term.key,
|
|
635
|
+
operator: term.operator,
|
|
636
|
+
value: term.value,
|
|
637
|
+
startPosition: term.startPosition,
|
|
638
|
+
endPosition: term.endPosition,
|
|
639
|
+
raw: term.raw
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Add operators
|
|
645
|
+
for (const op of context.logicalOperators) {
|
|
646
|
+
const opLength = op.operator.length;
|
|
647
|
+
allTokens.push({
|
|
648
|
+
position: op.position,
|
|
649
|
+
token: {
|
|
650
|
+
type: 'operator',
|
|
651
|
+
operator: op.operator as 'AND' | 'OR' | 'NOT',
|
|
652
|
+
startPosition: op.position,
|
|
653
|
+
endPosition: op.position + opLength,
|
|
654
|
+
raw: op.operator
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Sort by position to get the interleaved order
|
|
660
|
+
allTokens.sort((a, b) => a.position - b.position);
|
|
661
|
+
|
|
662
|
+
const tokens = allTokens.map(t => t.token);
|
|
663
|
+
|
|
664
|
+
// Find active token based on cursor position
|
|
665
|
+
let activeToken: QueryToken | null = null;
|
|
666
|
+
let activeTokenIndex = -1;
|
|
667
|
+
|
|
668
|
+
if (cursorPosition !== undefined) {
|
|
669
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
670
|
+
const token = tokens[i];
|
|
671
|
+
if (
|
|
672
|
+
cursorPosition >= token.startPosition &&
|
|
673
|
+
cursorPosition <= token.endPosition
|
|
674
|
+
) {
|
|
675
|
+
activeToken = token;
|
|
676
|
+
activeTokenIndex = i;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// If cursor is at the end and right after the last token
|
|
682
|
+
if (activeToken === null && tokens.length > 0) {
|
|
683
|
+
const lastToken = tokens[tokens.length - 1];
|
|
684
|
+
if (cursorPosition === lastToken.endPosition) {
|
|
685
|
+
activeToken = lastToken;
|
|
686
|
+
activeTokenIndex = tokens.length - 1;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
tokens,
|
|
693
|
+
input,
|
|
694
|
+
activeToken,
|
|
695
|
+
activeTokenIndex
|
|
696
|
+
};
|
|
697
|
+
}
|