import { TokenKind } from './types'; import type { Token, LokiQuery, Situation } from './types'; import { tokenize, nonWhitespace } from './lexer'; import { parse } from './parser'; // Pipeline keywords that should NOT be treated as label filter start const PIPELINE_KEYWORDS = new Set(['json', 'logfmt', 'regexp', 'pattern', 'unpack', 'line_format', 'label_format', 'drop', 'keep', 'unwrap', 'decolorize']); // ─── Describe cursor position ───────────────────────────────────── interface CursorDescription { activeToken: Token | null; prevToken: Token | null; nextToken: Token | null; prefix: string; replaceStart: number; } function describeCursor(offset: number, tokens: Token[]): CursorDescription { let activeToken: Token | null = null; let prevToken: Token | null = null; let nextToken: Token | null = null; for (let i = 0; i < tokens.length; i++) { const t = tokens[i]; if (t.start <= offset && t.end >= offset) { activeToken = t; prevToken = i > 0 ? tokens[i - 1] : null; nextToken = i + 1 < tokens.length ? tokens[i + 1] : null; break; } if (t.start > offset) { nextToken = t; prevToken = i > 0 ? tokens[i - 1] : null; break; } prevToken = t; } // If no active token found, cursor is at or past the end if (!activeToken && tokens.length > 0) { const last = tokens[tokens.length - 1]; if (offset >= last.end) { prevToken = last; } } // Only consider the cursor inside a token when strictly between start and end. // When cursor is at a token boundary, treat it as no active token for prefix purposes. let prefix = ''; let replaceStart = offset; if (activeToken && offset > activeToken.start && offset < activeToken.end) { // Cursor is inside a token — partial word being typed prefix = activeToken.value.slice(0, offset - activeToken.start); replaceStart = activeToken.start; } else if (activeToken && offset === activeToken.end && activeToken.kind === TokenKind.Ident) { // Cursor is at the end of an identifier — use the full word as prefix so // Monaco can filter completions (e.g. "count" → matches count_over_time). prefix = activeToken.value; replaceStart = activeToken.start; } return { activeToken, prevToken, nextToken, prefix, replaceStart }; } // ─── Check if inside an unclosed string ─────────────────────────── function insideUnclosedString(tokens: Token[], offset: number): boolean { for (const t of tokens) { if (t.kind === TokenKind.String && t.end >= offset && !isStringClosed(t.value)) { return true; } } return false; } function isStringClosed(value: string): boolean { if (value.length < 2) return false; const quote = value[0]; const endQuote = value[value.length - 1]; // For backtick and single-quote, the last char should match first if (quote === '`' || quote === "'") return endQuote === quote; // For double-quote, check it's properly closed (odd number of backslashes before final quote) if (quote === '"') { if (endQuote !== '"') return false; // Count trailing backslashes before the closing quote let backslashes = 0; for (let i = value.length - 2; i >= 1 && value[i] === '\\'; i--) { backslashes++; } return backslashes % 2 === 0; } return false; } // ─── Main locate function ───────────────────────────────────────── export function locate(offset: number, tokens: Token[]): Situation { const { activeToken, prevToken, prefix, replaceStart } = describeCursor(offset, tokens); // Helper: find label name by scanning backwards from a given token index function findLabelBefore(idx: number): string | undefined { for (let i = idx - 1; i >= 0; i--) { const t = tokens[i]; if (t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals) { const nameToken = i > 0 ? tokens[i - 1] : null; if (nameToken && nameToken.kind === TokenKind.Ident) return nameToken.value; } if (t.kind === TokenKind.LCurly) break; // don't go past { } return undefined; } // Check if we're inside { ... } (stream selector) if (activeToken && activeToken.kind === TokenKind.LCurly && activeToken.start === offset) { return { kind: 'IN_SELECTOR_LABEL', prefix, replaceRange: { start: replaceStart, end: offset } }; } // Check if cursor is at or after RCurly, but NOT inside a multi-matcher selector if (activeToken && activeToken.kind === TokenKind.RCurly && activeToken.start === offset) { // If there's a comma before RCurly, the user wants to add another label, // not start a pipeline — let the stream selector check below handle it. const hasCommaBefore = tokens.some((t) => t.kind === TokenKind.Comma && t.start < activeToken.start); // Only return AFTER_PIPE if there's at least one complete matcher (operator present). // Otherwise the selector is empty/incomplete and we should stay in label-name mode. const hasOperatorInSelector = tokens.some( (t) => (t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals) && t.start < activeToken.start, ); if (!hasCommaBefore && hasOperatorInSelector) { return { kind: 'AFTER_PIPE', prefix, replaceRange: { start: replaceStart, end: offset } }; } } // Inside stream selector (look for LCurly before cursor) const curlyOpenToken = tokens.find((t) => t.kind === TokenKind.LCurly); const curlyCloseToken = tokens.find((t) => t.kind === TokenKind.RCurly); if (curlyOpenToken && offset > curlyOpenToken.start) { const beforeClose = curlyCloseToken ? offset < curlyCloseToken.end : true; if (beforeClose) { // ── After operator → value completion ── if ( activeToken && (activeToken.kind === TokenKind.Equals || activeToken.kind === TokenKind.NotEquals || activeToken.kind === TokenKind.RegexEquals || activeToken.kind === TokenKind.RegexNotEquals) ) { const opIdx = tokens.indexOf(activeToken); const nameToken = opIdx > 0 ? tokens[opIdx - 1] : null; // Count matchers before this one to get the matcher index const matcherIndex = tokens .slice(0, opIdx) .filter((t) => t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals).length; return { kind: 'IN_SELECTOR_VALUE', prefix, replaceRange: { start: replaceStart, end: offset }, labelName: nameToken?.value, matcherIndex, }; } // ── Inside a string value (e.g. {job="|"}) → value completion ── if (activeToken && activeToken.kind === TokenKind.String) { const strIdx = tokens.indexOf(activeToken); const labelName = findLabelBefore(strIdx); if (labelName) { // Strip the opening quote from the prefix so Monaco doesn't // filter all label values by the quote character. const quote = activeToken.value[0]; const innerPrefix = prefix.startsWith(quote) ? prefix.slice(quote.length) : prefix; // Count matchers before THIS matcher: find the operator before the // string, then count operators strictly before that operator. const strIdx = tokens.indexOf(activeToken); let opIdx = -1; for (let i = strIdx - 1; i >= 0; i--) { if ( tokens[i].kind === TokenKind.Equals || tokens[i].kind === TokenKind.NotEquals || tokens[i].kind === TokenKind.RegexEquals || tokens[i].kind === TokenKind.RegexNotEquals ) { opIdx = i; break; } } const matcherIndex = opIdx >= 0 ? tokens .slice(0, opIdx) .filter((t) => t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals).length : 0; return { kind: 'IN_SELECTOR_VALUE', prefix: innerPrefix, replaceRange: { start: replaceStart + quote.length, end: offset }, labelName, insideString: true, matcherIndex, }; } } // ── Default → label completion ── return { kind: 'IN_SELECTOR_LABEL', prefix, replaceRange: { start: replaceStart, end: offset } }; } } // Inside an unclosed string (not in stream selector) → no suggestions if (insideUnclosedString(tokens, offset)) { return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset } }; } // After a pipe (check if prev token is a pipe or pipe operator) if (prevToken) { if (prevToken.kind === TokenKind.Pipe || prevToken.kind === TokenKind.PipeEquals || prevToken.kind === TokenKind.PipeRegex) { // After a bare pipe - offer pipeline keywords if (prevToken.kind === TokenKind.Pipe) { return { kind: 'AFTER_PIPE', prefix, replaceRange: { start: replaceStart, end: offset }, }; } // After |= or |~ - already defined the operator, cursor is in the value position return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset }, }; } // Check for pipe followed by != or !~ if (prevToken.kind === TokenKind.NotEquals || prevToken.kind === TokenKind.RegexNotEquals) { // Check if the token before prevToken is a Pipe const beforePrev = tokens[tokens.indexOf(prevToken) - 1]; if (beforePrev && beforePrev.kind === TokenKind.Pipe) { // This is a line filter operator return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset }, }; } } } // Inside function parentheses (but not inside range brackets) let parenDepth = 0; let bracketDepth = 0; for (const t of tokens) { if (t.kind === TokenKind.LParen) parenDepth++; if (t.kind === TokenKind.LBracket) bracketDepth++; if (t.start <= offset && t.end >= offset && parenDepth > 0 && bracketDepth === 0) { return { kind: 'IN_FUNCTION_PARENS', prefix, replaceRange: { start: replaceStart, end: offset }, }; } if (t.kind === TokenKind.RParen) parenDepth--; if (t.kind === TokenKind.RBracket) bracketDepth--; } // Default: offer pipeline stage completion if a pipe appeared before const hasPipeBefore = tokens.some((t) => (t.kind === TokenKind.Pipe || t.kind === TokenKind.PipeEquals || t.kind === TokenKind.PipeRegex) && t.start < offset); if (hasPipeBefore) { // After a pipe operator (|=, !=, |~, !~) → typing value, no extra suggestions if ( prevToken && (prevToken.kind === TokenKind.PipeEquals || prevToken.kind === TokenKind.PipeRegex || (prevToken.kind === TokenKind.NotEquals && tokens[tokens.indexOf(prevToken) - 1]?.kind === TokenKind.Pipe) || (prevToken.kind === TokenKind.RegexNotEquals && tokens[tokens.indexOf(prevToken) - 1]?.kind === TokenKind.Pipe)) ) { return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset }, }; } // Previous token is a label filter operator → typing a value if ( prevToken && (prevToken.kind === TokenKind.GreaterThan || prevToken.kind === TokenKind.GreaterEquals || prevToken.kind === TokenKind.LessThan || prevToken.kind === TokenKind.LessEquals || prevToken.kind === TokenKind.Equals || prevToken.kind === TokenKind.NotEquals || prevToken.kind === TokenKind.RegexEquals || prevToken.kind === TokenKind.RegexNotEquals) ) { return { kind: 'IN_LABEL_FILTER', prefix, replaceRange: { start: replaceStart, end: offset }, }; } // Previous token is an identifier that's NOT a pipeline keyword → label name in label filter if (prevToken && prevToken.kind === TokenKind.Ident && !PIPELINE_KEYWORDS.has(prevToken.value)) { return { kind: 'IN_LABEL_FILTER', prefix, replaceRange: { start: replaceStart, end: offset }, }; } } // Inside range brackets [...] → no suggestions (duration values only) let bDepth = 0; for (const t of tokens) { if (t.kind === TokenKind.LBracket && t.start < offset) bDepth++; if (t.kind === TokenKind.RBracket && t.end < offset) bDepth--; } if (bDepth > 0) { return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset } }; } // Fallback: at root level, offer metric / aggregation functions return { kind: 'IN_FUNCTION_PARENS', prefix, replaceRange: { start: replaceStart, end: offset }, }; } // ─── Convenience: parse + locate in one call ────────────────────── export function parseAndLocate( input: string, offset: number, ): { ast: LokiQuery; tokens: Token[]; situation: Situation; } { const rawTokens = tokenize(input); const tokens = nonWhitespace(rawTokens); const ast = parse(input); const situation = locate(offset, tokens); return { ast, tokens, situation }; }