import { AnalyzeSegment, FilterNode, LogQLAST, SearchExpr, Situation, Token, TokenKind, } from './types'; const ANALYZE_FIELD_BEFORE = new Set([ 'SELECT', 'WHERE', 'AND', 'OR', 'NOT', 'BY', 'ON', 'CASE', 'WHEN', 'THEN', 'ELSE', 'IN', 'LIKE', 'BETWEEN', 'IS', 'AS', 'HAVING', ]); const ANALYZE_KEYWORD_BEFORE = new Set([ 'FROM', ]); /** * Decide what kind of completion to offer for the cursor offset. * * The implementation is intentionally permissive: when in doubt we err on the * side of offering MORE suggestions (field name + keywords) rather than no * suggestions, because Monaco filters them by prefix anyway. */ export function locate(offset: number, ast: LogQLAST, tokens: Token[]): Situation { const { activeToken, prevToken, prefix, replaceStart } = describeCursor(offset, tokens); if (insideUnclosedString(activeToken, offset)) { return makeSituation(ast, offset, { section: sectionAt(offset, ast), kind: 'IN_STRING', prefix, replaceRange: { start: replaceStart, end: offset }, }); } // `$` → variable trigger if (prevToken?.kind === TokenKind.Dollar) { return makeSituation(ast, offset, { section: sectionAt(offset, ast), kind: 'VARIABLE', prefix: '', replaceRange: { start: prevToken.start, end: offset }, }); } // Inside a partially-typed `${...}` (handled as Dollar by lexer if `}` missing). if (activeToken?.kind === TokenKind.Variable) { return makeSituation(ast, offset, { section: sectionAt(offset, ast), kind: 'VARIABLE', prefix: activeToken.name ?? '', replaceRange: { start: activeToken.start, end: activeToken.end }, }); } const section = sectionAt(offset, ast); if (section === 'analyze') { return locateAnalyze(ast.analyze!, offset, prevToken, prefix, replaceStart); } return locateSearch(ast.search, offset, prevToken, prefix, replaceStart, tokens); } function makeSituation(_ast: LogQLAST, _offset: number, s: Situation): Situation { return s; } function sectionAt(offset: number, ast: LogQLAST): 'search' | 'analyze' { if (ast.pipe && offset > ast.pipe.start) return 'analyze'; return 'search'; } function describeCursor(offset: number, tokens: Token[]) { let activeToken: Token | undefined; let prevToken: Token | undefined; for (const t of tokens) { if (t.kind === TokenKind.EOF) break; // Word-like tokens absorb the cursor at their end position so the user // can complete the partial identifier they just typed. Punctuation // (`:`, `=`, etc.) does NOT absorb so the cursor flows to the next // suggestion site (e.g. value position after `:`). const isWordToken = t.kind === TokenKind.Ident || t.kind === TokenKind.Keyword || t.kind === TokenKind.Variable || t.kind === TokenKind.String; const inside = isWordToken ? offset > t.start && offset <= t.end : offset > t.start && offset < t.end; if (inside) { activeToken = t; break; } if (t.end <= offset && t.kind !== TokenKind.Whitespace) { prevToken = t; } if (t.start > offset) break; } if (activeToken && activeToken.kind === TokenKind.Whitespace) { activeToken = undefined; } let prefix = ''; let replaceStart = offset; if (activeToken && (activeToken.kind === TokenKind.Ident || activeToken.kind === TokenKind.Keyword)) { prefix = activeToken.value.slice(0, offset - activeToken.start); replaceStart = activeToken.start; } return { activeToken, prevToken, prefix, replaceStart }; } function insideUnclosedString(activeToken: Token | undefined, offset: number): boolean { if (!activeToken || activeToken.kind !== TokenKind.String) return false; // Inside a quoted literal — only treat as IN_STRING when the cursor is // strictly between the quotes (not on the closing quote). if (activeToken.unterminated) return true; return offset > activeToken.start && offset < activeToken.end; } /* ---------- Search segment ---------- */ function locateSearch( expr: SearchExpr | undefined, offset: number, prevToken: Token | undefined, prefix: string, replaceStart: number, _tokens: Token[], ): Situation { // If the previous meaningful token is `:` or `=`, the user is filling a value. if (prevToken && prevToken.kind === TokenKind.Operator && (prevToken.value === ':' || prevToken.value === '=')) { const filter = findFilterEndingAt(expr, prevToken.end); return { section: 'search', kind: 'FIELD_VALUE', prefix, fieldName: filter?.field?.value, replaceRange: { start: replaceStart, end: offset }, }; } // If the cursor is on the field-value side of an existing FilterNode. if (expr) { const filter = findFilterCoveringValue(expr, offset); if (filter) { return { section: 'search', kind: 'FIELD_VALUE', prefix, fieldName: filter.field?.value, replaceRange: { start: replaceStart, end: offset }, }; } } return { section: 'search', kind: 'FIELD_NAME', prefix, replaceRange: { start: replaceStart, end: offset }, }; } function findFilterEndingAt(expr: SearchExpr | undefined, end: number): FilterNode | undefined { if (!expr) return undefined; switch (expr.type) { case 'Filter': if (expr.op && expr.op.end === end) return expr; return undefined; case 'Binary': return findFilterEndingAt(expr.left, end) ?? findFilterEndingAt(expr.right, end); case 'Unary': return findFilterEndingAt(expr.expr, end); case 'Group': return findFilterEndingAt(expr.expr, end); default: return undefined; } } function findFilterCoveringValue(expr: SearchExpr, offset: number): FilterNode | undefined { switch (expr.type) { case 'Filter': { if (!expr.op) return undefined; // Cursor sits after the operator within the filter's range. if (offset > expr.op.end && offset <= expr.range[1]) return expr; return undefined; } case 'Binary': { const l = findFilterCoveringValue(expr.left, offset); if (l) return l; return findFilterCoveringValue(expr.right, offset); } case 'Unary': return findFilterCoveringValue(expr.expr, offset); case 'Group': return expr.expr ? findFilterCoveringValue(expr.expr, offset) : undefined; default: return undefined; } } /* ---------- Analyze segment ---------- */ function locateAnalyze( segment: AnalyzeSegment, offset: number, prevToken: Token | undefined, prefix: string, replaceStart: number, ): Situation { const meaningfulPrev = findPrevMeaningful(segment.tokens, offset); const prevValue = (meaningfulPrev?.kind === TokenKind.Keyword || meaningfulPrev?.kind === TokenKind.Ident) ? (meaningfulPrev.value || '').toUpperCase() : meaningfulPrev?.value; // After `:` / `=` users probably want a value — fall through to field name set // (analyze segments don't typically use those operators in front of a value). // Right after an opening paren that follows an identifier → likely function args. if (prevToken?.kind === TokenKind.LParen) { return { section: 'analyze', kind: 'ANALYZE_FIELD', prefix, replaceRange: { start: replaceStart, end: offset }, }; } if (prevValue && ANALYZE_FIELD_BEFORE.has(prevValue)) { return { section: 'analyze', kind: 'ANALYZE_FIELD', prefix, replaceRange: { start: replaceStart, end: offset }, }; } if (prevValue && ANALYZE_KEYWORD_BEFORE.has(prevValue)) { return { section: 'analyze', kind: 'ANALYZE_FIELD', prefix, replaceRange: { start: replaceStart, end: offset }, }; } return { section: 'analyze', kind: 'ANALYZE_KEYWORD', prefix, replaceRange: { start: replaceStart, end: offset }, }; } function findPrevMeaningful(tokens: Token[], offset: number): Token | undefined { let prev: Token | undefined; for (const t of tokens) { if (t.kind === TokenKind.Whitespace || t.kind === TokenKind.EOF) continue; if (t.end <= offset) prev = t; else if (t.start >= offset) break; } return prev; }