import { tokenize, nonWhitespace } from './lexer'; import { AnalyzeSegment, BinaryNode, FilterNode, GroupNode, LogQLAST, SearchExpr, TermNode, Token, TokenKind, UnaryNode, ValueNode, } from './types'; /** * Container around the lexer output that exposes a non-whitespace cursor while * remembering the full token stream (used downstream by the locator). */ class TokenCursor { /** All tokens including whitespace; consumers need offsets from this list. */ readonly all: Token[]; /** Whitespace-stripped tokens — what the recursive-descent parser walks. */ private readonly toks: Token[]; private idx = 0; constructor(all: Token[]) { this.all = all; this.toks = nonWhitespace(all); } peek(offset = 0): Token { return this.toks[this.idx + offset] ?? this.toks[this.toks.length - 1]; } consume(): Token { const t = this.peek(); if (this.idx < this.toks.length - 1) this.idx++; return t; } match(kind: TokenKind): boolean { return this.peek().kind === kind; } eof(): boolean { return this.peek().kind === TokenKind.EOF; } } export interface ParseResult { ast: LogQLAST; tokens: Token[]; } export function parse(input: string): ParseResult { const tokens = tokenize(input); // Locate the top-level pipe (if any) by scanning the original token stream; // the lexer already handles brackets/strings, so a Pipe token there is by // definition top-level. const pipeIdx = tokens.findIndex((t) => t.kind === TokenKind.Pipe); const searchTokens = pipeIdx === -1 ? tokens : tokens.slice(0, pipeIdx).concat([ { kind: TokenKind.EOF, value: '', start: tokens[pipeIdx].start, end: tokens[pipeIdx].start }, ]); const analyzeTokens = pipeIdx === -1 ? [] : tokens.slice(pipeIdx + 1); let searchExpr: SearchExpr | undefined; if (pipeIdx !== 0 && hasMeaningfulToken(searchTokens)) { const cursor = new TokenCursor(searchTokens); searchExpr = parseOr(cursor); } let analyzeSegment: AnalyzeSegment | undefined; if (pipeIdx !== -1) { const meaningful = analyzeTokens.filter( (t) => t.kind !== TokenKind.Whitespace && t.kind !== TokenKind.EOF, ); const start = tokens[pipeIdx].end; const end = meaningful.length ? meaningful[meaningful.length - 1].end : start; analyzeSegment = { type: 'Analyze', tokens: analyzeTokens.filter((t) => t.kind !== TokenKind.EOF), range: [start, end], }; } const ast: LogQLAST = { type: 'Query', search: searchExpr, pipe: pipeIdx === -1 ? undefined : tokens[pipeIdx], analyze: analyzeSegment, range: [0, input.length], }; return { ast, tokens }; } function hasMeaningfulToken(toks: Token[]): boolean { return toks.some((t) => t.kind !== TokenKind.Whitespace && t.kind !== TokenKind.EOF); } /* ---------- Recursive descent for the search segment ---------- */ // Precedence (low → high): OR < AND (implicit and explicit) < NOT < primary. function parseOr(c: TokenCursor): SearchExpr { let left = parseAnd(c); while (!c.eof() && isKeyword(c.peek(), 'OR')) { const opToken = c.consume(); const right = parseAnd(c); const node: BinaryNode = { type: 'Binary', op: 'OR', opToken, left, right, range: [left.range[0], right.range[1]], }; left = node; } return left; } function parseAnd(c: TokenCursor): SearchExpr { let left = parseNot(c); while (!c.eof() && canStartImplicitAnd(c)) { let opToken: Token | undefined; if (isKeyword(c.peek(), 'AND')) { opToken = c.consume(); } const right = parseNot(c); const node: BinaryNode = { type: 'Binary', op: 'AND', opToken: opToken ?? syntheticAndToken(left.range[1]), left, right, range: [left.range[0], right.range[1]], }; left = node; } return left; } function canStartImplicitAnd(c: TokenCursor): boolean { const t = c.peek(); if (isKeyword(t, 'OR')) return false; if (isKeyword(t, 'AND')) return true; // Implicit AND is allowed before any primary-starting token. return canStartPrimary(t); } function canStartPrimary(t: Token): boolean { switch (t.kind) { case TokenKind.Ident: case TokenKind.String: case TokenKind.Number: case TokenKind.Variable: case TokenKind.Wildcard: case TokenKind.LParen: case TokenKind.LBracket: case TokenKind.Dollar: return true; case TokenKind.Keyword: return t.value === 'NOT'; default: return false; } } function parseNot(c: TokenCursor): SearchExpr { if (isKeyword(c.peek(), 'NOT')) { const opToken = c.consume(); const expr = parseNot(c); const node: UnaryNode = { type: 'Unary', op: 'NOT', opToken, expr, range: [opToken.start, expr.range[1]], }; return node; } return parsePrimary(c); } function parsePrimary(c: TokenCursor): SearchExpr { const t = c.peek(); if (t.kind === TokenKind.LParen) { return parseGroup(c); } if (t.kind === TokenKind.LBracket) { return parseRangeAsTerm(c); } if (t.kind === TokenKind.Ident) { return parseIdentStart(c); } if (t.kind === TokenKind.String || t.kind === TokenKind.Number || t.kind === TokenKind.Wildcard) { const value = c.consume(); const term: TermNode = { type: 'Term', value: { type: 'Literal', token: value }, range: [value.start, value.end], }; return term; } if (t.kind === TokenKind.Variable) { const value = c.consume(); const term: TermNode = { type: 'Term', value: { type: 'Variable', token: value, name: value.name ?? '' }, range: [value.start, value.end], }; return term; } if (t.kind === TokenKind.Dollar) { const value = c.consume(); const term: TermNode = { type: 'Term', value: { type: 'Literal', token: value }, range: [value.start, value.end], }; return term; } // Fallback: consume one token to avoid infinite loops. const consumed = c.consume(); return { type: 'Error', range: [consumed.start, consumed.end] }; } function parseGroup(c: TokenCursor): GroupNode { const open = c.consume(); if (c.peek().kind === TokenKind.RParen) { const close = c.consume(); return { type: 'Group', expr: undefined, range: [open.start, close.end] }; } const inner = parseOr(c); let endPos = inner.range[1]; if (c.peek().kind === TokenKind.RParen) { endPos = c.consume().end; } return { type: 'Group', expr: inner, range: [open.start, endPos] }; } function parseRangeAsTerm(c: TokenCursor): TermNode { const open = c.consume(); const fromTok = canStartPrimary(c.peek()) ? c.consume() : undefined; // Optional `TO` keyword if (isKeyword(c.peek(), 'TO')) c.consume(); const toTok = canStartPrimary(c.peek()) ? c.consume() : undefined; let endPos = (toTok ?? fromTok ?? open).end; if (c.peek().kind === TokenKind.RBracket) { endPos = c.consume().end; } const range: ValueNode = { type: 'Range', from: fromTok, to: toTok, range: [open.start, endPos] }; return { type: 'Term', value: range, range: [open.start, endPos] }; } function parseIdentStart(c: TokenCursor): SearchExpr { const ident = c.consume(); const next = c.peek(); if (next.kind === TokenKind.Operator && isFieldAssignOperator(next.value)) { const op = c.consume(); const value = parseFilterValue(c); const endPos = valueEnd(value, op.end); const node: FilterNode = { type: 'Filter', field: ident, op, value: value ?? undefined, range: [ident.start, endPos], }; return node; } const term: TermNode = { type: 'Term', value: { type: 'Literal', token: ident }, range: [ident.start, ident.end], }; return term; } function parseFilterValue(c: TokenCursor): ValueNode | undefined { const t = c.peek(); if (t.kind === TokenKind.LBracket) { const range = parseRangeAsTerm(c).value; return range; } if ( t.kind === TokenKind.String || t.kind === TokenKind.Number || t.kind === TokenKind.Ident || t.kind === TokenKind.Wildcard ) { const tok = c.consume(); return { type: 'Literal', token: tok }; } if (t.kind === TokenKind.Variable) { const tok = c.consume(); return { type: 'Variable', token: tok, name: tok.name ?? '' }; } if (t.kind === TokenKind.Operator && (t.value === '>' || t.value === '<' || t.value === '>=' || t.value === '<=')) { const op = c.consume(); const inner = parseFilterValue(c); if (inner && inner.type === 'Literal') { return { type: 'Literal', token: { ...inner.token, start: op.start, value: op.value + inner.token.value } }; } return { type: 'Literal', token: { ...op } }; } return undefined; } function isKeyword(t: Token, name: 'AND' | 'OR' | 'NOT' | 'TO' | 'IN'): boolean { return t.kind === TokenKind.Keyword && t.value === name; } function isFieldAssignOperator(op: string): boolean { return op === ':' || op === '='; } function valueEnd(value: ValueNode | undefined, fallback: number): number { if (!value) return fallback; if (value.type === 'Literal' || value.type === 'Variable') return value.token.end; return value.range[1]; } function syntheticAndToken(at: number): Token { return { kind: TokenKind.Keyword, value: 'AND', start: at, end: at }; }