@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.
@@ -0,0 +1,493 @@
1
+ "use strict";
2
+ /**
3
+ * Input Parser for QueryKit
4
+ *
5
+ * This module provides utilities for parsing partial/in-progress query input
6
+ * from search bars, enabling features like:
7
+ * - Key-value highlighting
8
+ * - Autocomplete suggestions
9
+ * - Real-time validation feedback
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.parseQueryInput = parseQueryInput;
13
+ exports.getTermAtPosition = getTermAtPosition;
14
+ exports.isInputComplete = isInputComplete;
15
+ exports.extractKeyValue = extractKeyValue;
16
+ exports.parseQueryTokens = parseQueryTokens;
17
+ /**
18
+ * Regular expression patterns for parsing
19
+ */
20
+ const PATTERNS = {
21
+ // Matches logical operators (AND, OR, NOT) with word boundaries
22
+ LOGICAL_OPERATOR: /\b(AND|OR|NOT)\b/gi,
23
+ // Matches comparison operators: :, :>, :>=, :<, :<=, :!=, :=
24
+ COMPARISON_OPERATOR: /^(:>=|:<=|:!=|:>|:<|:=|:)/,
25
+ // Matches a quoted string (single or double quotes)
26
+ QUOTED_STRING: /^(["'])(?:\\.|[^\\])*?\1/,
27
+ // Matches word characters and some special chars (for keys/values)
28
+ WORD_CHARS: /^[a-zA-Z0-9_.-]+/,
29
+ // Matches whitespace
30
+ WHITESPACE: /^\s+/,
31
+ // Matches parentheses
32
+ PAREN_OPEN: /^\(/,
33
+ PAREN_CLOSE: /^\)/,
34
+ // Matches negation prefix
35
+ NEGATION: /^-/
36
+ };
37
+ /**
38
+ * Parse a single term (key:value, key:>value, or just value)
39
+ */
40
+ function parseTerm(input, startPosition) {
41
+ if (!input || input.length === 0) {
42
+ return null;
43
+ }
44
+ let key = null;
45
+ let operator = null;
46
+ let value = null;
47
+ let remaining = input;
48
+ let currentPos = 0;
49
+ // Handle negation prefix (e.g., -status:active)
50
+ let hasNegation = false;
51
+ const negationMatch = remaining.match(PATTERNS.NEGATION);
52
+ if (negationMatch) {
53
+ hasNegation = true;
54
+ remaining = remaining.substring(1);
55
+ currentPos += 1;
56
+ }
57
+ // Try to match a key (word before operator)
58
+ const keyMatch = remaining.match(PATTERNS.WORD_CHARS);
59
+ if (keyMatch) {
60
+ const potentialKey = keyMatch[0];
61
+ const afterKey = remaining.substring(potentialKey.length);
62
+ // Check if followed by an operator
63
+ const operatorMatch = afterKey.match(PATTERNS.COMPARISON_OPERATOR);
64
+ if (operatorMatch) {
65
+ // This is a key:value pattern
66
+ key = (hasNegation ? '-' : '') + potentialKey;
67
+ operator = operatorMatch[0];
68
+ currentPos += potentialKey.length + operator.length;
69
+ remaining = afterKey.substring(operator.length);
70
+ // Try to match the value
71
+ // First check for quoted string
72
+ const quotedMatch = remaining.match(PATTERNS.QUOTED_STRING);
73
+ if (quotedMatch) {
74
+ value = quotedMatch[0];
75
+ currentPos += value.length;
76
+ }
77
+ else {
78
+ // Match unquoted value (until whitespace or logical operator)
79
+ const valueMatch = remaining.match(/^[^\s()]+/);
80
+ if (valueMatch) {
81
+ value = valueMatch[0];
82
+ currentPos += value.length;
83
+ }
84
+ else {
85
+ // Operator present but no value yet
86
+ value = null;
87
+ }
88
+ }
89
+ }
90
+ else {
91
+ // No operator - this is a bare value (or incomplete key)
92
+ // Treat the whole thing as a potential key that could become key:value
93
+ // or as a bare value for full-text search
94
+ key = null;
95
+ operator = null;
96
+ value = (hasNegation ? '-' : '') + potentialKey;
97
+ currentPos += potentialKey.length;
98
+ }
99
+ }
100
+ else {
101
+ // Check for quoted string as bare value
102
+ const quotedMatch = remaining.match(PATTERNS.QUOTED_STRING);
103
+ if (quotedMatch) {
104
+ key = null;
105
+ operator = null;
106
+ value = (hasNegation ? '-' : '') + quotedMatch[0];
107
+ currentPos += quotedMatch[0].length;
108
+ }
109
+ else {
110
+ // No recognizable token
111
+ return null;
112
+ }
113
+ }
114
+ return {
115
+ key,
116
+ operator,
117
+ value,
118
+ startPosition,
119
+ endPosition: startPosition + currentPos,
120
+ raw: input.substring(0, currentPos)
121
+ };
122
+ }
123
+ /**
124
+ * Tokenize the input string into terms and logical operators
125
+ */
126
+ function tokenize(input) {
127
+ const terms = [];
128
+ const logicalOperators = [];
129
+ let remaining = input;
130
+ let position = 0;
131
+ while (remaining.length > 0) {
132
+ // Skip whitespace
133
+ const wsMatch = remaining.match(PATTERNS.WHITESPACE);
134
+ if (wsMatch) {
135
+ position += wsMatch[0].length;
136
+ remaining = remaining.substring(wsMatch[0].length);
137
+ continue;
138
+ }
139
+ // Skip parentheses (they're structural, not terms)
140
+ if (remaining.match(PATTERNS.PAREN_OPEN)) {
141
+ position += 1;
142
+ remaining = remaining.substring(1);
143
+ continue;
144
+ }
145
+ if (remaining.match(PATTERNS.PAREN_CLOSE)) {
146
+ position += 1;
147
+ remaining = remaining.substring(1);
148
+ continue;
149
+ }
150
+ // Check for logical operators
151
+ const logicalMatch = remaining.match(/^(AND|OR|NOT)\b/i);
152
+ if (logicalMatch) {
153
+ logicalOperators.push({
154
+ operator: logicalMatch[0].toUpperCase(),
155
+ position
156
+ });
157
+ position += logicalMatch[0].length;
158
+ remaining = remaining.substring(logicalMatch[0].length);
159
+ continue;
160
+ }
161
+ // Try to parse a term
162
+ const term = parseTerm(remaining, position);
163
+ if (term) {
164
+ terms.push(term);
165
+ position = term.endPosition;
166
+ remaining = input.substring(position);
167
+ }
168
+ else {
169
+ // Skip unknown character
170
+ position += 1;
171
+ remaining = remaining.substring(1);
172
+ }
173
+ }
174
+ return { terms, logicalOperators };
175
+ }
176
+ /**
177
+ * Determine the cursor context based on position within a term
178
+ */
179
+ function determineCursorContext(term, cursorPosition) {
180
+ const relativePos = cursorPosition - term.startPosition;
181
+ if (term.key !== null && term.operator !== null) {
182
+ // Key and operator are present
183
+ const keyLength = term.key.length;
184
+ const operatorLength = term.operator.length;
185
+ const keyPlusOperatorLength = keyLength + operatorLength;
186
+ if (relativePos < keyLength) {
187
+ return 'key';
188
+ }
189
+ else if (relativePos < keyPlusOperatorLength) {
190
+ return 'operator';
191
+ }
192
+ else {
193
+ // Cursor is at or after the operator - this is the value position
194
+ // Even if value is null (user hasn't typed anything yet),
195
+ // they're positioned to type a value
196
+ return 'value';
197
+ }
198
+ }
199
+ else if (term.key !== null) {
200
+ // Only key present (incomplete term)
201
+ return 'key';
202
+ }
203
+ else if (term.value !== null) {
204
+ // Only value present (bare value)
205
+ return 'value';
206
+ }
207
+ return 'empty';
208
+ }
209
+ /**
210
+ * Find the term that contains the cursor position
211
+ */
212
+ function findActiveTermAndContext(terms, cursorPosition, inputLength) {
213
+ if (cursorPosition === null) {
214
+ // If no cursor position provided, use the last term
215
+ if (terms.length > 0) {
216
+ const lastTerm = terms[terms.length - 1];
217
+ return {
218
+ activeTerm: lastTerm,
219
+ cursorContext: determineCursorContext(lastTerm, lastTerm.endPosition)
220
+ };
221
+ }
222
+ return { activeTerm: null, cursorContext: 'empty' };
223
+ }
224
+ // Find term containing cursor
225
+ for (const term of terms) {
226
+ if (cursorPosition >= term.startPosition &&
227
+ cursorPosition <= term.endPosition) {
228
+ return {
229
+ activeTerm: term,
230
+ cursorContext: determineCursorContext(term, cursorPosition)
231
+ };
232
+ }
233
+ }
234
+ // Cursor is between terms or at the end
235
+ if (cursorPosition >= inputLength && terms.length > 0) {
236
+ // Cursor at the end - check if right after a term
237
+ const lastTerm = terms[terms.length - 1];
238
+ if (cursorPosition === lastTerm.endPosition) {
239
+ return {
240
+ activeTerm: lastTerm,
241
+ cursorContext: determineCursorContext(lastTerm, cursorPosition)
242
+ };
243
+ }
244
+ }
245
+ return { activeTerm: null, cursorContext: 'between' };
246
+ }
247
+ /**
248
+ * Parse query input to extract structured information about the current search state.
249
+ *
250
+ * This function is designed for real-time parsing of user input in a search bar,
251
+ * allowing developers to:
252
+ * - Highlight keys and values differently
253
+ * - Provide autocomplete suggestions based on context
254
+ * - Validate input as the user types
255
+ *
256
+ * @param input The current search input string
257
+ * @param cursorPosition Optional cursor position to determine the active term
258
+ * @param options Optional parsing options
259
+ * @returns Structured information about the query input
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * // User is typing "status:d" (intending to type "status:done")
264
+ * const result = parseQueryInput('status:d');
265
+ * // result.terms[0] = { key: 'status', operator: ':', value: 'd', ... }
266
+ * // result.activeTerm = { key: 'status', operator: ':', value: 'd', ... }
267
+ * // result.cursorContext = 'value'
268
+ *
269
+ * // User is typing "priority:>2 status:"
270
+ * const result = parseQueryInput('priority:>2 status:', 19);
271
+ * // result.terms[0] = { key: 'priority', operator: ':>', value: '2', ... }
272
+ * // result.terms[1] = { key: 'status', operator: ':', value: null, ... }
273
+ * // result.activeTerm = result.terms[1] (cursor is at position 19)
274
+ * // result.cursorContext = 'value' (waiting for value input)
275
+ * ```
276
+ */
277
+ function parseQueryInput(input, cursorPosition, options) {
278
+ // Handle empty input
279
+ if (!input || input.trim().length === 0) {
280
+ return {
281
+ terms: [],
282
+ activeTerm: null,
283
+ cursorContext: 'empty',
284
+ input,
285
+ cursorPosition: cursorPosition ?? null,
286
+ logicalOperators: []
287
+ };
288
+ }
289
+ // Tokenize the input
290
+ const { terms, logicalOperators } = tokenize(input);
291
+ // Apply case-insensitivity to keys if requested
292
+ if (options?.caseInsensitiveKeys) {
293
+ for (const term of terms) {
294
+ if (term.key !== null) {
295
+ term.key = term.key.toLowerCase();
296
+ }
297
+ }
298
+ }
299
+ // Find active term and cursor context
300
+ const { activeTerm, cursorContext } = findActiveTermAndContext(terms, cursorPosition ?? null, input.length);
301
+ return {
302
+ terms,
303
+ activeTerm,
304
+ cursorContext,
305
+ input,
306
+ cursorPosition: cursorPosition ?? null,
307
+ logicalOperators
308
+ };
309
+ }
310
+ /**
311
+ * Get the term at a specific cursor position.
312
+ * Convenience function for quick lookups.
313
+ *
314
+ * @param input The query input string
315
+ * @param cursorPosition The cursor position
316
+ * @returns The term at the cursor position, or null if none
317
+ */
318
+ function getTermAtPosition(input, cursorPosition) {
319
+ const result = parseQueryInput(input, cursorPosition);
320
+ return result.activeTerm;
321
+ }
322
+ /**
323
+ * Check if the input appears to be a complete, valid query expression.
324
+ * This is a lightweight check - it doesn't guarantee the query will parse successfully.
325
+ *
326
+ * @param input The query input string
327
+ * @returns true if the input appears complete, false if it looks incomplete
328
+ */
329
+ function isInputComplete(input) {
330
+ if (!input || input.trim().length === 0) {
331
+ return false;
332
+ }
333
+ const result = parseQueryInput(input);
334
+ // Check if any term is incomplete
335
+ for (const term of result.terms) {
336
+ // A key:value term is incomplete if it has an operator but no value
337
+ if (term.key !== null && term.operator !== null && term.value === null) {
338
+ return false;
339
+ }
340
+ }
341
+ // Check if the input ends with a logical operator
342
+ const trimmed = input.trim();
343
+ if (/\b(AND|OR|NOT)\s*$/i.test(trimmed)) {
344
+ return false;
345
+ }
346
+ // Check if there's an unclosed quote
347
+ const singleQuotes = (input.match(/'/g) || []).length;
348
+ const doubleQuotes = (input.match(/"/g) || []).length;
349
+ if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) {
350
+ return false;
351
+ }
352
+ // Check for unclosed parentheses
353
+ const openParens = (input.match(/\(/g) || []).length;
354
+ const closeParens = (input.match(/\)/g) || []).length;
355
+ if (openParens !== closeParens) {
356
+ return false;
357
+ }
358
+ return true;
359
+ }
360
+ /**
361
+ * Extract just the key and value from a simple input.
362
+ * Convenience function for the most common use case.
363
+ *
364
+ * @param input The query input string (e.g., "status:done")
365
+ * @returns Object with key and value, or null if not a key:value pattern
366
+ *
367
+ * @example
368
+ * ```typescript
369
+ * extractKeyValue('status:done');
370
+ * // { key: 'status', value: 'done' }
371
+ *
372
+ * extractKeyValue('status:');
373
+ * // { key: 'status', value: null }
374
+ *
375
+ * extractKeyValue('hello');
376
+ * // null (no key:value pattern)
377
+ * ```
378
+ */
379
+ function extractKeyValue(input) {
380
+ const result = parseQueryInput(input.trim());
381
+ if (result.terms.length === 0) {
382
+ return null;
383
+ }
384
+ const term = result.terms[0];
385
+ if (term.key === null) {
386
+ return null;
387
+ }
388
+ return {
389
+ key: term.key,
390
+ value: term.value
391
+ };
392
+ }
393
+ /**
394
+ * Parse query input into an interleaved sequence of terms and operators.
395
+ *
396
+ * This provides a flat, ordered representation ideal for:
397
+ * - Rendering query tokens as UI chips/tags
398
+ * - Building visual query builders
399
+ * - Syntax highlighting with proper ordering
400
+ *
401
+ * @param input The query input string
402
+ * @param cursorPosition Optional cursor position to identify active token
403
+ * @returns Ordered sequence of term and operator tokens
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * const result = parseQueryTokens('status:done AND priority:high');
408
+ * // result.tokens = [
409
+ * // { type: 'term', key: 'status', value: 'done', ... },
410
+ * // { type: 'operator', operator: 'AND', ... },
411
+ * // { type: 'term', key: 'priority', value: 'high', ... }
412
+ * // ]
413
+ *
414
+ * // For incomplete input like 'status:d'
415
+ * const result = parseQueryTokens('status:d');
416
+ * // result.tokens = [
417
+ * // { type: 'term', key: 'status', value: 'd', ... }
418
+ * // ]
419
+ * ```
420
+ */
421
+ function parseQueryTokens(input, cursorPosition) {
422
+ if (!input || input.trim().length === 0) {
423
+ return {
424
+ tokens: [],
425
+ input,
426
+ activeToken: null,
427
+ activeTokenIndex: -1
428
+ };
429
+ }
430
+ const context = parseQueryInput(input, cursorPosition);
431
+ // Build a combined list of all tokens with their positions
432
+ const allTokens = [];
433
+ // Add terms
434
+ for (const term of context.terms) {
435
+ allTokens.push({
436
+ position: term.startPosition,
437
+ token: {
438
+ type: 'term',
439
+ key: term.key,
440
+ operator: term.operator,
441
+ value: term.value,
442
+ startPosition: term.startPosition,
443
+ endPosition: term.endPosition,
444
+ raw: term.raw
445
+ }
446
+ });
447
+ }
448
+ // Add operators
449
+ for (const op of context.logicalOperators) {
450
+ const opLength = op.operator.length;
451
+ allTokens.push({
452
+ position: op.position,
453
+ token: {
454
+ type: 'operator',
455
+ operator: op.operator,
456
+ startPosition: op.position,
457
+ endPosition: op.position + opLength,
458
+ raw: op.operator
459
+ }
460
+ });
461
+ }
462
+ // Sort by position to get the interleaved order
463
+ allTokens.sort((a, b) => a.position - b.position);
464
+ const tokens = allTokens.map(t => t.token);
465
+ // Find active token based on cursor position
466
+ let activeToken = null;
467
+ let activeTokenIndex = -1;
468
+ if (cursorPosition !== undefined) {
469
+ for (let i = 0; i < tokens.length; i++) {
470
+ const token = tokens[i];
471
+ if (cursorPosition >= token.startPosition &&
472
+ cursorPosition <= token.endPosition) {
473
+ activeToken = token;
474
+ activeTokenIndex = i;
475
+ break;
476
+ }
477
+ }
478
+ // If cursor is at the end and right after the last token
479
+ if (activeToken === null && tokens.length > 0) {
480
+ const lastToken = tokens[tokens.length - 1];
481
+ if (cursorPosition === lastToken.endPosition) {
482
+ activeToken = lastToken;
483
+ activeTokenIndex = tokens.length - 1;
484
+ }
485
+ }
486
+ }
487
+ return {
488
+ tokens,
489
+ input,
490
+ activeToken,
491
+ activeTokenIndex
492
+ };
493
+ }
@@ -1,4 +1,4 @@
1
- import { IParserOptions, IQueryParser, QueryExpression } from './types';
1
+ import { IParserOptions, IParseWithContextOptions, IQueryParser, IQueryParseResult, QueryExpression } from './types';
2
2
  /**
3
3
  * Error thrown when query parsing fails
4
4
  */
@@ -15,6 +15,35 @@ export declare class QueryParser implements IQueryParser {
15
15
  * Parse a query string into a QueryKit AST
16
16
  */
17
17
  parse(query: string): QueryExpression;
18
+ /**
19
+ * Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
20
+ * Supports:
21
+ * - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
22
+ *
23
+ * This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
24
+ * - `priority:>2` (comparison)
25
+ * - `status:active` (equality)
26
+ * - `status:[todo, doing, done]` (IN / multiple values)
27
+ */
28
+ private preprocessQuery;
29
+ /**
30
+ * Convert a field and comma-separated values to an OR expression string
31
+ */
32
+ private convertToOrExpression;
33
+ /**
34
+ * Parse a comma-separated string into values, respecting quoted strings.
35
+ * Commas inside quoted strings are preserved as part of the value.
36
+ *
37
+ * Examples:
38
+ * - `a, b, c` → ['a', 'b', 'c']
39
+ * - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
40
+ * - `'hello, world', test` → ["'hello, world'", 'test']
41
+ */
42
+ private parseCommaSeparatedValues;
43
+ /**
44
+ * Format a field:value pair, quoting the value if necessary
45
+ */
46
+ private formatFieldValue;
18
47
  /**
19
48
  * Validate a query string
20
49
  */
@@ -35,6 +64,11 @@ export declare class QueryParser implements IQueryParser {
35
64
  * Create a comparison expression
36
65
  */
37
66
  private createComparisonExpression;
67
+ /**
68
+ * Convert a Liqe RangeExpression to a QueryKit logical AND expression
69
+ * E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
70
+ */
71
+ private convertRangeExpression;
38
72
  /**
39
73
  * Convert a Liqe operator to a QueryKit operator
40
74
  */
@@ -48,4 +82,117 @@ export declare class QueryParser implements IQueryParser {
48
82
  * Normalize a field name based on parser options
49
83
  */
50
84
  private normalizeFieldName;
85
+ /**
86
+ * Parse a query string with full context information.
87
+ *
88
+ * Unlike `parse()`, this method never throws. Instead, it returns a result object
89
+ * that indicates success or failure along with rich contextual information useful
90
+ * for building search UIs.
91
+ *
92
+ * @param query The query string to parse
93
+ * @param options Optional configuration (cursor position, etc.)
94
+ * @returns Rich parse result with tokens, AST/error, and structural analysis
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * const result = parser.parseWithContext('status:done AND priority:high');
99
+ *
100
+ * if (result.success) {
101
+ * // Use result.ast for query execution
102
+ * console.log('Valid query:', result.ast);
103
+ * } else {
104
+ * // Show error to user
105
+ * console.log('Error:', result.error?.message);
106
+ * }
107
+ *
108
+ * // Always available for UI rendering
109
+ * console.log('Tokens:', result.tokens);
110
+ * console.log('Structure:', result.structure);
111
+ * ```
112
+ */
113
+ parseWithContext(query: string, options?: IParseWithContextOptions): IQueryParseResult;
114
+ /**
115
+ * Convert tokens from input parser format to IQueryToken format
116
+ */
117
+ private convertTokens;
118
+ /**
119
+ * Convert a single token from input parser format
120
+ */
121
+ private convertSingleToken;
122
+ /**
123
+ * Analyze the structure of a query
124
+ */
125
+ private analyzeStructure;
126
+ /**
127
+ * Calculate the maximum nesting depth of parentheses
128
+ */
129
+ private calculateDepth;
130
+ /**
131
+ * Determine query complexity classification
132
+ */
133
+ private determineComplexity;
134
+ /**
135
+ * Try to extract error position from error message
136
+ */
137
+ private extractErrorPosition;
138
+ /**
139
+ * Try to extract the problematic text from the query based on error
140
+ */
141
+ private extractProblematicText;
142
+ /**
143
+ * Validate fields against the provided schema
144
+ */
145
+ private validateFields;
146
+ /**
147
+ * Find a similar field name (for typo suggestions)
148
+ */
149
+ private findSimilarField;
150
+ /**
151
+ * Calculate Levenshtein distance between two strings
152
+ */
153
+ private levenshteinDistance;
154
+ /**
155
+ * Perform security pre-check against the provided options
156
+ */
157
+ private performSecurityCheck;
158
+ /**
159
+ * Generate autocomplete suggestions based on cursor position
160
+ */
161
+ private generateAutocompleteSuggestions;
162
+ /**
163
+ * Suggest for empty/start context
164
+ */
165
+ private suggestForEmptyContext;
166
+ /**
167
+ * Suggest between tokens (after a complete term)
168
+ */
169
+ private suggestBetweenTokens;
170
+ /**
171
+ * Suggest field names
172
+ */
173
+ private suggestFields;
174
+ /**
175
+ * Get field suggestions based on partial input
176
+ */
177
+ private getFieldSuggestions;
178
+ /**
179
+ * Suggest operators
180
+ */
181
+ private suggestOperators;
182
+ /**
183
+ * Get operator suggestions based on field type
184
+ */
185
+ private getOperatorSuggestions;
186
+ /**
187
+ * Suggest values
188
+ */
189
+ private suggestValues;
190
+ /**
191
+ * Get value suggestions based on schema
192
+ */
193
+ private getValueSuggestions;
194
+ /**
195
+ * Generate error recovery suggestions
196
+ */
197
+ private generateErrorRecovery;
51
198
  }