import type { SyntaxNode, Tree } from '@lezer/common'; import { AggregateExpr, AggregateModifier, BinaryExpr, EqlRegex, EqlSingle, FunctionCallBody, GroupingLabels, Identifier, LabelMatchers, LabelName, MatchOp, MatrixSelector, Neq, NeqRegex, NumberDurationLiteralInDurationContext, parser, PromQL, StringLiteral, UnquotedLabelMatcher, VectorSelector, WithExpr, WithAssignment, } from '@fc-components/lezer-metricsql'; import { NeverCaseError } from '../util'; import { LabelOperator, Label } from '../types'; type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling'; type NodeTypeId = | 0 // this is used as error-id | typeof AggregateExpr | typeof AggregateModifier | typeof FunctionCallBody | typeof GroupingLabels | typeof Identifier | typeof UnquotedLabelMatcher | typeof LabelMatchers | typeof LabelName | typeof PromQL | typeof StringLiteral | typeof VectorSelector | typeof MatrixSelector | typeof MatchOp | typeof EqlSingle | typeof Neq | typeof EqlRegex | typeof NeqRegex | typeof WithExpr | typeof WithAssignment; type Path = Array<[Direction, NodeTypeId]>; function move(node: SyntaxNode, direction: Direction): SyntaxNode | null { switch (direction) { case 'parent': return node.parent; case 'firstChild': return node.firstChild; case 'lastChild': return node.lastChild; case 'nextSibling': return node.nextSibling; default: throw new NeverCaseError(direction); } } function walk(node: SyntaxNode, path: Path): SyntaxNode | null { let current: SyntaxNode | null = node; for (const [direction, expectedType] of path) { current = move(current, direction); if (current === null) { // we could not move in the direction, we stop return null; } if (current.type.id !== expectedType) { // the reached node has wrong type, we stop return null; } } return current; } function getNodeText(node: SyntaxNode, text: string): string { return text.slice(node.from, node.to); } function parsePromQLStringLiteral(text: string): string { // if it is a string-literal, it is inside quotes of some kind const inside = text.slice(1, text.length - 1); // FIXME: support https://prometheus.io/docs/prometheus/latest/querying/basics/#string-literals // FIXME: maybe check other promql code, if all is supported or not // for now we do only some very simple un-escaping // we start with double-quotes if (text.startsWith('"') && text.endsWith('"')) { // NOTE: this is not 100% perfect, we only unescape the double-quote, // there might be other characters too return inside.replace(/\\"/, '"'); } // then single-quote if (text.startsWith("'") && text.endsWith("'")) { // NOTE: this is not 100% perfect, we only unescape the single-quote, // there might be other characters too return inside.replace(/\\'/, "'"); } // then backticks if (text.startsWith('`') && text.endsWith('`')) { return inside; } throw new Error('FIXME: invalid string literal'); } /** * Safe string literal parser that never throws. Falls back to returning * the original text for unrecognised formats. */ export function parseStringLiteralSafe(text: string): string { if (text.length < 2) return text; try { return parsePromQLStringLiteral(text); } catch { return text; } } export type Situation = | { type: 'IN_FUNCTION'; } | { type: 'AT_ROOT'; } | { type: 'EMPTY'; } | { type: 'IN_DURATION'; } | { type: 'IN_WITH_BODY'; } | { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME'; metricName?: string; otherLabels: Label[]; hasOperator: boolean; } | { type: 'IN_GROUPING'; metricName: string; otherLabels: Label[]; } | { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME'; metricName?: string; labelName: string; betweenQuotes: boolean; otherLabels: Label[]; valueStartPos: number; }; type Resolver = { path: NodeTypeId[]; fun: (node: SyntaxNode, text: string, pos: number) => Situation | null; }; function isPathMatch(resolverPath: NodeTypeId[], cursorPath: number[]): boolean { return resolverPath.every((item, index) => item === cursorPath[index]); } const ERROR_NODE_NAME: NodeTypeId = 0; // this is used as error-id const RESOLVERS: Resolver[] = [ { path: [LabelName, UnquotedLabelMatcher], fun: resolveLabelName, }, { path: [UnquotedLabelMatcher, LabelMatchers], fun: resolveLabelName, }, { path: [LabelMatchers, VectorSelector], fun: resolveLabelKeysWithEquals, }, { path: [LabelMatchers, VectorSelector, PromQL], fun: resolveLabelKeysWithEquals, }, { path: [PromQL], fun: resolveTopLevel, }, { path: [VectorSelector, PromQL], fun: resolveTopLevel, }, { path: [Identifier, VectorSelector], fun: resolveTopLevel, }, { path: [Identifier, VectorSelector, PromQL], fun: resolveTopLevel, }, { path: [FunctionCallBody], fun: resolveInFunction, }, { path: [StringLiteral, UnquotedLabelMatcher], fun: resolveLabelMatcher, }, { path: [ERROR_NODE_NAME, BinaryExpr, PromQL], fun: resolveTopLevel, }, { path: [ERROR_NODE_NAME, UnquotedLabelMatcher], fun: resolveErrorInLabelMatcher, }, { path: [ERROR_NODE_NAME, NumberDurationLiteralInDurationContext, MatrixSelector], fun: resolveDurations, }, { path: [GroupingLabels], fun: resolveLabelsForGrouping, }, { path: [WithExpr], fun: resolveWithExpr, }, { path: [WithExpr, PromQL], fun: resolveWithExpr, }, { path: [WithAssignment, WithExpr], fun: resolveWithExpr, }, ]; const LABEL_OP_MAP = new Map([ [EqlSingle, '='], [EqlRegex, '=~'], [Neq, '!='], [NeqRegex, '!~'], ]); function getLabelOp(opNode: SyntaxNode): LabelOperator | null { const opChild = opNode.firstChild; if (opChild === null) { return null; } return LABEL_OP_MAP.get(opChild.type.id) ?? null; } function getLabel(labelMatcherNode: SyntaxNode, text: string): Label | null { if (labelMatcherNode.type.id !== UnquotedLabelMatcher) { return null; } const nameNode = walk(labelMatcherNode, [['firstChild', LabelName]]); if (nameNode === null) { return null; } const opNode = walk(nameNode, [['nextSibling', MatchOp]]); if (opNode === null) { return null; } const op = getLabelOp(opNode); if (op === null) { return null; } const valueNode = walk(labelMatcherNode, [['lastChild', StringLiteral]]); if (valueNode === null) { return null; } const name = getNodeText(nameNode, text); const value = parseStringLiteralSafe(getNodeText(valueNode, text)); return { name, value, op }; } function getLabels(labelMatchersNode: SyntaxNode, text: string): Label[] { if (labelMatchersNode.type.id !== LabelMatchers) { return []; } const labelNodes = labelMatchersNode.getChildren(UnquotedLabelMatcher); return labelNodes.map((ln) => getLabel(ln, text)).filter(notEmpty); } function getNodeChildren(node: SyntaxNode): SyntaxNode[] { let child: SyntaxNode | null = node.firstChild; const children: SyntaxNode[] = []; while (child !== null) { children.push(child); child = child.nextSibling; } return children; } function getNodeInSubtree(node: SyntaxNode, typeId: NodeTypeId): SyntaxNode | null { // first we try the current node if (node.type.id === typeId) { return node; } // then we try the children const children = getNodeChildren(node); for (const child of children) { const n = getNodeInSubtree(child, typeId); if (n !== null) { return n; } } return null; } function resolveLabelsForGrouping(node: SyntaxNode, text: string, _pos: number): Situation | null { const aggrExpNode = walk(node, [ ['parent', AggregateModifier], ['parent', AggregateExpr], ]); if (aggrExpNode === null) { return null; } const bodyNode = aggrExpNode.getChild(FunctionCallBody); if (bodyNode === null) { return null; } const metricIdNode = getNodeInSubtree(bodyNode, Identifier); if (metricIdNode === null) { return null; } const metricName = getNodeText(metricIdNode, text); return { type: 'IN_GROUPING', metricName, otherLabels: [], }; } function resolveLabelMatcher(node: SyntaxNode, text: string, _pos: number): Situation | null { // we can arrive here in two situation. `node` is either: // - a StringNode (like in `{job="^"}`) // - or an error node (like in `{job=^}`) const inStringNode = !node.type.isError; // calculate where the value starts // for string nodes, it's after the opening quote // for error nodes, it's at the node start const valueStartPos = inStringNode ? node.from + 1 : node.from; const parent = walk(node, [['parent', UnquotedLabelMatcher]]); if (parent === null) { return null; } const labelNameNode = walk(parent, [['firstChild', LabelName]]); if (labelNameNode === null) { return null; } const labelName = getNodeText(labelNameNode, text); const labelMatchersNode = walk(parent, [['parent', LabelMatchers]]); if (labelMatchersNode === null) { return null; } // now we need to find the other names const allLabels = getLabels(labelMatchersNode, text); // we need to remove "our" label from all-labels, if it is in there const otherLabels = allLabels.filter((label) => label.name !== labelName); const metricNameNode = walk(labelMatchersNode, [ ['parent', VectorSelector], ['firstChild', Identifier], ]); if (metricNameNode === null) { // we are probably in a situation without a metric name return { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', labelName, betweenQuotes: inStringNode, otherLabels, valueStartPos, }; } const metricName = getNodeText(metricNameNode, text); return { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', metricName, labelName, betweenQuotes: inStringNode, otherLabels, valueStartPos, }; } function resolveErrorInLabelMatcher(node: SyntaxNode, text: string, pos: number): Situation | null { // 处理错误节点在 UnquotedLabelMatcher 中的情况 // 需要判断是删除值还是删除名称 const parent = walk(node, [['parent', UnquotedLabelMatcher]]); if (parent === null) { return null; } const labelNameNode = walk(parent, [['firstChild', LabelName]]); // 情况1: 有完整的标签名 -> 提示 label value if (labelNameNode !== null) { // 检查是否有 MatchOp(=, =~, !=, !~) const hasMatchOp = walk(labelNameNode, [['nextSibling', MatchOp]]) !== null; if (hasMatchOp) { // 这是值场景,使用 resolveLabelMatcher 的逻辑 return resolveLabelMatcher(node, text, pos); } } // 情况2: 没有完整的标签名或没有 MatchOp -> 提示 label name // 使用 resolveLabelName 的逻辑 return resolveLabelName(parent, text, pos); } function resolveTopLevel(): Situation { return { type: 'AT_ROOT', }; } function resolveInFunction(): Situation { return { type: 'IN_FUNCTION', }; } function resolveWithExpr(node: SyntaxNode, text: string, pos: number): Situation | null { // Find the containing WithExpr node (node may be a WithAssignment child) let withExprNode: SyntaxNode | null = node.type.id === WithExpr ? node : node.parent; while (withExprNode && withExprNode.type.id !== WithExpr) { withExprNode = withExprNode.parent; } if (!withExprNode) return null; // Only return IN_WITH_BODY when cursor is in the body expression (after the closing ')') const children = getNodeChildren(withExprNode); const closeParen = children.find((c) => getNodeText(c, text) === ')'); if (closeParen && pos > closeParen.to) { return { type: 'IN_WITH_BODY' }; } return null; } function resolveDurations(): Situation { return { type: 'IN_DURATION', }; } function resolveLabelName(node: SyntaxNode, text: string, pos: number): Situation | null { let labelMatcherNode: SyntaxNode | null = node; // 如果节点是错误节点,尝试找到其父节点 UnquotedLabelMatcher if (node.type.isError) { const parent = node.parent; if (parent !== null && parent.type.id === UnquotedLabelMatcher) { labelMatcherNode = parent; } else { return null; } } else if (node.type.id === LabelName) { labelMatcherNode = walk(node, [['parent', UnquotedLabelMatcher]]); } if (labelMatcherNode === null || labelMatcherNode.type.id !== UnquotedLabelMatcher) { return null; } const labelNameNode = walk(labelMatcherNode, [['firstChild', LabelName]]); if (labelNameNode === null) { return null; } if (pos > labelNameNode.to) { return null; } const labelMatchersNode = walk(labelMatcherNode, [['parent', LabelMatchers]]); if (labelMatchersNode === null) { return null; } const currentLabelName = getNodeText(labelNameNode, text); const allLabels = getLabels(labelMatchersNode, text); const otherLabels = allLabels.filter((label) => label.name !== currentLabelName); const hasOperator = walk(labelNameNode, [['nextSibling', MatchOp]]) !== null; const metricNameNode = walk(labelMatchersNode, [ ['parent', VectorSelector], ['firstChild', Identifier], ]); if (metricNameNode === null) { return { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels, hasOperator, }; } const metricName = getNodeText(metricNameNode, text); return { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', metricName, otherLabels, hasOperator, }; } function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Situation | null { // next false positive: // `something{a="1"^}` const child = walk(node, [['firstChild', UnquotedLabelMatcher]]); if (child !== null) { // means the label-matching part contains at least one label already. // // in this case, we will need to have a `,` character at the end, // to be able to suggest adding the next label. // the area between the end-of-the-child-node and the cursor-pos // must contain a `,` in this case. const textToCheck = text.slice(child.to, pos); if (!textToCheck.includes(',')) { return null; } } const metricNameNode = walk(node, [ ['parent', VectorSelector], ['firstChild', Identifier], ]); const otherLabels = getLabels(node, text); if (metricNameNode === null) { // we are probably in a situation without a metric name. return { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels, hasOperator: false, }; } const metricName = getNodeText(metricNameNode, text); return { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', metricName, otherLabels, hasOperator: false, }; } // we find the first error-node in the tree that is at the cursor-position. // NOTE: this might be too slow, might need to optimize it // (ideas: we do not need to go into every subtree, based on from/to) // also, only go to places that are in the sub-tree of the node found // by default by lezer. problem is, `next()` will go upward too, // and we do not want to go higher than our node function getErrorNode(tree: Tree, pos: number): SyntaxNode | null { const cur = tree.cursorAt(pos); while (true) { if (cur.from === pos && cur.to === pos) { const { node } = cur; if (node.type.isError) { return node; } } if (!cur.next()) { break; } } return null; } function findMatchingParen(text: string, openPos: number): number { let depth = 0; for (let i = openPos; i < text.length; i++) { if (text[i] === '(') depth++; if (text[i] === ')') { depth--; if (depth === 0) return i; } } return -1; } function isInWithBody(text: string, pos: number): boolean { const withMatch = text.match(/^with\s*\(/i); if (!withMatch) return false; const openParenPos = withMatch[0].length - 1; const closeParenPos = findMatchingParen(text, openParenPos); if (closeParenPos === -1) return false; return pos > closeParenPos; } export function getSituation(text: string, pos: number): Situation | null { // there is a special-case when we are at the start of writing text, // so we handle that case first if (text === '') { return { type: 'EMPTY', }; } // text-based check for with body (fallback until grammar supports WithExpr) if (isInWithBody(text, pos)) { return { type: 'IN_WITH_BODY', }; } /** PromQL Expr VectorSelector LabelMatchers */ const tree = parser.parse(text); // if the tree contains error, it is very probable that // our node is one of those error-nodes. // also, if there are errors, the node lezer finds us, // might not be the best node. // so first we check if there is an error-node at the cursor-position const maybeErrorNode = getErrorNode(tree, pos); const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(pos); const currentNode = cur.node; const ids = [cur.type.id]; // const names = [cur.type.name]; while (cur.parent()) { ids.push(cur.type.id); // names.push(cur.type.name); } for (let resolver of RESOLVERS) { // i do not use a foreach because i want to stop as soon // as i find something if (isPathMatch(resolver.path, ids)) { return resolver.fun(currentNode, text, pos); } } return null; } function notEmpty(value: TValue | null | undefined): value is TValue { return value !== null && value !== undefined; }