web/ui/module/codemirror-promql/src/complete/hybrid.ts (495 lines of code) (raw):

// Copyright 2021 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { CompleteStrategy } from './index'; import { SyntaxNode } from '@lezer/common'; import { PrometheusClient } from '../client'; import { Add, AggregateExpr, And, BinaryExpr, BoolModifier, Div, Duration, Eql, EqlRegex, EqlSingle, FunctionCallBody, GroupingLabels, Gte, Gtr, LabelMatcher, LabelMatchers, LabelName, Lss, Lte, MatchOp, MatrixSelector, Identifier, Mod, Mul, Neq, NeqRegex, NumberLiteral, OffsetExpr, Or, Pow, PromQL, StepInvariantExpr, StringLiteral, Sub, SubqueryExpr, Unless, VectorSelector, } from '@prometheus-io/lezer-promql'; import { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; import { buildLabelMatchers, containsAtLeastOneChild, containsChild, walkBackward } from '../parser'; import { aggregateOpModifierTerms, aggregateOpTerms, atModifierTerms, binOpModifierTerms, binOpTerms, durationTerms, functionIdentifierTerms, matchOpTerms, numberTerms, snippets, } from './promql.terms'; import { Matcher } from '../types'; import { syntaxTree } from '@codemirror/language'; const autocompleteNodes: { [key: string]: Completion[] } = { matchOp: matchOpTerms, binOp: binOpTerms, duration: durationTerms, binOpModifier: binOpModifierTerms, atModifier: atModifierTerms, functionIdentifier: functionIdentifierTerms, aggregateOp: aggregateOpTerms, aggregateOpModifier: aggregateOpModifierTerms, number: numberTerms, }; // ContextKind is the different possible value determinate by the autocompletion export enum ContextKind { // dynamic autocompletion (required a distant server) MetricName, LabelName, LabelValue, // static autocompletion Function, Aggregation, BinOpModifier, BinOp, MatchOp, AggregateOpModifier, Duration, Offset, Bool, AtModifiers, Number, } export interface Context { kind: ContextKind; metricName?: string; labelName?: string; matchers?: Matcher[]; } function getMetricNameInGroupBy(tree: SyntaxNode, state: EditorState): string { // There should be an AggregateExpr as parent of the GroupingLabels. // Then we should find the VectorSelector child to be able to find the metric name. const currentNode: SyntaxNode | null = walkBackward(tree, AggregateExpr); if (!currentNode) { return ''; } let metricName = ''; currentNode.cursor().iterate((node) => { // Continue until we find the VectorSelector, then look up the metric name. if (node.type.id === VectorSelector) { metricName = getMetricNameInVectorSelector(node.node, state); if (metricName) { return false; } } }); return metricName; } function getMetricNameInVectorSelector(tree: SyntaxNode, state: EditorState): string { // Find if there is a defined metric name. Should be used to autocomplete a labelValue or a labelName // First find the parent "VectorSelector" to be able to find then the subChild "Identifier" if it exists. let currentNode: SyntaxNode | null = walkBackward(tree, VectorSelector); if (!currentNode) { // Weird case that shouldn't happen, because "VectorSelector" is by definition the parent of the LabelMatchers. return ''; } currentNode = currentNode.getChild(Identifier); if (!currentNode) { return ''; } return state.sliceDoc(currentNode.from, currentNode.to); } function arrayToCompletionResult(data: Completion[], from: number, to: number, includeSnippet = false, span = true): CompletionResult { const options = data; if (includeSnippet) { options.push(...snippets); } return { from: from, to: to, options: options, validFor: span ? /^[a-zA-Z0-9_:]+$/ : undefined, } as CompletionResult; } // computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel calculates the start position only when the node is a LabelMatchers or a GroupingLabels function computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel(node: SyntaxNode, pos: number): number { // Here we can have two different situations: // 1. `metric{}` or `sum by()` with the cursor between the bracket // and so we have increment the starting position to avoid to consider the open bracket when filtering the autocompletion list. // 2. `metric{foo="bar",} or `sum by(foo,) with the cursor after the comma. // Then the start number should be the current position to avoid to consider the previous labelMatcher/groupingLabel when filtering the autocompletion list. let start = node.from + 1; if (node.firstChild !== null) { // here that means the LabelMatchers / GroupingLabels has a child, which is not possible if we have the expression `metric{}`. So we are likely trying to autocomplete the label list after a comma start = pos; } return start; } // computeStartCompletePosition calculates the start position of the autocompletion. // It is an important step because the start position will be used by CMN to find the string and then to use it to filter the CompletionResult. // A wrong `start` position will lead to have the completion not working. // Note: this method is exported only for testing purpose. export function computeStartCompletePosition(node: SyntaxNode, pos: number): number { let start = node.from; if (node.type.id === LabelMatchers || node.type.id === GroupingLabels) { start = computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel(node, pos); } else if (node.type.id === FunctionCallBody || (node.type.id === StringLiteral && node.parent?.type.id === LabelMatcher)) { // When the cursor is between bracket, quote, we need to increment the starting position to avoid to consider the open bracket/ first string. start++; } else if ( node.type.id === OffsetExpr || (node.type.id === NumberLiteral && node.parent?.type.id === 0 && node.parent.parent?.type.id === SubqueryExpr) || (node.type.id === 0 && (node.parent?.type.id === OffsetExpr || node.parent?.type.id === MatrixSelector || (node.parent?.type.id === SubqueryExpr && containsAtLeastOneChild(node.parent, Duration)))) ) { start = pos; } return start; } // analyzeCompletion is going to determinate what should be autocompleted. // The value of the autocompletion is then calculate by the function buildCompletion. // Note: this method is exported for testing purpose only. Do not use it directly. export function analyzeCompletion(state: EditorState, node: SyntaxNode): Context[] { const result: Context[] = []; switch (node.type.id) { case 0: // 0 is the id of the error node if (node.parent?.type.id === OffsetExpr) { // we are likely in the given situation: // `metric_name offset 5` that leads to this tree: // `OffsetExpr(VectorSelector(Identifier),Offset,⚠)` // Here we can just autocomplete a duration. result.push({ kind: ContextKind.Duration }); break; } if (node.parent?.type.id === LabelMatcher) { // In this case the current token is not itself a valid match op yet: // metric_name{labelName!} result.push({ kind: ContextKind.MatchOp }); break; } if (node.parent?.type.id === MatrixSelector) { // we are likely in the given situation: // `metric_name{}[5]` // We can also just autocomplete a duration result.push({ kind: ContextKind.Duration }); break; } if (node.parent?.type.id === SubqueryExpr && containsAtLeastOneChild(node.parent, Duration)) { // we are likely in the given situation: // `rate(foo[5d:5])` // so we should autocomplete a duration result.push({ kind: ContextKind.Duration }); break; } // when we are in the situation 'metric_name !', we have the following tree // VectorSelector(Identifier,⚠) // We should try to know if the char '!' is part of a binOp. // Note: as it is quite experimental, maybe it requires more condition and to check the current tree (parent, other child at the same level ..etc.). const operator = state.sliceDoc(node.from, node.to); if (binOpTerms.filter((term) => term.label.includes(operator)).length > 0) { result.push({ kind: ContextKind.BinOp }); } break; case Identifier: // sometimes an Identifier has an error has parent. This should be treated in priority if (node.parent?.type.id === 0) { const errorNodeParent = node.parent.parent; if (errorNodeParent?.type.id === StepInvariantExpr) { // we are likely in the given situation: // `expr @ s` // we can autocomplete start / end result.push({ kind: ContextKind.AtModifiers }); break; } if (errorNodeParent?.type.id === AggregateExpr) { // it matches 'sum() b'. So here we can autocomplete: // - the aggregate operation modifier // - the binary operation (since it's not mandatory to have an aggregate operation modifier) result.push({ kind: ContextKind.AggregateOpModifier }, { kind: ContextKind.BinOp }); break; } if (errorNodeParent?.type.id === VectorSelector) { // it matches 'sum b'. So here we also have to autocomplete the aggregate operation modifier only // if the associated identifier is matching an aggregation operation. // Note: here is the corresponding tree in order to understand the situation: // VectorSelector( // Identifier, // ⚠(Identifier) // ) const operator = getMetricNameInVectorSelector(node, state); if (aggregateOpTerms.filter((term) => term.label === operator).length > 0) { result.push({ kind: ContextKind.AggregateOpModifier }); } // It's possible it also match the expr 'metric_name unle'. // It's also possible that the operator is also a metric even if it matches the list of aggregation function. // So we also have to autocomplete the binary operator. // // The expr `metric_name off` leads to the same tree. So we have to provide the offset keyword too here. result.push({ kind: ContextKind.BinOp }, { kind: ContextKind.Offset }); break; } if (errorNodeParent && containsChild(errorNodeParent, 'Expr')) { // this last case can appear with the following expression: // 1. http_requests_total{method="GET"} off // 2. rate(foo[5m]) un // 3. sum(http_requests_total{method="GET"} off) // For these different cases we have this kind of tree: // Parent ( // ⚠(Identifier) // ) // We don't really care about the parent, here we are more interested if in the siblings of the error node, there is the node 'Expr' // If it is the case, then likely we should autocomplete the BinOp or the offset. result.push({ kind: ContextKind.BinOp }, { kind: ContextKind.Offset }); break; } } // As the leaf Identifier is coming for different cases, we have to take a bit time to analyze the tree // in order to know what we have to autocomplete exactly. // Here is some cases: // 1. metric_name / ignor --> we should autocomplete the BinOpModifier + metric/function/aggregation // 2. sum(http_requests_total{method="GET"} / o) --> BinOpModifier + metric/function/aggregation // Examples above give a different tree each time and ends up to be treated in this case. // But they all have the following common tree pattern: // Parent( ..., // ... , // VectorSelector(Identifier) // ) // // So the first things to do is to get the `Parent` and to determinate if we are in this configuration. // Otherwise we would just have to autocomplete the metric / function / aggregation. const parent = node.parent?.parent; if (!parent) { // this case can be possible if the topNode is not anymore PromQL but MetricName. // In this particular case, then we just want to autocomplete the metric result.push({ kind: ContextKind.MetricName, metricName: state.sliceDoc(node.from, node.to) }); break; } // now we have to know if we have two Expr in the direct children of the `parent` const containExprTwice = containsChild(parent, 'Expr', 'Expr'); if (containExprTwice) { if (parent.type.id === BinaryExpr && !containsAtLeastOneChild(parent, 0)) { // We are likely in the case 1 or 5 result.push( { kind: ContextKind.MetricName, metricName: state.sliceDoc(node.from, node.to) }, { kind: ContextKind.Function }, { kind: ContextKind.Aggregation }, { kind: ContextKind.BinOpModifier }, { kind: ContextKind.Number } ); // in case the BinaryExpr is a comparison, we should autocomplete the `bool` keyword. But only if it is not present. // When the `bool` keyword is NOT present, then the expression looks like this: // BinaryExpr( ..., Gtr , ... ) // When the `bool` keyword is present, then the expression looks like this: // BinaryExpr( ..., Gtr , BoolModifier(...), ... ) if (containsAtLeastOneChild(parent, Eql, Gte, Gtr, Lte, Lss, Neq) && !containsAtLeastOneChild(parent, BoolModifier)) { result.push({ kind: ContextKind.Bool }); } } } else { result.push( { kind: ContextKind.MetricName, metricName: state.sliceDoc(node.from, node.to) }, { kind: ContextKind.Function }, { kind: ContextKind.Aggregation } ); if (parent.type.id !== FunctionCallBody && parent.type.id !== MatrixSelector) { // it's too avoid to autocomplete a number in situation where it shouldn't. // Like with `sum by(rat)` result.push({ kind: ContextKind.Number }); } } break; case PromQL: if (node.firstChild !== null && node.firstChild.type.id === 0) { // this situation can happen when there is nothing in the text area and the user is explicitly triggering the autocompletion (with ctrl + space) result.push( { kind: ContextKind.MetricName, metricName: '' }, { kind: ContextKind.Function }, { kind: ContextKind.Aggregation }, { kind: ContextKind.Number } ); } break; case GroupingLabels: // In this case we are in the given situation: // sum by () or sum (metric_name) by () // so we have or to autocomplete any kind of labelName or to autocomplete only the labelName associated to the metric result.push({ kind: ContextKind.LabelName, metricName: getMetricNameInGroupBy(node, state) }); break; case LabelMatchers: // In that case we are in the given situation: // metric_name{} or {} // so we have or to autocomplete any kind of labelName or to autocomplete only the labelName associated to the metric result.push({ kind: ContextKind.LabelName, metricName: getMetricNameInVectorSelector(node, state) }); break; case LabelName: if (node.parent?.type.id === GroupingLabels) { // In this case we are in the given situation: // sum by (myL) // So we have to continue to autocomplete any kind of labelName result.push({ kind: ContextKind.LabelName }); } else if (node.parent?.type.id === LabelMatcher) { // In that case we are in the given situation: // metric_name{myL} or {myL} // so we have or to continue to autocomplete any kind of labelName or // to continue to autocomplete only the labelName associated to the metric result.push({ kind: ContextKind.LabelName, metricName: getMetricNameInVectorSelector(node, state) }); } break; case StringLiteral: if (node.parent?.type.id === LabelMatcher) { // In this case we are in the given situation: // metric_name{labelName=""} // So we can autocomplete the labelValue // Get the labelName. // By definition it's the firstChild: https://github.com/promlabs/lezer-promql/blob/0ef65e196a8db6a989ff3877d57fd0447d70e971/src/promql.grammar#L250 let labelName = ''; if (node.parent.firstChild?.type.id === LabelName) { labelName = state.sliceDoc(node.parent.firstChild.from, node.parent.firstChild.to); } // then find the metricName if it exists const metricName = getMetricNameInVectorSelector(node, state); // finally get the full matcher available const matcherNode = walkBackward(node, LabelMatchers); const labelMatchers = buildLabelMatchers(matcherNode ? matcherNode.getChildren(LabelMatcher) : [], state); result.push({ kind: ContextKind.LabelValue, metricName: metricName, labelName: labelName, matchers: labelMatchers, }); } break; case NumberLiteral: if (node.parent?.type.id === 0 && node.parent.parent?.type.id === SubqueryExpr) { // Here we are likely in this situation: // `go[5d:4]` // and we have the given tree: // SubqueryExpr( // VectorSelector(Identifier), // Duration, Duration, ⚠(NumberLiteral) // ) // So we should continue to autocomplete a duration result.push({ kind: ContextKind.Duration }); } else { result.push({ kind: ContextKind.Number }); } break; case Duration: case OffsetExpr: result.push({ kind: ContextKind.Duration }); break; case FunctionCallBody: // In this case we are in the given situation: // sum() or in rate() // with the cursor between the bracket. So we can autocomplete the metric, the function and the aggregation. result.push({ kind: ContextKind.MetricName, metricName: '' }, { kind: ContextKind.Function }, { kind: ContextKind.Aggregation }); break; case Neq: if (node.parent?.type.id === MatchOp) { result.push({ kind: ContextKind.MatchOp }); } else if (node.parent?.type.id === BinaryExpr) { result.push({ kind: ContextKind.BinOp }); } break; case EqlSingle: case EqlRegex: case NeqRegex: case MatchOp: result.push({ kind: ContextKind.MatchOp }); break; case Pow: case Mul: case Div: case Mod: case Add: case Sub: case Eql: case Gte: case Gtr: case Lte: case Lss: case And: case Unless: case Or: case BinaryExpr: result.push({ kind: ContextKind.BinOp }); break; } return result; } // HybridComplete provides a full completion result with or without a remote prometheus. export class HybridComplete implements CompleteStrategy { private readonly prometheusClient: PrometheusClient | undefined; private readonly maxMetricsMetadata: number; constructor(prometheusClient?: PrometheusClient, maxMetricsMetadata = 10000) { this.prometheusClient = prometheusClient; this.maxMetricsMetadata = maxMetricsMetadata; } getPrometheusClient(): PrometheusClient | undefined { return this.prometheusClient; } promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null { const { state, pos } = context; const tree = syntaxTree(state).resolve(pos, -1); const contexts = analyzeCompletion(state, tree); let asyncResult: Promise<Completion[]> = Promise.resolve([]); let completeSnippet = false; let span = true; for (const context of contexts) { switch (context.kind) { case ContextKind.Aggregation: completeSnippet = true; asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.aggregateOp); }); break; case ContextKind.Function: completeSnippet = true; asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.functionIdentifier); }); break; case ContextKind.BinOpModifier: asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.binOpModifier); }); break; case ContextKind.BinOp: asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.binOp); }); break; case ContextKind.MatchOp: asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.matchOp); }); break; case ContextKind.AggregateOpModifier: asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.aggregateOpModifier); }); break; case ContextKind.Duration: span = false; asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.duration); }); break; case ContextKind.Offset: asyncResult = asyncResult.then((result) => { return result.concat([{ label: 'offset' }]); }); break; case ContextKind.Bool: asyncResult = asyncResult.then((result) => { return result.concat([{ label: 'bool' }]); }); break; case ContextKind.AtModifiers: asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.atModifier); }); break; case ContextKind.Number: asyncResult = asyncResult.then((result) => { return result.concat(autocompleteNodes.number); }); break; case ContextKind.MetricName: asyncResult = asyncResult.then((result) => { return this.autocompleteMetricName(result, context); }); break; case ContextKind.LabelName: asyncResult = asyncResult.then((result) => { return this.autocompleteLabelName(result, context); }); break; case ContextKind.LabelValue: asyncResult = asyncResult.then((result) => { return this.autocompleteLabelValue(result, context); }); } } return asyncResult.then((result) => { return arrayToCompletionResult(result, computeStartCompletePosition(tree, pos), pos, completeSnippet, span); }); } private autocompleteMetricName(result: Completion[], context: Context): Completion[] | Promise<Completion[]> { if (!this.prometheusClient) { return result; } const metricCompletion = new Map<string, Completion>(); return this.prometheusClient .metricNames(context.metricName) .then((metricNames: string[]) => { for (const metricName of metricNames) { metricCompletion.set(metricName, { label: metricName, type: 'constant' }); } // avoid to get all metric metadata if the prometheus server is too big if (metricNames.length <= this.maxMetricsMetadata) { // in order to enrich the completion list of the metric, // we are trying to find the associated metadata return this.prometheusClient?.metricMetadata(); } }) .then((metricMetadata) => { if (metricMetadata) { for (const [metricName, node] of metricCompletion) { // For histograms and summaries, the metadata is only exposed for the base metric name, // not separately for the _count, _sum, and _bucket time series. const metadata = metricMetadata[metricName.replace(/(_count|_sum|_bucket)$/, '')]; if (metadata) { if (metadata.length > 1) { // it means the metricName has different possible helper and type for (const m of metadata) { if (node.detail === '') { node.detail = m.type; } else if (node.detail !== m.type) { node.detail = 'unknown'; node.info = 'multiple different definitions for this metric'; } if (node.info === '') { node.info = m.help; } else if (node.info !== m.help) { node.info = 'multiple different definitions for this metric'; } } } else if (metadata.length === 1) { let { type, help } = metadata[0]; if (type === 'histogram' || type === 'summary') { if (metricName.endsWith('_count')) { type = 'counter'; help = `The total number of observations for: ${help}`; } if (metricName.endsWith('_sum')) { type = 'counter'; help = `The total sum of observations for: ${help}`; } if (metricName.endsWith('_bucket')) { type = 'counter'; help = `The total count of observations for a bucket in the histogram: ${help}`; } } node.detail = type; node.info = help; } } } } return result.concat(Array.from(metricCompletion.values())); }); } private autocompleteLabelName(result: Completion[], context: Context): Completion[] | Promise<Completion[]> { if (!this.prometheusClient) { return result; } return this.prometheusClient.labelNames(context.metricName).then((labelNames: string[]) => { return result.concat(labelNames.map((value) => ({ label: value, type: 'constant' }))); }); } private autocompleteLabelValue(result: Completion[], context: Context): Completion[] | Promise<Completion[]> { if (!this.prometheusClient || !context.labelName) { return result; } return this.prometheusClient.labelValues(context.labelName, context.metricName, context.matchers).then((labelValues: string[]) => { return result.concat(labelValues.map((value) => ({ label: value, type: 'text' }))); }); } }