@prometheus-io/codemirror-promql
Version:
a CodeMirror mode for the PromQL language
826 lines (792 loc) • 35 kB
text/typescript
// 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,
AggregateModifier,
And,
BinaryExpr,
BoolModifier,
Div,
Eql,
EqlRegex,
EqlSingle,
FunctionCallBody,
GroupingLabels,
Gte,
Gtr,
LabelMatchers,
LabelName,
Lss,
Lte,
MatchOp,
MatrixSelector,
Identifier,
Mod,
Mul,
Neq,
NeqRegex,
OffsetExpr,
Or,
Pow,
PromQL,
StepInvariantExpr,
StringLiteral,
Sub,
SubqueryExpr,
Unless,
VectorSelector,
UnquotedLabelMatcher,
QuotedLabelMatcher,
QuotedLabelName,
NumberDurationLiteralInDurationContext,
NumberDurationLiteral,
AggregateOp,
Topk,
Bottomk,
LimitK,
LimitRatio,
CountValues,
TrimLower,
TrimUpper,
} 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;
}
function escapePromQLString(str: string): string {
// PromQL only evaluates escape sequences in single- and double-quoted strings.
// Backtick-quoted string completions are not handled separately today, so keep
// the inserted value escaped unconditionally.
return str.replace(/([\\"])/g, '\\$1');
}
function isAfterClosedFunctionCallBody(state: EditorState, node: SyntaxNode, pos: number): boolean {
return node.type.id === FunctionCallBody && pos >= node.to && node.from < node.to && state.sliceDoc(node.to - 1, node.to) === ')';
}
// computeEndCompletePosition calculates the end position for autocompletion replacement.
// When the cursor is in the middle of a token, this ensures the entire token is replaced,
// not just the portion before the cursor. This fixes issue #15839.
// Note: this method is exported only for testing purpose.
export function computeEndCompletePosition(state: EditorState, node: SyntaxNode, pos: number): number {
// For error nodes, use the cursor position as the end position
if (node.type.id === 0) {
return pos;
}
if (isAfterClosedFunctionCallBody(state, node, pos)) {
return pos;
}
if (
node.type.id === LabelMatchers ||
node.type.id === GroupingLabels ||
node.type.id === FunctionCallBody ||
node.type.id === MatrixSelector ||
node.type.id === SubqueryExpr
) {
// When we're inside empty brackets, we want to replace up to just before the closing bracket.
return node.to - 1;
}
if (node.type.id === StringLiteral && (node.parent?.type.id === UnquotedLabelMatcher || node.parent?.type.id === QuotedLabelMatcher)) {
// For label values, we want to replace all content inside the quotes.
return node.parent.to - 1;
}
// For all other nodes, extend the end position to include the entire token.
return node.to;
}
// Matches complete PromQL durations, including compound units (e.g., 5m, 1d2h, 1h30m, etc.).
// Duration units are a fixed, safe set (no regex metacharacters), so no escaping is needed.
export const durationWithUnitRegexp = new RegExp(`^(\\d+(${durationTerms.map((term) => term.label).join('|')}))+$`);
// Determines if a duration already has a complete time unit to prevent autocomplete insertion (issue #15452)
function hasCompleteDurationUnit(state: EditorState, node: SyntaxNode): boolean {
if (node.from >= node.to) {
return false;
}
const nodeContent = state.sliceDoc(node.from, node.to);
return durationWithUnitRegexp.test(nodeContent);
}
// 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(state: EditorState, node: SyntaxNode, pos: number): number {
const currentText = state.doc.slice(node.from, pos).toString();
let start = node.from;
if (isAfterClosedFunctionCallBody(state, node, pos)) {
start = pos;
} else if (node.type.id === LabelMatchers || node.type.id === GroupingLabels) {
start = computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel(node, pos);
} else if (
(node.type.id === FunctionCallBody && node.firstChild === null) ||
(node.type.id === StringLiteral && (node.parent?.type.id === UnquotedLabelMatcher || node.parent?.type.id === QuotedLabelMatcher))
) {
// 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 ||
// Since duration and number are equivalent, writing go[5] or go[5d] is syntactically accurate.
// Before we were able to guess when we had to autocomplete the duration later based on the error node,
// which is not possible anymore.
// So we have to analyze the string about the current node to see if the duration unit is already present or not.
(node.type.id === NumberDurationLiteralInDurationContext && !durationTerms.map((v) => v.label).includes(currentText[currentText.length - 1])) ||
(node.type.id === NumberDurationLiteral && node.parent?.type.id === 0 && node.parent.parent?.type.id === SubqueryExpr) ||
(node.type.id === FunctionCallBody && isAggregatorWithParam(node) && node.firstChild !== null) ||
(node.type.id === 0 &&
(node.parent?.type.id === OffsetExpr ||
node.parent?.type.id === MatrixSelector ||
(node.parent?.type.id === SubqueryExpr && containsAtLeastOneChild(node.parent, NumberDurationLiteralInDurationContext))))
) {
start = pos;
}
return start;
}
function isAggregatorWithParam(functionCallBody: SyntaxNode): boolean {
const parent = functionCallBody.parent;
if (parent !== null && parent.firstChild?.type.id === AggregateOp) {
const aggregationOpType = parent.firstChild.firstChild;
if (aggregationOpType !== null && [Topk, Bottomk, LimitK, LimitRatio, CountValues].includes(aggregationOpType.type.id)) {
return true;
}
}
return false;
}
// 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, pos: number): 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 === UnquotedLabelMatcher || node.parent?.type.id === QuotedLabelMatcher) {
// 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, NumberDurationLiteralInDurationContext)) {
// 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 && parent.type.id !== FunctionCallBody) {
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 to 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: {
if (pos >= node.to) {
// Cursor is outside of the label matcher block (e.g. right after `}`),
// so don't offer label-related completions anymore.
break;
}
// 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 === UnquotedLabelMatcher) {
// 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 === UnquotedLabelMatcher || node.parent?.type.id === QuotedLabelMatcher) {
// In this case we are in the given situation:
// metric_name{labelName=""} or 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);
} else if (node.parent.firstChild?.type.id === QuotedLabelName) {
labelName = state.sliceDoc(node.parent.firstChild.from, node.parent.firstChild.to).slice(1, -1);
}
// then find the metricName if it exists
const metricName = getMetricNameInVectorSelector(node, state);
// finally get the full matcher available
const matcherNode = walkBackward(node, LabelMatchers);
const labelMatcherOpts = [QuotedLabelName, QuotedLabelMatcher, UnquotedLabelMatcher];
let labelMatchers: Matcher[] = [];
for (const labelMatcherOpt of labelMatcherOpts) {
labelMatchers = labelMatchers.concat(buildLabelMatchers(matcherNode ? matcherNode.getChildren(labelMatcherOpt) : [], state));
}
result.push({
kind: ContextKind.LabelValue,
metricName: metricName,
labelName: labelName,
matchers: labelMatchers,
});
} else if (node.parent?.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?.parent?.type.id === LabelMatchers) {
// In that case we are in the given situation:
// {""} or {"metric_"}
// since this is for the QuotedMetricName we need to continue to autocomplete for the metric names
result.push({ kind: ContextKind.MetricName, metricName: state.sliceDoc(node.from, node.to).slice(1, -1) });
}
break;
case NumberDurationLiteral:
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
if (!hasCompleteDurationUnit(state, node)) {
result.push({ kind: ContextKind.Duration });
}
} else {
result.push({ kind: ContextKind.Number });
}
break;
case NumberDurationLiteralInDurationContext:
if (!hasCompleteDurationUnit(state, node)) {
result.push({ kind: ContextKind.Duration });
}
break;
case OffsetExpr:
result.push({ kind: ContextKind.Duration });
break;
case FunctionCallBody:
if (isAfterClosedFunctionCallBody(state, node, pos)) {
if (node.parent?.type.id === AggregateExpr && !containsAtLeastOneChild(node.parent, AggregateModifier)) {
result.push({ kind: ContextKind.AggregateOpModifier });
}
result.push({ kind: ContextKind.BinOp });
break;
}
// For aggregation function such as Topk, the first parameter is a number.
// The second one is an expression.
// When moving to the second parameter, the node is an error node.
// Unfortunately, as a current node, codemirror doesn't give us the error node but instead the FunctionCallBody
// The tree looks like that: PromQL(AggregateExpr(AggregateOp(Topk),FunctionCallBody(NumberDurationLiteral,⚠)))
// So, we need to figure out if the cursor is on the first parameter or in the second.
if (isAggregatorWithParam(node)) {
if (node.firstChild === null || (node.firstChild.from <= pos && node.firstChild.to >= pos)) {
// it means the FunctionCallBody has no child, which means we are autocompleting the first parameter
result.push({ kind: ContextKind.Number });
break;
}
// at this point we are necessary autocompleting the second parameter
result.push({ kind: ContextKind.MetricName, metricName: '' }, { kind: ContextKind.Function }, { kind: ContextKind.Aggregation });
break;
}
// In all other cases, 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 TrimLower:
case TrimUpper:
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;
}
destroy(): void {
this.prometheusClient?.destroy?.();
}
promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null {
const { state, pos } = context;
const tree = syntaxTree(state).resolve(pos, -1);
// The lines above can help you to print the current lezer tree.
// It's useful when you are trying to understand why it doesn't autocomplete.
// console.log(syntaxTree(state).topNode.toString());
// console.log(`current node: ${tree.type.name}`);
const contexts = analyzeCompletion(state, tree, pos);
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(state, tree, pos),
computeEndCompletePosition(state, tree, 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) {
// First check if the full metric name has metadata (even if it has one of the histogram/summary/openmetrics suffixes
// it may be a metric that is not following naming conventions)
// Then fall back to the base metric name if full metadata doesn't exist
const metadata = metricMetadata[metricName] ?? metricMetadata[metricName.replace(/(_count|_sum|_bucket|_total)$/, '')];
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, apply: escapePromQLString(value), type: 'text' })));
});
}
}