@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.
@@ -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
+ }