feat: improve suggestions

This commit is contained in:
Yunus M 2025-05-06 00:02:08 +05:30 committed by ahrefabhi
parent ff7c398445
commit a242fd3846
5 changed files with 1403 additions and 92 deletions

View File

@ -307,6 +307,68 @@
}
}
}
// Context indicator styles
.context-indicator {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 4px;
font-size: 13px;
background-color: #f5f5f5;
border-left: 4px solid #1890ff;
.triplet-info {
margin-left: 16px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.query-pair-info {
display: inline-flex;
align-items: center;
gap: 4px;
border-left: 1px solid rgba(0, 0, 0, 0.1);
padding-left: 8px;
background-color: rgba(0, 0, 0, 0.03);
padding: 4px 8px;
border-radius: 4px;
}
// Color variations based on context
&.context-indicator-key {
border-left-color: #1890ff; // blue
background-color: rgba(24, 144, 255, 0.1);
}
&.context-indicator-operator {
border-left-color: #722ed1; // purple
background-color: rgba(114, 46, 209, 0.1);
}
&.context-indicator-value {
border-left-color: #52c41a; // green
background-color: rgba(82, 196, 26, 0.1);
}
&.context-indicator-conjunction {
border-left-color: #fa8c16; // orange
background-color: rgba(250, 140, 22, 0.1);
}
&.context-indicator-function {
border-left-color: #13c2c2; // cyan
background-color: rgba(19, 194, 194, 0.1);
}
&.context-indicator-parenthesis {
border-left-color: #eb2f96; // magenta
background-color: rgba(235, 47, 150, 0.1);
}
}
}
/* Dark mode support */
@ -370,5 +432,15 @@
}
}
}
.context-indicator {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-100);
.query-pair-info {
border-left: 1px solid rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.05);
}
}
}
}

View File

