import { SEARCH_KEYWORDS_SET, Token, TokenKind } from './types'; const IDENT_START = /[A-Za-z_]/; const IDENT_CONT = /[A-Za-z0-9_\-.]/; /** * Forgiving tokenizer for LogQL. * * - Recognises `${name}` as `Variable`; a stray `$` becomes `Dollar` so the * completion provider can surface variables before the user finishes typing. * - Tracks bracket nesting so a `|` inside `(...)`, `[...]`, `"..."` or `${...}` * does NOT become a top-level pipe; this lets us split search / analyze segments * reliably. * - Never throws: invalid characters yield `Error` tokens. */ export function tokenize(input: string): Token[] { const tokens: Token[] = []; const len = input.length; let pos = 0; let parenDepth = 0; let bracketDepth = 0; while (pos < len) { const start = pos; const ch = input[pos]; if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { while (pos < len && /\s/.test(input[pos])) pos++; tokens.push({ kind: TokenKind.Whitespace, value: input.slice(start, pos), start, end: pos }); continue; } if (ch === '"' || ch === "'" || ch === '`') { pos = readString(input, pos, tokens); continue; } if (ch === '$') { pos = readDollarOrVariable(input, pos, tokens); continue; } if (ch === '(') { parenDepth++; tokens.push({ kind: TokenKind.LParen, value: '(', start, end: pos + 1 }); pos++; continue; } if (ch === ')') { if (parenDepth > 0) parenDepth--; tokens.push({ kind: TokenKind.RParen, value: ')', start, end: pos + 1 }); pos++; continue; } if (ch === '[') { bracketDepth++; tokens.push({ kind: TokenKind.LBracket, value: '[', start, end: pos + 1 }); pos++; continue; } if (ch === ']') { if (bracketDepth > 0) bracketDepth--; tokens.push({ kind: TokenKind.RBracket, value: ']', start, end: pos + 1 }); pos++; continue; } if (ch === ',') { tokens.push({ kind: TokenKind.Comma, value: ',', start, end: pos + 1 }); pos++; continue; } if (ch === '|') { const next = input[pos + 1]; if (next === '|') { // SQL boolean OR — keep as operator and don't treat as top-level pipe. tokens.push({ kind: TokenKind.Operator, value: '||', start, end: pos + 2 }); pos += 2; continue; } if (parenDepth === 0 && bracketDepth === 0) { tokens.push({ kind: TokenKind.Pipe, value: '|', start, end: pos + 1 }); } else { tokens.push({ kind: TokenKind.Operator, value: '|', start, end: pos + 1 }); } pos++; continue; } if (ch === '*' || ch === '?') { tokens.push({ kind: TokenKind.Wildcard, value: ch, start, end: pos + 1 }); pos++; continue; } if (ch === ':') { tokens.push({ kind: TokenKind.Operator, value: ':', start, end: pos + 1 }); pos++; continue; } if (ch === '!' || ch === '<' || ch === '>' || ch === '=') { pos = readComparator(input, pos, tokens); continue; } if (/[0-9]/.test(ch) || (ch === '-' && /[0-9]/.test(input[pos + 1] ?? ''))) { pos = readNumber(input, pos, tokens); continue; } if (IDENT_START.test(ch)) { pos = readIdent(input, pos, tokens); continue; } // Unknown character — emit Error and advance. tokens.push({ kind: TokenKind.Error, value: ch, start, end: pos + 1 }); pos++; } tokens.push({ kind: TokenKind.EOF, value: '', start: len, end: len }); return tokens; } function readString(input: string, pos: number, tokens: Token[]): number { const quote = input[pos]; const start = pos; pos++; let unterminated = true; while (pos < input.length) { const ch = input[pos]; if (ch === '\\' && pos + 1 < input.length) { pos += 2; continue; } if (ch === quote) { pos++; unterminated = false; break; } pos++; } tokens.push({ kind: TokenKind.String, value: input.slice(start, pos), start, end: pos, quote, unterminated, }); return pos; } function readDollarOrVariable(input: string, pos: number, tokens: Token[]): number { const start = pos; if (input[pos + 1] === '{') { let i = pos + 2; while (i < input.length && /[A-Za-z0-9_\-.]/.test(input[i])) i++; if (input[i] === '}') { const name = input.slice(pos + 2, i); tokens.push({ kind: TokenKind.Variable, value: input.slice(start, i + 1), start, end: i + 1, name, }); return i + 1; } // Unterminated `${...` — emit a Variable token with `unterminated:true` so // the completion provider can still treat the cursor as being inside a // variable reference. The token spans `$` through whatever partial name // the user typed. const partial = input.slice(pos + 2, i); tokens.push({ kind: TokenKind.Variable, value: input.slice(start, i), start, end: i, name: partial, unterminated: true, }); return i; } tokens.push({ kind: TokenKind.Dollar, value: '$', start, end: pos + 1 }); return pos + 1; } function readComparator(input: string, pos: number, tokens: Token[]): number { const start = pos; const ch = input[pos]; const next = input[pos + 1]; let value: string; if (ch === '!' && next === '=') value = '!='; else if (ch === '<' && next === '>') value = '<>'; else if (ch === '<' && next === '=') value = '<='; else if (ch === '>' && next === '=') value = '>='; else value = ch; pos += value.length; tokens.push({ kind: TokenKind.Operator, value, start, end: pos }); return pos; } function readNumber(input: string, pos: number, tokens: Token[]): number { const start = pos; if (input[pos] === '-') pos++; while (pos < input.length && /[0-9]/.test(input[pos])) pos++; if (input[pos] === '.' && /[0-9]/.test(input[pos + 1] ?? '')) { pos++; while (pos < input.length && /[0-9]/.test(input[pos])) pos++; } if (input[pos] === 'e' || input[pos] === 'E') { pos++; if (input[pos] === '+' || input[pos] === '-') pos++; while (pos < input.length && /[0-9]/.test(input[pos])) pos++; } tokens.push({ kind: TokenKind.Number, value: input.slice(start, pos), start, end: pos }); return pos; } function readIdent(input: string, pos: number, tokens: Token[]): number { const start = pos; while (pos < input.length && IDENT_CONT.test(input[pos])) pos++; const raw = input.slice(start, pos); const upper = raw.toUpperCase(); if (SEARCH_KEYWORDS_SET.has(upper)) { tokens.push({ kind: TokenKind.Keyword, value: upper, start, end: pos }); } else { tokens.push({ kind: TokenKind.Ident, value: raw, start, end: pos }); } return pos; } /** Convenience: tokens without whitespace; preserves original `start/end`. */ export function nonWhitespace(tokens: Token[]): Token[] { return tokens.filter((t) => t.kind !== TokenKind.Whitespace); }