From a242fd3846a0ea323962b095d3abb07642a4fb39 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Tue, 6 May 2025 00:02:08 +0530 Subject: [PATCH] feat: improve suggestions --- .../CodeMirrorWhereClause.styles.scss | 72 ++ .../CodeMirrorWhereClause.tsx | 480 +++++++-- frontend/src/types/antlrQueryTypes.ts | 17 + frontend/src/utils/antlrQueryUtils.ts | 2 +- frontend/src/utils/queryContextUtils.ts | 924 ++++++++++++++++++ 5 files changed, 1403 insertions(+), 92 deletions(-) create mode 100644 frontend/src/utils/queryContextUtils.ts diff --git a/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.styles.scss b/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.styles.scss index 331c03e7abce..413f9258d54c 100644 --- a/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.styles.scss +++ b/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.styles.scss @@ -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); + } + } } } diff --git a/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.tsx b/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.tsx index 4939046ecfe9..085f1429a4b4 100644 --- a/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.tsx +++ b/frontend/src/components/QueryBuilderV2/CodeMirrorWhereClause/CodeMirrorWhereClause.tsx @@ -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(''); const [valueSuggestions, setValueSuggestions] = useState([ @@ -178,11 +187,23 @@ function CodeMirrorWhereClause(): JSX.Element { // Reference to the editor view for programmatic autocompletion const editorRef = useRef(null); const lastKeyRef = useRef(''); + const isMountedRef = useRef(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 => { - 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 Unknown; + + switch (editingMode) { + case 'key': + return Key; + case 'operator': + return Operator; + case 'value': + return Value; + case 'conjunction': + return Conjunction; + case 'function': + return Function; + case 'parenthesis': + return Parenthesis; + default: + return Unknown; + } + }; + + // 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 (
+ {/* Add a context indicator banner */} + {editingMode && ( +
+ Currently editing: {renderContextBadge()} + {queryContext?.keyToken && ( + + Key: {queryContext.keyToken} + + )} + {queryContext?.operatorToken && ( + + Operator: {queryContext.operatorToken} + + )} + {queryContext?.valueToken && ( + + Value: {queryContext.valueToken} + + )} + {queryContext?.currentPair && ( + + Current pair: {queryContext.currentPair.key} + {queryContext.currentPair.operator} + {queryContext.currentPair.value && ( + {queryContext.currentPair.value} + )} + + {queryContext.currentPair.isComplete ? 'Complete' : 'Incomplete'} + + + )} + {queryContext?.queryPairs && queryContext.queryPairs.length > 0 && ( + + Total pairs: {queryContext.queryPairs.length} + + )} +
+ )} + { }; // Helper function to find key-operator-value triplets in token stream -function findKeyOperatorValueTriplet( +export function findKeyOperatorValueTriplet( allTokens: IToken[], currentToken: IToken, isInKey: boolean, diff --git a/frontend/src/utils/queryContextUtils.ts b/frontend/src/utils/queryContextUtils.ts new file mode 100644 index 000000000000..cb8c1a9d7384 --- /dev/null +++ b/frontend/src/utils/queryContextUtils.ts @@ -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 | 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 + * } + * ``` + */