@ -17,8 +17,8 @@ import {
import { javascript } from '@codemirror/lang-javascript';
import { ViewPlugin, ViewUpdate } from '@codemirror/view';
import { copilot } from '@uiw/codemirror-theme-copilot';
import CodeMirror, { EditorView } from '@uiw/react-codemirror';
import { Card, Collapse, Space, Typography } from 'antd';
import CodeMirror, { EditorView, Extension } from '@uiw/react-codemirror';
import { Card, Collapse, Space, Tag, Typography } from 'antd';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -28,11 +28,8 @@ import {
IValidationResult,
} from 'types/antlrQueryTypes';
import { QueryKeySuggestionsProps } from 'types/api/querySuggestions/types';
import {
getQueryContextAtCursor,
queryOperatorSuggestions,
validateQuery,
} from 'utils/antlrQueryUtils';
import { queryOperatorSuggestions, validateQuery } from 'utils/antlrQueryUtils';
import { getQueryContextAtCursor } from 'utils/queryContextUtils';
const { Text } = Typography;
const { Panel } = Collapse;
@ -153,6 +150,18 @@ const contextAwarePlugin = (
},
);
const disallowMultipleSpaces: Extension = EditorView.inputHandler.of(
(view, from, to, text) => {
const currentLine = view.state.doc.lineAt(from);
const before = currentLine.text.slice(0, from - currentLine.from);
const after = currentLine.text.slice(to - currentLine.from);
const newText = before + text + after;
return /\s{2,}/.test(newText);
},
);
function CodeMirrorWhereClause(): JSX.Element {
const [query, setQuery] = useState<string>('');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([
@ -178,11 +187,23 @@ function CodeMirrorWhereClause(): JSX.Element {
// Reference to the editor view for programmatic autocompletion
const editorRef = useRef<EditorView | null>(null);
const lastKeyRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const { data: queryKeySuggestions } = useGetQueryKeySuggestions({
signal: 'traces',
});
// Add a state for tracking editing mode
const [editingMode, setEditingMode] = useState<
| 'key'
| 'operator'
| 'value'
| 'conjunction'
| 'function'
| 'parenthesis'
| null
>(null);
// Helper function to wrap string values in quotes if they aren't already quoted
const wrapStringValueInQuotes = (value: string): string => {
// If value is already quoted (with single quotes), return as is
@ -235,48 +256,99 @@ function CodeMirrorWhereClause(): JSX.Element {
return value;
};
const analyzeContext = (view: EditorView, pos: number): void => {
const word = view.state.wordAt(pos);
const token = word ? view.state.sliceDoc(word.from, word.to) : '';
const analyzeContext = useCallback((view: EditorView, pos: number): void => {
// Skip if component unmounted
if (!isMountedRef.current) return;
// Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(view.state.doc.toString(), pos);
const doc = view.state.doc.toString();
let contextType = 'Unknown';
if (queryContext.isInKey) {
contextType = 'Key';
} else if (queryContext.isInOperator) {
contextType = 'Operator';
} else if (queryContext.isInValue) {
contextType = 'Value';
} else if (queryContext.isInFunction) {
contextType = 'Function';
} else if (queryContext.isInConjunction) {
contextType = 'Conjunction';
} else if (queryContext.isInParenthesis) {
contextType = 'Parenthesis';
}
// Check for spaces around the cursor position for debugging
const isCursorAtSpace = pos < doc.length && doc[pos] === ' ';
const isCursorAfterSpace = pos > 0 && doc[pos - 1] === ' ';
const isCursorAfterToken =
pos > 0 && doc[pos - 1] !== ' ' && doc[pos - 1] !== undefined;
const isCursorBeforeToken =
pos < doc.length && doc[pos] !== ' ' && doc[pos] !== undefined;
console.log(
'Cursor is at',
pos,
'Token under cursor:',
token,
'Context:',
contextType,
);
};
// Check if cursor is at transition point (right after a token at the beginning of a space)
const isTransitionPoint = isCursorAtSpace && isCursorAfterToken;
// Get a slice of the text around cursor for context
const sliceStart = Math.max(0, pos - 10);
const sliceEnd = Math.min(doc.length, pos + 10);
const textSlice = doc.substring(sliceStart, sliceEnd);
const cursorPosInSlice = pos - sliceStart;
// Create a visual cursor indicator
const beforeCursor = textSlice.substring(0, cursorPosInSlice);
const afterCursor = textSlice.substring(cursorPosInSlice);
const visualCursor = `${beforeCursor}|${afterCursor}`;
const context = getQueryContextAtCursor(doc, pos);
// Enhanced debug logging with space and pair detection
console.log('Context at cursor:', {
position: pos,
visualCursor,
cursorAtSpace: isCursorAtSpace,
cursorAfterSpace: isCursorAfterSpace,
cursorAfterToken: isCursorAfterToken,
cursorBeforeToken: isCursorBeforeToken,
isTransitionPoint,
contextType: context.isInKey
? 'Key'
: context.isInOperator
? 'Operator'
: context.isInValue
? 'Value'
: context.isInConjunction
? 'Conjunction'
: context.isInFunction
? 'Function'
: context.isInParenthesis
? 'Parenthesis'
: 'Unknown',
keyToken: context.keyToken,
operatorToken: context.operatorToken,
valueToken: context.valueToken,
queryPairs: context.queryPairs?.length || 0,
currentPair: context.currentPair
? {
key: context.currentPair.key,
operator: context.currentPair.operator,
value: context.currentPair.value,
isComplete: context.currentPair.isComplete,
}
: null,
});
}, []);
// Add cleanup effect to prevent component updates after unmount
useEffect(
(): (() => void) => (): void => {
// Mark component as unmounted to prevent state updates
isMountedRef.current = false;
},
[],
);
// Use callback to prevent dependency changes on each render
const fetchValueSuggestions = useCallback(
async (key: string): Promise<void> => {
if (!key || (key === activeKey && !isLoadingSuggestions)) return;
if (
!key ||
(key === activeKey && !isLoadingSuggestions) ||
!isMountedRef.current
)
return;
// Set loading state and store the key we're fetching for
setIsLoadingSuggestions(true);
lastKeyRef.current = key;
setActiveKey(key);
console.log('fetching suggestions for key:', key);
// Replace current suggestions with loading indicator
setValueSuggestions([
{
@ -293,9 +365,9 @@ function CodeMirrorWhereClause(): JSX.Element {
signal: 'traces',
});
// Verify we're still on the same key (user hasn't moved on)
if (lastKeyRef.current !== key) {
return; // Skip updating if key has changed
// Skip updates if component unmounted or key changed
if (!isMountedRef.current || lastKeyRef.current !== key) {
return; // Skip updating if key has changed or component unmounted
}
// Process the response data
@ -337,7 +409,7 @@ function CodeMirrorWhereClause(): JSX.Element {
);
// Only if we're still on the same key
if (lastKeyRef.current === key) {
if (lastKeyRef.current === key && isMountedRef.current) {
if (allOptions.length > 0) {
setValueSuggestions(allOptions);
} else {
@ -354,14 +426,16 @@ function CodeMirrorWhereClause(): JSX.Element {
// Force reopen the completion if editor is available
if (editorRef.current) {
setTimeout(() => {
startCompletion(editorRef.current!);
if (isMountedRef.current && editorRef.current) {
startCompletion(editorRef.current);
}
}, 10);
}
setIsLoadingSuggestions(false);
}
} catch (error) {
console.error('Error fetching suggestions:', error);
if (lastKeyRef.current === key) {
if (lastKeyRef.current === key && isMountedRef.current) {
setValueSuggestions([
{
label: 'Error loading suggestions',
@ -377,29 +451,101 @@ function CodeMirrorWhereClause(): JSX.Element {
[activeKey, isLoadingSuggestions],
);
const handleUpdate = (viewUpdate: { view: EditorView }): void => {
// Store editor reference
if (!editorRef.current) {
editorRef.current = viewUpdate.view;
}
// Enhanced update handler to track context changes
const handleUpdate = useCallback(
(viewUpdate: { view: EditorView }): void => {
// Skip updates if component is unmounted
if (!isMountedRef.current) return;
const selection = viewUpdate.view.state.selection.main;
const pos = selection.head;
// Store editor reference
if (!editorRef.current) {
editorRef.current = viewUpdate.view;
}
const lineInfo = viewUpdate.view.state.doc.lineAt(pos);
const newPos = {
line: lineInfo.number,
ch: pos - lineInfo.from,
};
const selection = viewUpdate.view.state.selection.main;
const pos = selection.head;
const doc = viewUpdate.view.state.doc.toString();
const lastPos = lastPosRef.current;
const lineInfo = viewUpdate.view.state.doc.lineAt(pos);
const newPos = {
line: lineInfo.number,
ch: pos - lineInfo.from,
};
// Only update if cursor position actually changed
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
setCursorPos(newPos);
lastPosRef.current = newPos;
}
};
const lastPos = lastPosRef.current;
// Only update if cursor position actually changed
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
setCursorPos(newPos);
lastPosRef.current = newPos;
// Detect if cursor is at a space or after a token
const isAtSpace = pos < doc.length && doc[pos] === ' ';
const isAfterToken =
pos > 0 && doc[pos - 1] !== ' ' && doc[pos - 1] !== undefined;
const isTransitionPoint = isAtSpace && isAfterToken;
// Get context immediately when cursor position changes
if (doc) {
const context = getQueryContextAtCursor(doc, pos);
// Only update context and mode if they've actually changed
// This prevents unnecessary re-renders
const previousContextType = queryContext?.isInKey
? 'key'
: queryContext?.isInOperator
? 'operator'
: queryContext?.isInValue
? 'value'
: queryContext?.isInConjunction
? 'conjunction'
: queryContext?.isInFunction
? 'function'
: queryContext?.isInParenthesis
? 'parenthesis'
: null;
const newContextType = context.isInKey
? 'key'
: context.isInOperator
? 'operator'
: context.isInValue
? 'value'
: context.isInConjunction
? 'conjunction'
: context.isInFunction
? 'function'
: context.isInParenthesis
? 'parenthesis'
: null;
// Log context changes for debugging
if (previousContextType !== newContextType) {
console.log(
`Context changed: ${previousContextType || 'none'} -> ${
newContextType || 'none'
}`,
{
position: pos,
isAtSpace,
isAfterToken,
isTransitionPoint,
keyToken: context.keyToken,
operatorToken: context.operatorToken,
valueToken: context.valueToken,
},
);
}
setQueryContext(context);
// Update editing mode based on context
setEditingMode(newContextType);
}
}
},
[queryContext],
);
const handleQueryChange = useCallback(async (newQuery: string) => {
setQuery(newQuery);
@ -416,13 +562,6 @@ function CodeMirrorWhereClause(): JSX.Element {
}
}, []);
useEffect(() => {
if (query) {
const context = getQueryContextAtCursor(query, cursorPos.ch);
setQueryContext(context as IQueryContext);
}
}, [query, cursorPos]);
const handleChange = (value: string): void => {
setQuery(value);
handleQueryChange(value);
@ -435,6 +574,29 @@ function CodeMirrorWhereClause(): JSX.Element {
handleQueryChange(newQuery);
};
// Helper function to render a badge for the current context mode
const renderContextBadge = (): JSX.Element => {
if (!editingMode) return <Tag>Unknown</Tag>;
switch (editingMode) {
case 'key':
return <Tag color="blue">Key</Tag>;
case 'operator':
return <Tag color="purple">Operator</Tag>;
case 'value':
return <Tag color="green">Value</Tag>;
case 'conjunction':
return <Tag color="orange">Conjunction</Tag>;
case 'function':
return <Tag color="cyan">Function</Tag>;
case 'parenthesis':
return <Tag color="magenta">Parenthesis</Tag>;
default:
return <Tag>Unknown</Tag>;
}
};
// Enhanced myCompletions function to better use context including query pairs
function myCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/[.\w]*/);
if (word?.from === word?.to && !context.explicit) return null;
@ -442,6 +604,8 @@ function CodeMirrorWhereClause(): JSX.Element {
// Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(query, cursorPos.ch);
console.log('queryContext', queryContext);
// Define autocomplete options based on the context
let options: {
label: string;
@ -449,6 +613,7 @@ function CodeMirrorWhereClause(): JSX.Element {
info?: string;
apply?: string;
detail?: string;
boost?: number;
}[] = [];
if (queryContext.isInKey) {
@ -458,6 +623,28 @@ function CodeMirrorWhereClause(): JSX.Element {
option.label.toLowerCase().includes(searchText),
);
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.includes(option.label) ? -10 : 10,
info: usedKeys.includes(option.label)
? `${option.info || ''} (already used in query)`
: option.info,
}));
}
// Add boost to exact matches
options = options.map((option) => ({
...option,
boost:
(option.boost || 0) +
(option.label.toLowerCase() === searchText ? 100 : 0),
}));
return {
from: word?.from ?? 0,
to: word?.to ?? cursorPos.ch,
@ -466,8 +653,51 @@ function CodeMirrorWhereClause(): JSX.Element {
}
if (queryContext.isInOperator) {
options = [];
options = queryOperatorSuggestions;
// Get key information from context or current pair
const keyName = queryContext.keyToken || queryContext.currentPair?.key;
// If we have a key context, add that info to the operator suggestions
if (keyName) {
// Find the key details from suggestions
const keyDetails = (keySuggestions || []).find((k) => k.label === keyName);
const keyType = keyDetails?.type || '';
// Filter operators based on key type
if (keyType) {
if (keyType === 'number') {
// Prioritize numeric operators
options = options.map((op) => ({
...op,
boost: ['>', '<', '>=', '<=', '=', '!=', 'BETWEEN'].includes(op.label)
? 100
: 0,
}));
} else if (keyType === 'string' || keyType === 'keyword') {
// Prioritize string operators
options = options.map((op) => ({
...op,
boost: ['=', '!=', 'LIKE', 'ILIKE', 'CONTAINS', 'IN'].includes(op.label)
? 100
: 0,
}));
} else if (keyType === 'boolean') {
// Prioritize boolean operators
options = options.map((op) => ({
...op,
boost: ['=', '!='].includes(op.label) ? 100 : 0,
}));
}
}
// Add key info to all operators
options = options.map((op) => ({
...op,
info: `${op.info || ''} (for ${keyName})`,
}));
}
return {
from: word?.from ?? 0,
to: word?.to ?? cursorPos.ch,
@ -476,15 +706,20 @@ function CodeMirrorWhereClause(): JSX.Element {
}
if (queryContext.isInValue) {
// Fetch values based on the key - use the keyToken if available
const { keyToken, currentToken, operatorToken } = queryContext;
const key = keyToken || currentToken;
// Fetch values based on the key - use available context
const keyName = queryContext.keyToken || queryContext.currentPair?.key || '';
const operatorName =
queryContext.operatorToken || queryContext.currentPair?.operator || '';
// Trigger fetch only if key is different from activeKey or if we're still loading
if (key && (key !== activeKey || isLoadingSuggestions)) {
if (!keyName) {
return null;
}
// Trigger fetch only if needed
if (keyName && (keyName !== activeKey || isLoadingSuggestions)) {
// Don't trigger a new fetch if we're already loading for this key
if (!(isLoadingSuggestions && lastKeyRef.current === key)) {
fetchValueSuggestions(key);
if (!(isLoadingSuggestions && lastKeyRef.current === keyName)) {
fetchValueSuggestions(keyName);
}
}
@ -503,22 +738,42 @@ function CodeMirrorWhereClause(): JSX.Element {
// String values get quoted
processedOption.apply = formatValueForOperator(
option.label,
operatorToken,
operatorName,
option.type,
);
// Add context info to the suggestion
if (keyName && operatorName) {
processedOption.info = `Value for ${keyName} ${operatorName}`;
}
} else if (option.type === 'number') {
// Numbers don't get quoted but may need brackets for IN operators
if (isListOperator(operatorToken)) {
if (isListOperator(operatorName)) {
processedOption.apply = `[${option.label}]`;
} else {
processedOption.apply = option.label;
}
// Add context info to the suggestion
if (keyName && operatorName) {
processedOption.info = `Numeric value for ${keyName} ${operatorName}`;
}
} else if (option.type === 'boolean') {
// Boolean values don't get quoted
processedOption.apply = option.label;
// Add context info
if (keyName && operatorName) {
processedOption.info = `Boolean value for ${keyName} ${operatorName}`;
}
} else if (option.type === 'array') {
// Arrays are already formatted as arrays
processedOption.apply = option.label;
// Add context info
if (keyName && operatorName) {
processedOption.info = `Array value for ${keyName} ${operatorName}`;
}
}
return processedOption;
@ -594,9 +849,15 @@ function CodeMirrorWhereClause(): JSX.Element {
}
}
// If no specific context is detected, provide general suggestions
return {
from: word?.from ?? 0,
options: [],
options: [
...(keySuggestions || []),
{ label: 'AND', type: 'conjunction', boost: -10 },
{ label: 'OR', type: 'conjunction', boost: -10 },
{ label: '(', type: 'parenthesis', info: 'Open group', boost: -20 },
],
};
}
@ -620,24 +881,60 @@ function CodeMirrorWhereClause(): JSX.Element {
// Update state when query context changes to trigger suggestion refresh
useEffect(() => {
if (queryContext?.isInValue) {
const { keyToken, currentToken } = queryContext;
const key = keyToken || currentToken;
// Skip if we don't have a value context or it hasn't changed
if (!queryContext?.isInValue) return;
if (key && (key !== activeKey || isLoadingSuggestions)) {
// Don't trigger a new fetch if we're already loading for this key
if (!(isLoadingSuggestions && lastKeyRef.current === key)) {
fetchValueSuggestions(key);
}
}
const { keyToken, currentToken } = queryContext;
const key = keyToken || currentToken;
// We're no longer automatically adding quotes here - they will be added
// only when a specific value is selected from the dropdown
// Only fetch if needed and if we have a valid key
if (key && key !== activeKey && !isLoadingSuggestions) {
fetchValueSuggestions(key);
}
}, [queryContext, activeKey, fetchValueSuggestions, isLoadingSuggestions]);
// Use only the specific properties of queryContext we need to avoid unnecessary renders
}, [queryContext, activeKey, isLoadingSuggestions, fetchValueSuggestions]);
return (
<div className="code-mirror-where-clause">
{/* Add a context indicator banner */}
{editingMode && (
<div className={`context-indicator context-indicator-${editingMode}`}>
Currently editing: {renderContextBadge()}
{queryContext?.keyToken && (
<span className="triplet-info">
Key: <Tag>{queryContext.keyToken}</Tag>
</span>
)}
{queryContext?.operatorToken && (
<span className="triplet-info">
Operator: <Tag>{queryContext.operatorToken}</Tag>
</span>
)}
{queryContext?.valueToken && (
<span className="triplet-info">
Value: <Tag>{queryContext.valueToken}</Tag>
</span>
)}
{queryContext?.currentPair && (
<span className="triplet-info query-pair-info">
Current pair: <Tag color="blue">{queryContext.currentPair.key}</Tag>
<Tag color="purple">{queryContext.currentPair.operator}</Tag>
{queryContext.currentPair.value && (
<Tag color="green">{queryContext.currentPair.value}</Tag>
)}
<Tag color={queryContext.currentPair.isComplete ? 'success' : 'warning'}>
{queryContext.currentPair.isComplete ? 'Complete' : 'Incomplete'}
</Tag>
</span>
)}
{queryContext?.queryPairs && queryContext.queryPairs.length > 0 && (
<span className="triplet-info">
Total pairs: <Tag color="blue">{queryContext.queryPairs.length}</Tag>
</span>
)}
</div>
)}
<CodeMirror
value={query}
theme={copilot}
@ -657,6 +954,7 @@ function CodeMirrorWhereClause(): JSX.Element {
EditorView.lineWrapping,
stopEventsExtension,
contextAwarePlugin(analyzeContext),
disallowMultipleSpaces,
// customTheme,
]}
basicSetup={{

View File

@ -12,6 +12,21 @@ export interface IToken {
channel?: number;
}
export interface IQueryPair {
key: string;
operator: string;
value?: string;
position: {
keyStart: number;
keyEnd: number;
operatorStart: number;
operatorEnd: number;
valueStart?: number;
valueEnd?: number;
};
isComplete: boolean; // true if the pair has all three components
}
export interface IQueryContext {
tokenType: number;
text: string;
@ -27,6 +42,8 @@ export interface IQueryContext {
keyToken?: string;
operatorToken?: string;
valueToken?: string;
queryPairs?: IQueryPair[];
currentPair?: IQueryPair | null;
}
export interface IDetailedError {

View File

@ -185,7 +185,7 @@ export const validateQuery = (query: string): IValidationResult => {
};
// Helper function to find key-operator-value triplets in token stream
function findKeyOperatorValueTriplet(
export function findKeyOperatorValueTriplet(
allTokens: IToken[],
currentToken: IToken,
isInKey: boolean,

View File

@ -0,0 +1,924 @@
/* eslint-disable */
import { CharStreams, CommonTokenStream, Token } from 'antlr4';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import { IQueryContext, IQueryPair, IToken } from 'types/antlrQueryTypes';
// Function to normalize multiple spaces to single spaces when not in quotes
function normalizeSpaces(query: string): string {
let result = '';
let inQuotes = false;
let lastChar = '';
for (let i = 0; i < query.length; i++) {
const char = query[i];
// Track quote state
if (char === "'" && (i === 0 || query[i - 1] !== '\\')) {
inQuotes = !inQuotes;
}
// If we're in quotes, always keep the original character
if (inQuotes) {
result += char;
}
// Otherwise, collapse multiple spaces to a single space
else if (char === ' ' && lastChar === ' ') {
// Skip this space (don't add it)
} else {
result += char;
}
lastChar = char;
}
return result;
}
// Function to create a context object
export function createContext(
token: Token,
isInKey: boolean,
isInOperator: boolean,
isInValue: boolean,
keyToken?: string,
operatorToken?: string,
valueToken?: string,
queryPairs?: IQueryPair[],
currentPair?: IQueryPair | null,
): IQueryContext {
return {
tokenType: token.type,
text: token.text || '',
start: token.start,
stop: token.stop,
currentToken: token.text || '',
isInKey,
isInOperator,
isInValue,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
keyToken,
operatorToken,
valueToken,
queryPairs: queryPairs || [],
currentPair,
};
}
// Helper to determine token type for context
function determineTokenContext(
tokenType: number,
): {
isInKey: boolean;
isInOperator: boolean;
isInValue: boolean;
isInFunction: boolean;
isInConjunction: boolean;
isInParenthesis: boolean;
} {
// Key context
const isInKey = tokenType === FilterQueryLexer.KEY;
// Operator context
const isInOperator = [
FilterQueryLexer.EQUALS,
FilterQueryLexer.NOT_EQUALS,
FilterQueryLexer.NEQ,
FilterQueryLexer.LT,
FilterQueryLexer.LE,
FilterQueryLexer.GT,
FilterQueryLexer.GE,
FilterQueryLexer.LIKE,
FilterQueryLexer.NOT_LIKE,
FilterQueryLexer.ILIKE,
FilterQueryLexer.NOT_ILIKE,
FilterQueryLexer.BETWEEN,
FilterQueryLexer.EXISTS,
FilterQueryLexer.REGEXP,
FilterQueryLexer.CONTAINS,
FilterQueryLexer.IN,
FilterQueryLexer.NOT,
].includes(tokenType);
// Value context
const isInValue = [
FilterQueryLexer.QUOTED_TEXT,
FilterQueryLexer.NUMBER,
FilterQueryLexer.BOOL,
].includes(tokenType);
// Function context
const isInFunction = [
FilterQueryLexer.HAS,
FilterQueryLexer.HASANY,
FilterQueryLexer.HASALL,
FilterQueryLexer.HASNONE,
].includes(tokenType);
// Conjunction context
const isInConjunction = [FilterQueryLexer.AND, FilterQueryLexer.OR].includes(
tokenType,
);
// Parenthesis context
const isInParenthesis = [
FilterQueryLexer.LPAREN,
FilterQueryLexer.RPAREN,
FilterQueryLexer.LBRACK,
FilterQueryLexer.RBRACK,
].includes(tokenType);
return {
isInKey,
isInOperator,
isInValue,
isInFunction,
isInConjunction,
isInParenthesis,
};
}
/**
* Gets the current query context at the cursor position
* This is useful for determining what kind of suggestions to show
*
* The function now includes full query pair information:
* - queryPairs: All key-operator-value triplets in the query
* - currentPair: The pair at or before the current cursor position
*
* This enables more intelligent context-aware suggestions based on
* the current key, operator, and surrounding query structure.
*
* @param query The query string
* @param cursorIndex The position of the cursor in the query
* @returns The query context at the cursor position
*/
export function getQueryContextAtCursor(
query: string,
cursorIndex: number,
): IQueryContext {
try {
// Guard against infinite recursion by checking call stack
const stackTrace = new Error().stack || '';
const callCount = (stackTrace.match(/getQueryContextAtCursor/g) || []).length;
if (callCount > 3) {
console.warn(
'Potential infinite recursion detected in getQueryContextAtCursor',
);
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInKey: true,
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
queryPairs: [],
currentPair: null,
};
}
// First check if the cursor is at a token boundary or within a whitespace area
// This is critical for context detection
const isAtSpace = cursorIndex < query.length && query[cursorIndex] === ' ';
const isAfterSpace = cursorIndex > 0 && query[cursorIndex - 1] === ' ';
const isAfterToken = cursorIndex > 0 && query[cursorIndex - 1] !== ' ';
// Check if cursor is right after a token and at the start of a space
const isTransitionPoint = isAtSpace && isAfterToken;
// First normalize the query to handle multiple spaces
// We need to adjust cursorIndex based on space normalization
let adjustedCursorIndex = cursorIndex;
let spaceCount = 0;
let inQuotes = false;
// Count consecutive spaces before the cursor to adjust the cursor position
for (let i = 0; i < cursorIndex; i++) {
// Track quote state
if (query[i] === "'" && (i === 0 || query[i - 1] !== '\\')) {
inQuotes = !inQuotes;
}
// Only count spaces when not in quotes
if (!inQuotes && query[i] === ' ' && (i === 0 || query[i - 1] === ' ')) {
spaceCount++;
}
}
// Adjust cursor position based on removed spaces
adjustedCursorIndex = cursorIndex - spaceCount;
// Normalize the query by removing extra spaces when not in quotes
const normalizedQuery = normalizeSpaces(query);
// Create input stream and lexer with normalized query
const input = normalizedQuery || '';
const chars = CharStreams.fromString(input);
const lexer = new FilterQueryLexer(chars);
// Create token stream and force token generation
const tokenStream = new CommonTokenStream(lexer);
tokenStream.fill();
// Get all tokens including whitespace
const allTokens = tokenStream.tokens as IToken[];
// Find exact token at cursor, including whitespace
let exactToken: IToken | null = null;
let previousToken: IToken | null = null;
let nextToken: IToken | null = null;
// Find the real token at or just before the cursor
let lastTokenBeforeCursor: IToken | null = null;
for (let i = 0; i < allTokens.length; i++) {
const token = allTokens[i];
if (token.type === FilterQueryLexer.EOF) continue;
// Store this token if it's before or at the cursor position
if (token.stop < cursorIndex) {
lastTokenBeforeCursor = token;
}
// If we found a token that starts after the cursor, we're done searching
if (token.start > cursorIndex) {
break;
}
}
// Get query pairs information to enhance context
const queryPairs = extractQueryPairs(query);
// Find the current pair without causing a circular dependency
let currentPair: IQueryPair | null = null;
if (queryPairs.length > 0) {
// Look for the rightmost pair whose end position is before or at the cursor
let bestMatch: IQueryPair | null = null;
for (const pair of queryPairs) {
const { position } = pair;
// Find the rightmost position of this pair
const pairEnd =
position.valueEnd || position.operatorEnd || position.keyEnd;
// If this pair ends at or before the cursor, and it's further right than our previous best match
if (
pairEnd <= cursorIndex &&
(!bestMatch ||
pairEnd >
(bestMatch.position.valueEnd ||
bestMatch.position.operatorEnd ||
bestMatch.position.keyEnd))
) {
bestMatch = pair;
}
}
// If we found a match, use it
if (bestMatch) {
currentPair = bestMatch;
}
// If cursor is at the end, use the last pair
else if (cursorIndex >= query.length) {
currentPair = queryPairs[queryPairs.length - 1];
}
}
// Handle cursor at the very end of input
if (adjustedCursorIndex >= input.length && allTokens.length > 0) {
const lastRealToken = allTokens
.filter((t) => t.type !== FilterQueryLexer.EOF)
.pop();
if (lastRealToken) {
exactToken = lastRealToken;
previousToken =
allTokens.filter((t) => t.stop < lastRealToken.start).pop() || null;
}
} else {
// Normal token search
for (let i = 0; i < allTokens.length; i++) {
const token = allTokens[i];
// Skip EOF token in normal search
if (token.type === FilterQueryLexer.EOF) {
continue;
}
// Check if cursor is within token bounds (inclusive)
if (
token.start <= adjustedCursorIndex &&
adjustedCursorIndex <= token.stop + 1
) {
exactToken = token;
previousToken = i > 0 ? allTokens[i - 1] : null;
nextToken = i < allTokens.length - 1 ? allTokens[i + 1] : null;
break;
}
}
// If cursor is between tokens, find surrounding tokens
if (!exactToken) {
for (let i = 0; i < allTokens.length - 1; i++) {
const current = allTokens[i];
const next = allTokens[i + 1];
if (
current.type === FilterQueryLexer.EOF ||
next.type === FilterQueryLexer.EOF
) {
continue;
}
if (
current.stop + 1 < adjustedCursorIndex &&
adjustedCursorIndex < next.start
) {
previousToken = current;
nextToken = next;
break;
}
}
}
}
// If we don't have tokens yet, return default context
if (!previousToken && !nextToken && !exactToken && !lastTokenBeforeCursor) {
return {
tokenType: -1,
text: '',
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: '',
isInKey: true, // Default to key context when input is empty
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
queryPairs: queryPairs, // Add all query pairs to the context
currentPair: null, // No current pair when query is empty
};
}
// If we have a token and we're at a space after it (transition point),
// then we should progress the context
if (
lastTokenBeforeCursor &&
(isAtSpace || isAfterSpace || isTransitionPoint)
) {
const lastTokenContext = determineTokenContext(lastTokenBeforeCursor.type);
// Apply the context progression logic: key → operator → value → conjunction → key
if (lastTokenContext.isInKey) {
// If we just typed a key and then a space, we move to operator context
return {
tokenType: lastTokenBeforeCursor.type,
text: lastTokenBeforeCursor.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: lastTokenBeforeCursor.text,
isInKey: false,
isInOperator: true, // After key + space, should be operator context
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
keyToken: lastTokenBeforeCursor.text,
queryPairs: queryPairs,
currentPair: currentPair,
};
}
if (lastTokenContext.isInOperator) {
// If we just typed an operator and then a space, we move to value context
const keyFromPair = currentPair?.key || '';
return {
tokenType: lastTokenBeforeCursor.type,
text: lastTokenBeforeCursor.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: lastTokenBeforeCursor.text,
isInKey: false,
isInOperator: false,
isInValue: true, // After operator + space, should be value context
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
operatorToken: lastTokenBeforeCursor.text,
keyToken: keyFromPair, // Include key from current pair
queryPairs: queryPairs,
currentPair: currentPair,
};
}
if (lastTokenContext.isInValue) {
// If we just typed a value and then a space, we move to conjunction context
const keyFromPair = currentPair?.key || '';
const operatorFromPair = currentPair?.operator || '';
return {
tokenType: lastTokenBeforeCursor.type,
text: lastTokenBeforeCursor.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: lastTokenBeforeCursor.text,
isInKey: false,
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: true, // After value + space, should be conjunction context
isInParenthesis: false,
valueToken: lastTokenBeforeCursor.text,
keyToken: keyFromPair, // Include key from current pair
operatorToken: operatorFromPair, // Include operator from current pair
queryPairs: queryPairs,
currentPair: currentPair,
};
}
if (lastTokenContext.isInConjunction) {
// If we just typed a conjunction and then a space, we move to key context
return {
tokenType: lastTokenBeforeCursor.type,
text: lastTokenBeforeCursor.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: lastTokenBeforeCursor.text,
isInKey: true, // After conjunction + space, should be key context
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
queryPairs: queryPairs,
currentPair: currentPair,
};
}
}
// Regular token-based context detection (when cursor is directly on a token)
if (exactToken?.channel === 0) {
const tokenContext = determineTokenContext(exactToken.type);
// Get relevant tokens based on current pair
const keyFromPair = currentPair?.key || '';
const operatorFromPair = currentPair?.operator || '';
const valueFromPair = currentPair?.value || '';
return {
tokenType: exactToken.type,
text: exactToken.text,
start: exactToken.start,
stop: exactToken.stop,
currentToken: exactToken.text,
...tokenContext,
keyToken: tokenContext.isInKey
? exactToken.text
: tokenContext.isInOperator || tokenContext.isInValue
? keyFromPair
: undefined,
operatorToken: tokenContext.isInOperator
? exactToken.text
: tokenContext.isInValue
? operatorFromPair
: undefined,
valueToken: tokenContext.isInValue ? exactToken.text : undefined,
queryPairs: queryPairs,
currentPair: currentPair,
};
}
// If we're between tokens but not after a space, use previous token to determine context
if (previousToken?.channel === 0) {
const prevContext = determineTokenContext(previousToken.type);
// Get relevant tokens based on current pair
const keyFromPair = currentPair?.key || '';
const operatorFromPair = currentPair?.operator || '';
const valueFromPair = currentPair?.value || '';
// CRITICAL FIX: Check if the last meaningful token is an operator
// If so, we're always in the value context regardless of spaces
if (prevContext.isInOperator) {
// If previous token is operator, we must be in value context
return {
tokenType: previousToken.type,
text: previousToken.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: previousToken.text,
isInKey: false,
isInOperator: false,
isInValue: true, // Always in value context after operator
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
operatorToken: previousToken.text,
keyToken: keyFromPair, // Include key from current pair
queryPairs: queryPairs,
currentPair: currentPair,
};
}
// Maintain the strict progression key → operator → value → conjunction → key
if (prevContext.isInKey) {
return {
tokenType: previousToken.type,
text: previousToken.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: previousToken.text,
isInKey: false,
isInOperator: true, // After key, progress to operator context
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
keyToken: previousToken.text,
queryPairs: queryPairs,
currentPair: currentPair,
};
}
if (prevContext.isInValue) {
return {
tokenType: previousToken.type,
text: previousToken.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: previousToken.text,
isInKey: false,
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: true, // After value, progress to conjunction context
isInParenthesis: false,
valueToken: previousToken.text,
keyToken: keyFromPair, // Include key from current pair
operatorToken: operatorFromPair, // Include operator from current pair
queryPairs: queryPairs,
currentPair: currentPair,
};
}
if (prevContext.isInConjunction) {
return {
tokenType: previousToken.type,
text: previousToken.text,
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: previousToken.text,
isInKey: true, // After conjunction, progress back to key context
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
queryPairs: queryPairs,
currentPair: currentPair,
};
}
}
// Default fallback to key context
return {
tokenType: -1,
text: '',
start: adjustedCursorIndex,
stop: adjustedCursorIndex,
currentToken: '',
isInKey: true,
isInOperator: false,
isInValue: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
queryPairs: queryPairs,
currentPair: currentPair,
};
} catch (error) {
console.error('Error in getQueryContextAtCursor:', error);
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: true, // Default to key context on error
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
queryPairs: [],
currentPair: null,
};
}
}
/**
* Extracts all key-operator-value triplets from a query string
* This is useful for getting value suggestions based on the current key and operator
*
* @param query The query string to parse
* @returns An array of IQueryPair objects representing the key-operator-value triplets
*/
export function extractQueryPairs(query: string): IQueryPair[] {
try {
// Guard against infinite recursion by checking call stack
const stackTrace = new Error().stack || '';
const callCount = (stackTrace.match(/extractQueryPairs/g) || []).length;
if (callCount > 3) {
console.warn('Potential infinite recursion detected in extractQueryPairs');
return [];
}
// Normalize the query to handle multiple spaces
const normalizedQuery = normalizeSpaces(query);
// Create input stream and lexer with normalized query
const input = normalizedQuery || '';
const chars = CharStreams.fromString(input);
const lexer = new FilterQueryLexer(chars);
// Create token stream and force token generation
const tokenStream = new CommonTokenStream(lexer);
tokenStream.fill();
// Get all tokens including whitespace
const allTokens = tokenStream.tokens as IToken[];
const queryPairs: IQueryPair[] = [];
let currentPair: Partial<IQueryPair> | null = null;
// Process tokens to build triplets
for (let i = 0; i < allTokens.length; i++) {
const token = allTokens[i];
// Skip EOF and whitespace tokens
if (token.type === FilterQueryLexer.EOF || token.channel !== 0) {
continue;
}
// If token is a KEY, start a new pair
if (token.type === FilterQueryLexer.KEY) {
// If we have an existing incomplete pair, add it to the result
if (currentPair && currentPair.key) {
queryPairs.push({
key: currentPair.key,
operator: currentPair.operator || '',
value: currentPair.value,
position: {
keyStart: currentPair.position?.keyStart || 0,
keyEnd: currentPair.position?.keyEnd || 0,
operatorStart: currentPair.position?.operatorStart || 0,
operatorEnd: currentPair.position?.operatorEnd || 0,
valueStart: currentPair.position?.valueStart,
valueEnd: currentPair.position?.valueEnd,
},
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
),
} as IQueryPair);
}
// Start a new pair
currentPair = {
key: token.text,
position: {
keyStart: token.start,
keyEnd: token.stop,
operatorStart: 0, // Initialize with default values
operatorEnd: 0, // Initialize with default values
},
};
}
// If token is an operator and we have a key, add the operator
else if (
isOperatorToken(token.type) &&
currentPair &&
currentPair.key &&
!currentPair.operator
) {
currentPair.operator = token.text;
// Ensure we create a valid position object with all required fields
currentPair.position = {
keyStart: currentPair.position?.keyStart || 0,
keyEnd: currentPair.position?.keyEnd || 0,
operatorStart: token.start,
operatorEnd: token.stop,
valueStart: currentPair.position?.valueStart,
valueEnd: currentPair.position?.valueEnd,
};
}
// If token is a value and we have a key and operator, add the value
else if (
isValueToken(token.type) &&
currentPair &&
currentPair.key &&
currentPair.operator &&
!currentPair.value
) {
currentPair.value = token.text;
// Ensure we create a valid position object with all required fields
currentPair.position = {
keyStart: currentPair.position?.keyStart || 0,
keyEnd: currentPair.position?.keyEnd || 0,
operatorStart: currentPair.position?.operatorStart || 0,
operatorEnd: currentPair.position?.operatorEnd || 0,
valueStart: token.start,
valueEnd: token.stop,
};
}
// If token is a conjunction (AND/OR), finalize the current pair
else if (isConjunctionToken(token.type) && currentPair && currentPair.key) {
queryPairs.push({
key: currentPair.key,
operator: currentPair.operator || '',
value: currentPair.value,
position: {
keyStart: currentPair.position?.keyStart || 0,
keyEnd: currentPair.position?.keyEnd || 0,
operatorStart: currentPair.position?.operatorStart || 0,
operatorEnd: currentPair.position?.operatorEnd || 0,
valueStart: currentPair.position?.valueStart,
valueEnd: currentPair.position?.valueEnd,
},
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
),
} as IQueryPair);
// Reset for the next pair
currentPair = null;
}
}
// Add the last pair if not already added
if (currentPair && currentPair.key) {
queryPairs.push({
key: currentPair.key,
operator: currentPair.operator || '',
value: currentPair.value,
position: {
keyStart: currentPair.position?.keyStart || 0,
keyEnd: currentPair.position?.keyEnd || 0,
operatorStart: currentPair.position?.operatorStart || 0,
operatorEnd: currentPair.position?.operatorEnd || 0,
valueStart: currentPair.position?.valueStart,
valueEnd: currentPair.position?.valueEnd,
},
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
),
} as IQueryPair);
}
return queryPairs;
} catch (error) {
console.error('Error in extractQueryPairs:', error);
return [];
}
}
/**
* Gets the current query pair at the cursor position
* This is useful for getting suggestions based on the current context
* The function finds the rightmost complete pair that ends before or at the cursor position
*
* @param query The query string
* @param cursorIndex The position of the cursor in the query
* @returns The query pair at the cursor position, or null if not found
*/
export function getCurrentQueryPair(
query: string,
cursorIndex: number,
): IQueryPair | null {
try {
const queryPairs = extractQueryPairs(query);
// Removed the circular dependency by not calling getQueryContextAtCursor here
// If we have pairs, try to find the one at the cursor position
if (queryPairs.length > 0) {
// Look for the rightmost pair whose end position is before or at the cursor
let bestMatch: IQueryPair | null = null;
for (const pair of queryPairs) {
const { position } = pair;
// Find the rightmost position of this pair
const pairEnd =
position.valueEnd || position.operatorEnd || position.keyEnd;
// If this pair ends at or before the cursor, and it's further right than our previous best match
if (
pairEnd <= cursorIndex &&
(!bestMatch ||
pairEnd >
(bestMatch.position.valueEnd ||
bestMatch.position.operatorEnd ||
bestMatch.position.keyEnd))
) {
bestMatch = pair;
}
}
// If we found a match, return it
if (bestMatch) {
return bestMatch;
}
// If cursor is at the very beginning, before any pairs, return null
if (cursorIndex === 0) {
return null;
}
// If no match found and cursor is at the end, return the last pair
if (cursorIndex >= query.length && queryPairs.length > 0) {
return queryPairs[queryPairs.length - 1];
}
}
// If no valid pair is found, and we cannot infer one from context, return null
return null;
} catch (error) {
console.error('Error in getCurrentQueryPair:', error);
return null;
}
}
// Helper function to check if a token is an operator
function isOperatorToken(tokenType: number): boolean {
return [
FilterQueryLexer.EQUALS,
FilterQueryLexer.NOT_EQUALS,
FilterQueryLexer.NEQ,
FilterQueryLexer.LT,
FilterQueryLexer.LE,
FilterQueryLexer.GT,
FilterQueryLexer.GE,
FilterQueryLexer.LIKE,
FilterQueryLexer.NOT_LIKE,
FilterQueryLexer.ILIKE,
FilterQueryLexer.NOT_ILIKE,
FilterQueryLexer.BETWEEN,
FilterQueryLexer.EXISTS,
FilterQueryLexer.REGEXP,
FilterQueryLexer.CONTAINS,
FilterQueryLexer.IN,
FilterQueryLexer.NOT,
].includes(tokenType);
}
// Helper function to check if a token is a value
function isValueToken(tokenType: number): boolean {
return [
FilterQueryLexer.QUOTED_TEXT,
FilterQueryLexer.NUMBER,
FilterQueryLexer.BOOL,
].includes(tokenType);
}
// Helper function to check if a token is a conjunction
function isConjunctionToken(tokenType: number): boolean {
return [FilterQueryLexer.AND, FilterQueryLexer.OR].includes(tokenType);
}
/**
* Usage example for query context with pairs:
*
* ```typescript
* // Get context at cursor position
* const context = getQueryContextAtCursor(query, cursorPosition);
*
* // Access all query pairs
* const allPairs = context.queryPairs || [];
* console.log(`Query contains ${allPairs.length} key-operator-value triplets`);
*
* // Access the current pair at cursor
* if (context.currentPair) {
* // Use the current triplet to provide relevant suggestions
* const { key, operator, value } = context.currentPair;
* console.log(`Current context: ${key} ${operator} ${value || ''}`);
*
* // Check if this is a complete triplet
* if (context.currentPair.isComplete) {
* // All parts (key, operator, value) are present
* } else {
* // Incomplete - might be missing operator or value
* }
* } else {
* // No current pair, likely at the start of a new condition
* }
* ```
*/