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' })));
});
}
}