import { TokenKind } from './types'; import type { Token, Situation } from './types'; import { tokenize, nonWhitespace, decodeUnquotedLiteral, decodeQuotedString } from './lexer'; import { parse } from './parser'; // ─── Group context ──────────────────────────────────────────────── interface GroupFrame { type: 'paren' | 'brace'; /** For value lists (field:( ... )): the field name the list belongs to. */ fieldName?: string; /** For nested queries (path:{ ... }): the accumulated dotted path. */ nestedPath?: string; } function isRangeOpKind(kind: TokenKind): boolean { return kind === TokenKind.Lt || kind === TokenKind.Lte || kind === TokenKind.Gt || kind === TokenKind.Gte; } function decodeLiteralToken(token: Token): string { return token.kind === TokenKind.String ? decodeQuotedString(token.value) : decodeUnquotedLiteral(token.value).trim(); } /** True when a String token represents an unterminated quoted string. */ function isUnclosedString(token: Token): boolean { const v = token.value; if (v.length < 2) return true; if (!v.endsWith('"')) return true; let backslashes = 0; for (let i = v.length - 2; i >= 1 && v[i] === '\\'; i--) { backslashes++; } return backslashes % 2 === 1; } function innermostPath(frames: GroupFrame[]): string | undefined { for (let i = frames.length - 1; i >= 0; i--) { if (frames[i].type === 'brace') return frames[i].nestedPath; } return undefined; } function joinPath(parentPath: string | undefined, fieldTok: Token | null): string | undefined { const name = fieldTok && (fieldTok.kind === TokenKind.Ident || fieldTok.kind === TokenKind.String) ? decodeLiteralToken(fieldTok) : ''; if (!name) return parentPath || undefined; return parentPath ? `${parentPath}.${name}` : name; } interface ScanResult { active: Token | null; prev: Token | null; prevPrev: Token | null; frames: GroupFrame[]; } /** * Walk tokens that lie fully before the cursor, tracking open groups. * An identifier whose end is exactly at the cursor (and does not end with * whitespace) still counts as "active": the word is being typed/completed. */ function scanTokens(tokens: Token[], offset: number): ScanResult { const frames: GroupFrame[] = []; let active: Token | null = null; let prev: Token | null = null; let prevPrev: Token | null = null; for (let i = 0; i < tokens.length; i++) { const t = tokens[i]; if (t.kind === TokenKind.EOF) break; const wordBeingCompleted = (t.kind === TokenKind.Ident && t.end === offset && !/\s$/.test(t.value)) || (t.kind === TokenKind.String && t.end === offset && isUnclosedString(t)); if (t.start < offset && (t.end > offset || wordBeingCompleted)) { active = t; break; } if (t.start >= offset) break; if (t.kind === TokenKind.LParen) { const isValueList = prev !== null && prev.kind === TokenKind.Colon; const fieldTok = isValueList && i >= 2 ? tokens[i - 2] : null; const parentPath = innermostPath(frames); frames.push({ type: 'paren', fieldName: isValueList ? joinPath(parentPath, fieldTok) : undefined }); } else if (t.kind === TokenKind.LCurly) { const isNested = prev !== null && prev.kind === TokenKind.Colon; const fieldTok = isNested && i >= 2 ? tokens[i - 2] : null; const parentPath = innermostPath(frames); frames.push({ type: 'brace', nestedPath: joinPath(parentPath, fieldTok) ?? '' }); } else if (t.kind === TokenKind.RParen || t.kind === TokenKind.RCurly) { const opener = t.kind === TokenKind.RParen ? 'paren' : 'brace'; for (let j = frames.length - 1; j >= 0; j--) { if (frames[j].type === opener) { frames.splice(j, 1); break; } } } prevPrev = prev; prev = t; } return { active, prev, prevPrev, frames }; } // ─── Main locate function ───────────────────────────────────────── export function locate(input: string, offset: number): Situation { const tokens = nonWhitespace(tokenize(input)); const { active, prev, prevPrev, frames } = scanTokens(tokens, offset); const top = frames.length > 0 ? frames[frames.length - 1] : null; const inValueList = top?.type === 'paren' && top.fieldName !== undefined; const inNested = top?.type === 'brace'; const nestedPath = top?.type === 'brace' ? top.nestedPath : undefined; const listFieldName = top?.fieldName; // ── Prefix / replace range ── let prefix = ''; let replaceStart = offset; if (active && active.kind === TokenKind.Ident) { prefix = active.value.slice(0, offset - active.start); replaceStart = active.start; } else if (active && active.kind === TokenKind.String) { prefix = active.value.slice(1, offset - active.start); replaceStart = active.start + 1; } const sit = (kind: Situation['kind'], extra: Partial = {}): Situation => ({ kind, prefix, replaceRange: { start: replaceStart, end: offset }, ...extra, }); const resolveFieldName = (fieldTok: Token | null): string | undefined => { if (!fieldTok) return undefined; const name = joinPath(innermostPath(frames), fieldTok); return name || undefined; }; const prevKind = prev ? prev.kind : null; const afterValueOperator = prevKind === TokenKind.Colon || isRangeOpKind(prevKind as TokenKind); const afterConjunction = prevKind === TokenKind.And || prevKind === TokenKind.Or || prevKind === TokenKind.Not; // ── Cursor strictly inside (or word-completing) a token ── if (active) { if (active.kind === TokenKind.String) { if (afterValueOperator) return sit('IN_VALUE', { fieldName: resolveFieldName(prevPrev), insideString: true }); if (inValueList) return sit('IN_VALUE_LIST', { fieldName: listFieldName, insideString: true }); if (inNested) return sit('IN_NESTED', { nestedPath, insideString: true }); return sit('IN_FIELD', { insideString: true }); } if (active.kind === TokenKind.Ident) { if (afterValueOperator) return sit('IN_VALUE', { fieldName: resolveFieldName(prevPrev) }); if (afterConjunction) { if (inValueList) return sit('IN_VALUE_LIST', { fieldName: listFieldName }); if (inNested) return sit('IN_NESTED', { nestedPath }); return sit('NONE'); } if (inValueList) return sit('IN_VALUE_LIST', { fieldName: listFieldName }); if (inNested) return sit('IN_NESTED', { nestedPath }); return sit('IN_FIELD'); } // Cursor inside an operator/bracket token — fall through to boundary logic } // ── Cursor at a token boundary ── if (!prev) return sit('NONE'); if (afterValueOperator) { return sit('IN_VALUE', { fieldName: resolveFieldName(prevPrev) }); } if (prevKind === TokenKind.LParen) { return inValueList ? sit('IN_VALUE_LIST', { fieldName: listFieldName }) : sit('NONE'); } if (prevKind === TokenKind.LCurly) { return sit('IN_NESTED', { nestedPath }); } if (afterConjunction) { if (inValueList) return sit('IN_VALUE_LIST', { fieldName: listFieldName }); if (inNested) return sit('IN_NESTED', { nestedPath }); return sit('NONE'); } if (prevKind === TokenKind.RParen || prevKind === TokenKind.RCurly) { if (inValueList) return sit('IN_VALUE_LIST', { fieldName: listFieldName }); return sit('AFTER_EXPRESSION'); } if (prevKind === TokenKind.String) { if (inValueList) return sit('IN_VALUE_LIST', { fieldName: listFieldName }); return sit('AFTER_EXPRESSION'); } if (prevKind === TokenKind.Ident) { // A word just ended (possibly with whitespace absorbed into the literal token) const valueOpBefore = prevPrev !== null && (prevPrev.kind === TokenKind.Colon || isRangeOpKind(prevPrev.kind)); const trailing = prev ? /\s+$/.exec(prev.value) : null; const wordEnd = prev && trailing ? offset - trailing[0].length : offset; if (valueOpBefore) { if (trailing) { // The value word is finished (whitespace absorbed after it) → conjunctions next return { kind: 'AFTER_EXPRESSION', prefix: '', replaceRange: { start: offset, end: offset } }; } return { kind: 'IN_VALUE', prefix: prev ? prev.value.slice(0, wordEnd - prev.start) : '', replaceRange: { start: prev ? prev.start : offset, end: offset }, fieldName: resolveFieldName(prevPrev), }; } const wordPrefix = prev ? prev.value.slice(0, wordEnd - prev.start) : ''; if (inValueList) { return { kind: 'IN_VALUE_LIST', prefix: wordPrefix, replaceRange: { start: prev ? prev.start : offset, end: offset }, fieldName: listFieldName }; } if (inNested) { return { kind: 'IN_NESTED', prefix: wordPrefix, replaceRange: { start: prev ? prev.start : offset, end: offset }, nestedPath }; } // Replace the absorbed whitespace so ": " lands as "field: " return { kind: 'AFTER_FIELD', prefix: wordPrefix, replaceRange: { start: wordEnd, end: offset } }; } return sit('NONE'); } // ─── Convenience: parse + locate in one call ────────────────────── export function parseAndLocate( input: string, offset: number, ): { ast: ReturnType; situation: Situation; } { return { ast: parse(input), situation: locate(input, offset), }; }