Prometheus: monaco-based query-editor-field, first step (#37251)

* prometheus: add monaco-based query-field

for now it is behind a feature-flag

* added new trigger character

* better separate grafana-specifc and prom-specific code

* better styling

* more styling

* more styling

* simpler monaco-import

* better imports

* simplified code

* simplified type imports

* refactor: group completion-provider files together

* renamed type

* simplify type-import

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>

* handle no-metric-name autocompletes

* updated comment

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
Gábor Farkas 2021-08-31 13:46:13 +02:00 committed by GitHub
parent 8d3b31ff23
commit a5d11a3bef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1003 additions and 15 deletions

View File

@ -272,12 +272,16 @@
"jquery": "3.5.1",
"json-source-map": "0.6.1",
"jsurl": "^0.1.5",
"lezer": "0.13.5",
"lezer-promql": "0.20.0",
"lezer-tree": "0.13.2",
"lodash": "4.17.21",
"logfmt": "^1.3.2",
"lru-cache": "^5.1.1",
"memoize-one": "5.1.1",
"moment": "2.29.1",
"moment-timezone": "0.5.33",
"monaco-promql": "^1.7.2",
"mousetrap": "1.6.5",
"mousetrap-global-bind": "1.1.0",
"ol": "^6.5.0",

View File

@ -50,6 +50,7 @@ export interface FeatureToggles {
accesscontrol: boolean;
tempoServiceGraph: boolean;
tempoSearch: boolean;
prometheusMonaco: boolean;
}
/**

View File

@ -65,6 +65,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
trimDefaults: false,
tempoServiceGraph: false,
tempoSearch: false,
prometheusMonaco: false,
};
licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false;

View File

@ -88,12 +88,14 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
handleBeforeMount = (monaco: Monaco) => {
this.monaco = monaco;
const { language, theme, getSuggestions } = this.props;
const { language, theme, getSuggestions, onBeforeEditorMount } = this.props;
defineThemes(monaco, theme);
if (getSuggestions) {
this.completionCancel = registerSuggestions(monaco, language, getSuggestions);
}
onBeforeEditorMount?.(monaco);
};
handleOnMount = (editor: MonacoEditorType, monaco: Monaco) => {

View File

@ -4,6 +4,7 @@ import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
export type CodeEditorChangeHandler = (value: string) => void;
export type CodeEditorSuggestionProvider = () => CodeEditorSuggestionItem[];
export type { monacoType as monacoTypes };
export type Monaco = typeof monacoType;
export type MonacoEditor = monacoType.editor.IStandaloneCodeEditor;
export type MonacoOptions = monacoType.editor.IStandaloneEditorConstructionOptions;
@ -19,6 +20,11 @@ export interface CodeEditorProps {
showLineNumbers?: boolean;
monacoOptions?: MonacoOptions;
/**
* Callback before the editor has mounted that gives you raw access to monaco
*/
onBeforeEditorMount?: (monaco: Monaco) => void;
/**
* Callback after the editor has mounted that gives you raw access to monaco
*/

View File

@ -42,7 +42,14 @@ export { QueryField } from './QueryField/QueryField';
// Code editor
export { CodeEditor } from './Monaco/CodeEditorLazy';
export { Monaco, MonacoEditor, CodeEditorSuggestionItem, CodeEditorSuggestionItemKind } from './Monaco/types';
export {
Monaco,
monacoTypes,
MonacoEditor,
MonacoOptions as CodeEditorMonacoOptions,
CodeEditorSuggestionItem,
CodeEditorSuggestionItemKind,
} from './Monaco/types';
export { variableSuggestionToCodeEditorSuggestion } from './Monaco/utils';
// TODO: namespace

View File

@ -1,5 +1,6 @@
import React, { ReactNode } from 'react';
import { config } from '@grafana/runtime';
import { Plugin } from 'slate';
import {
SlatePrism,
@ -28,6 +29,7 @@ import {
} from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { MonacoQueryFieldLazy } from './monaco-query-field/MonacoQueryFieldLazy';
export const RECORDING_RULES_GROUP = '__recording_rules__';
@ -279,6 +281,8 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
const buttonDisabled = !(syntaxLoaded && hasMetrics);
const isMonacoEditorEnabled = config.featureToggles.prometheusMonaco;
return (
<>
<div
@ -295,19 +299,29 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
</button>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
<QueryField
additionalPlugins={this.plugins}
cleanText={cleanText}
query={query.expr}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onBlur={this.props.onBlur}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
placeholder={placeholder}
portalOrigin="prometheus"
syntaxLoaded={syntaxLoaded}
/>
{isMonacoEditorEnabled ? (
<MonacoQueryFieldLazy
languageProvider={languageProvider}
history={this.props.history}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
initialValue={query.expr ?? ''}
/>
) : (
<QueryField
additionalPlugins={this.plugins}
cleanText={cleanText}
query={query.expr}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onBlur={this.props.onBlur}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
placeholder={placeholder}
portalOrigin="prometheus"
syntaxLoaded={syntaxLoaded}
/>
)}
</div>
</div>
{labelBrowserVisible && (

View File

@ -0,0 +1,110 @@
import React, { useRef } from 'react';
import { CodeEditor, CodeEditorMonacoOptions } from '@grafana/ui';
import { useLatest } from 'react-use';
import { promLanguageDefinition } from 'monaco-promql';
import { getCompletionProvider } from './monaco-completion-provider';
import { Props } from './MonacoQueryFieldProps';
const options: CodeEditorMonacoOptions = {
lineNumbers: 'off',
minimap: { enabled: false },
lineDecorationsWidth: 0,
wordWrap: 'off',
overviewRulerLanes: 0,
overviewRulerBorder: false,
folding: false,
scrollBeyondLastLine: false,
renderLineHighlight: 'none',
fontSize: 14,
};
const MonacoQueryField = (props: Props) => {
const containerRef = useRef<HTMLDivElement>(null);
const { languageProvider, history, onChange, initialValue } = props;
const lpRef = useLatest(languageProvider);
const historyRef = useLatest(history);
return (
<div
// NOTE: we will be setting inline-style-width/height on this element
ref={containerRef}
style={{
// FIXME:
// this is how the non-monaco query-editor is styled,
// through the "gf-form" class
// so to have the same effect, we do the same.
// this should be applied somehow differently probably,
// like a min-height on the whole row.
marginBottom: '4px',
}}
>
<CodeEditor
onBlur={onChange}
monacoOptions={options}
language="promql"
value={initialValue}
onBeforeEditorMount={(monaco) => {
// we construct a DataProvider object
const getSeries = (selector: string) => lpRef.current.getSeries(selector);
const getHistory = () =>
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
const getAllMetricNames = () => {
const { metricsMetadata } = lpRef.current;
const result = metricsMetadata == null ? [] : Object.keys(metricsMetadata);
return Promise.resolve(result);
};
const dataProvider = { getSeries, getHistory, getAllMetricNames };
const langId = promLanguageDefinition.id;
monaco.languages.register(promLanguageDefinition);
promLanguageDefinition.loader().then((mod) => {
monaco.languages.setMonarchTokensProvider(langId, mod.language);
monaco.languages.setLanguageConfiguration(langId, mod.languageConfiguration);
const completionProvider = getCompletionProvider(monaco, dataProvider);
monaco.languages.registerCompletionItemProvider(langId, completionProvider);
});
// FIXME: should we unregister this at end end?
}}
onEditorDidMount={(editor, monaco) => {
// this code makes the editor resize itself so that the content fits
// (it will grow taller when necessary)
// FIXME: maybe move this functionality into CodeEditor, like:
// <CodeEditor resizingMode="single-line"/>
const updateElementHeight = () => {
const containerDiv = containerRef.current;
if (containerDiv !== null) {
const pixelHeight = editor.getContentHeight();
containerDiv.style.height = `${pixelHeight}px`;
containerDiv.style.width = '100%';
const pixelWidth = containerDiv.clientWidth;
editor.layout({ width: pixelWidth, height: pixelHeight });
}
};
editor.onDidContentSizeChange(updateElementHeight);
updateElementHeight();
// handle: shift + enter
// FIXME: maybe move this functionality into CodeEditor?
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
const text = editor.getValue();
props.onChange(text);
props.onRunQuery();
});
}}
/>
</div>
);
};
// we will lazy-load this module using React.lazy,
// and that only supports default-exports,
// so we have to default-export this, even if
// it is agains the style-guidelines.
export default MonacoQueryField;

View File

@ -0,0 +1,12 @@
import React, { Suspense } from 'react';
import { Props } from './MonacoQueryFieldProps';
const Field = React.lazy(() => import(/* webpackChunkName: "prom-query-field" */ './MonacoQueryField'));
export const MonacoQueryFieldLazy = (props: Props) => {
return (
<Suspense fallback={null}>
<Field {...props} />
</Suspense>
);
};

View File

@ -0,0 +1,15 @@
import { HistoryItem } from '@grafana/data';
import { PromQuery } from '../../types';
import type PromQlLanguageProvider from '../../language_provider';
// we need to store this in a separate file,
// because we have an async-wrapper around,
// the react-component, and it needs the same
// props as the sync-component.
export type Props = {
initialValue: string;
languageProvider: PromQlLanguageProvider;
history: Array<HistoryItem<PromQuery>>;
onChange: (query: string) => void;
onRunQuery: () => void;
};

View File

@ -0,0 +1,140 @@
import type { Intent, Label } from './intent';
import { NeverCaseError } from './util';
// FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too
import { FUNCTIONS } from '../../../promql';
type Completion = {
label: string;
insertText: string;
triggerOnInsert?: boolean;
};
export type DataProvider = {
getHistory: () => Promise<string[]>;
getAllMetricNames: () => Promise<string[]>;
getSeries: (selector: string) => Promise<Record<string, string[]>>;
};
// we order items like: history, functions, metrics
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const names = await dataProvider.getAllMetricNames();
return names.map((text) => ({
label: text,
insertText: text,
}));
}
function getAllFunctionsCompletions(): Completion[] {
return FUNCTIONS.map((f) => ({
label: f.label,
insertText: f.insertText ?? '', // i don't know what to do when this is nullish. it should not be.
}));
}
function getAllDurationsCompletions(): Completion[] {
// FIXME: get a better list
return ['5m', '1m', '30s', '15s'].map((text) => ({
label: text,
insertText: text,
}));
}
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
const allHistory = await dataProvider.getHistory();
// FIXME: find a better history-limit
return allHistory.slice(0, 10).map((expr) => ({
label: expr,
insertText: expr,
}));
}
function makeSelector(metricName: string | undefined, labels: Label[]): string {
const allLabels = [...labels];
// we transform the metricName to a label, if it exists
if (metricName !== undefined) {
allLabels.push({ name: '__name__', value: metricName });
}
const allLabelTexts = allLabels.map((label) => `${label.name}="${label.value}"`);
return `{${allLabelTexts.join(',')}}`;
}
async function getLabelNamesForCompletions(
metric: string | undefined,
suffix: string,
triggerOnInsert: boolean,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
const selector = makeSelector(metric, otherLabels);
const data = await dataProvider.getSeries(selector);
const possibleLabelNames = Object.keys(data); // all names from prometheus
const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
const labelNames = possibleLabelNames.filter((l) => !usedLabelNames.has(l));
return labelNames.map((text) => ({
label: text,
insertText: `${text}${suffix}`,
triggerOnInsert,
}));
}
async function getLabelNamesForSelectorCompletions(
metric: string | undefined,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider);
}
async function getLabelNamesForByCompletions(
metric: string | undefined,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider);
}
async function getLabelValuesForMetricCompletions(
metric: string | undefined,
labelName: string,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
const selector = makeSelector(metric, otherLabels);
const data = await dataProvider.getSeries(selector);
const values = data[labelName] ?? [];
return values.map((text) => ({
label: text,
insertText: `"${text}"`, // FIXME: escaping strange characters?
}));
}
export async function getCompletions(intent: Intent, dataProvider: DataProvider): Promise<Completion[]> {
switch (intent.type) {
case 'ALL_DURATIONS':
return getAllDurationsCompletions();
case 'ALL_METRIC_NAMES':
return getAllMetricNamesCompletions(dataProvider);
case 'FUNCTIONS_AND_ALL_METRIC_NAMES': {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
return [...getAllFunctionsCompletions(), ...metricNames];
}
case 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES': {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
const historyCompletions = await getAllHistoryCompletions(dataProvider);
return [...historyCompletions, ...getAllFunctionsCompletions(), ...metricNames];
}
case 'LABEL_NAMES_FOR_SELECTOR':
return getLabelNamesForSelectorCompletions(intent.metricName, intent.otherLabels, dataProvider);
case 'LABEL_NAMES_FOR_BY':
return getLabelNamesForByCompletions(intent.metricName, intent.otherLabels, dataProvider);
case 'LABEL_VALUES':
return getLabelValuesForMetricCompletions(intent.metricName, intent.labelName, intent.otherLabels, dataProvider);
default:
throw new NeverCaseError(intent);
}
}

View File

@ -0,0 +1,59 @@
import type { Monaco, monacoTypes } from '@grafana/ui';
import { getIntent } from './intent';
import { getCompletions, DataProvider } from './completions';
export function getCompletionProvider(
monaco: Monaco,
dataProvider: DataProvider
): monacoTypes.languages.CompletionItemProvider {
const provideCompletionItems = (
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => {
const word = model.getWordAtPosition(position);
const range =
word != null
? monaco.Range.lift({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
})
: monaco.Range.fromPositions(position);
// documentation says `position` will be "adjusted" in `getOffsetAt`
// i don't know what that means, to be sure i clone it
const positionClone = {
column: position.column,
lineNumber: position.lineNumber,
};
const offset = model.getOffsetAt(positionClone);
const intent = getIntent(model.getValue(), offset);
const completionsPromise = intent != null ? getCompletions(intent, dataProvider) : Promise.resolve([]);
return completionsPromise.then((items) => {
// monaco by-default alphabetically orders the items.
// to stop it, we use a number-as-string sortkey,
// so that monaco keeps the order we use
const maxIndexDigits = items.length.toString().length;
const suggestions = items.map((item, index) => ({
kind: monaco.languages.CompletionItemKind.Text,
label: item.label,
insertText: item.insertText,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
command: item.triggerOnInsert
? {
id: 'editor.action.triggerSuggest',
title: '',
}
: undefined,
}));
return { suggestions };
});
};
return {
triggerCharacters: ['{', ',', '[', '(', '='],
provideCompletionItems,
};
}

View File

@ -0,0 +1,123 @@
import { getIntent, Intent } from './intent';
// we use the `^` character as the cursor-marker in the string.
function assertIntent(situation: string, expectedIntent: Intent | null) {
// first we find the cursor-position
const pos = situation.indexOf('^');
if (pos === -1) {
throw new Error('cursor missing');
}
// we remove the cursor-marker from the string
const text = situation.replace('^', '');
// sanity check, make sure no more cursor-markers remain
if (text.indexOf('^') !== -1) {
throw new Error('multiple cursors');
}
const result = getIntent(text, pos);
if (expectedIntent === null) {
expect(result).toStrictEqual(null);
} else {
expect(result).toMatchObject(expectedIntent);
}
}
describe('intent', () => {
it('handles things', () => {
assertIntent('^', {
type: 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES',
});
assertIntent('sum(one) / ^', {
type: 'FUNCTIONS_AND_ALL_METRIC_NAMES',
});
assertIntent('sum(^)', {
type: 'ALL_METRIC_NAMES',
});
assertIntent('sum(one) / sum(^)', {
type: 'ALL_METRIC_NAMES',
});
assertIntent('something{}[^]', {
type: 'ALL_DURATIONS',
});
});
it('handles label names', () => {
assertIntent('something{^}', {
type: 'LABEL_NAMES_FOR_SELECTOR',
metricName: 'something',
otherLabels: [],
});
assertIntent('sum(something) by (^)', {
type: 'LABEL_NAMES_FOR_BY',
metricName: 'something',
otherLabels: [],
});
assertIntent('sum by (^) (something)', {
type: 'LABEL_NAMES_FOR_BY',
metricName: 'something',
otherLabels: [],
});
assertIntent('something{one="val1",two="val2",^}', {
type: 'LABEL_NAMES_FOR_SELECTOR',
metricName: 'something',
otherLabels: [
{ name: 'one', value: 'val1' },
{ name: 'two', value: 'val2' },
],
});
assertIntent('{^}', {
type: 'LABEL_NAMES_FOR_SELECTOR',
otherLabels: [],
});
assertIntent('{one="val1",^}', {
type: 'LABEL_NAMES_FOR_SELECTOR',
otherLabels: [{ name: 'one', value: 'val1' }],
});
});
it('handles label values', () => {
assertIntent('something{job=^}', {
type: 'LABEL_VALUES',
metricName: 'something',
labelName: 'job',
otherLabels: [],
});
assertIntent('something{job=^,host="h1"}', {
type: 'LABEL_VALUES',
metricName: 'something',
labelName: 'job',
otherLabels: [{ name: 'host', value: 'h1' }],
});
assertIntent('{job=^,host="h1"}', {
type: 'LABEL_VALUES',
labelName: 'job',
otherLabels: [{ name: 'host', value: 'h1' }],
});
assertIntent('something{one="val1",two="val2",three=^,four="val4",five="val5"}', {
type: 'LABEL_VALUES',
metricName: 'something',
labelName: 'three',
otherLabels: [
{ name: 'one', value: 'val1' },
{ name: 'two', value: 'val2' },
{ name: 'four', value: 'val4' },
{ name: 'five', value: 'val5' },
],
});
});
});

View File

@ -0,0 +1,447 @@
import { parser } from 'lezer-promql';
import type { Tree, SyntaxNode } from 'lezer-tree';
import { NeverCaseError } from './util';
type Direction = 'parent' | 'firstChild' | 'lastChild';
type NodeTypeName =
| '⚠' // this is used as error-name
| 'AggregateExpr'
| 'AggregateModifier'
| 'FunctionCallBody'
| 'GroupingLabels'
| 'Identifier'
| 'LabelMatcher'
| 'LabelMatchers'
| 'LabelMatchList'
| 'LabelName'
| 'MetricIdentifier'
| 'PromQL'
| 'StringLiteral'
| 'VectorSelector'
| 'MatrixSelector';
type Path = Array<[Direction, NodeTypeName]>;
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;
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.name !== 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 {
// FIXME: support https://prometheus.io/docs/prometheus/latest/querying/basics/#string-literals
// FIXME: maybe check other promql code, if all is supported or not
// we start with double-quotes
if (text.startsWith('"') && text.endsWith('"')) {
if (text.indexOf('\\') !== -1) {
throw new Error('FIXME: escape-sequences not supported in label-values');
}
return text.slice(1, text.length - 1);
} else {
throw new Error('FIXME: invalid string literal');
}
}
export type Label = {
name: string;
value: string;
};
export type Intent =
| {
type: 'ALL_METRIC_NAMES';
}
| {
type: 'FUNCTIONS_AND_ALL_METRIC_NAMES';
}
| {
type: 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES';
}
| {
type: 'ALL_DURATIONS';
}
| {
type: 'LABEL_NAMES_FOR_SELECTOR';
metricName?: string;
otherLabels: Label[];
}
| {
type: 'LABEL_NAMES_FOR_BY';
metricName: string;
otherLabels: Label[];
}
| {
type: 'LABEL_VALUES';
metricName?: string;
labelName: string;
otherLabels: Label[];
};
type Resolver = {
path: NodeTypeName[];
fun: (node: SyntaxNode, text: string, pos: number) => Intent | null;
};
function isPathMatch(resolverPath: string[], cursorPath: string[]): boolean {
return resolverPath.every((item, index) => item === cursorPath[index]);
}
const ERROR_NODE_NAME: NodeTypeName = '⚠'; // this is used as error-name
const RESOLVERS: Resolver[] = [
{
path: ['LabelMatchers', 'VectorSelector'],
fun: resolveLabelKeysWithEquals,
},
{
path: ['PromQL'],
fun: resolveTopLevel,
},
{
path: ['FunctionCallBody'],
fun: resolveInFunction,
},
{
path: [ERROR_NODE_NAME, 'LabelMatcher'],
fun: resolveLabelMatcherError,
},
{
path: [ERROR_NODE_NAME, 'MatrixSelector'],
fun: resolveDurations,
},
{
path: ['GroupingLabels'],
fun: resolveLabelsForGrouping,
},
];
function getLabel(labelMatcherNode: SyntaxNode, text: string): Label | null {
if (labelMatcherNode.type.name !== 'LabelMatcher') {
return null;
}
const nameNode = walk(labelMatcherNode, [['firstChild', 'LabelName']]);
if (nameNode === null) {
return null;
}
const valueNode = walk(labelMatcherNode, [['lastChild', 'StringLiteral']]);
if (valueNode === null) {
return null;
}
const name = getNodeText(nameNode, text);
const value = parsePromQLStringLiteral(getNodeText(valueNode, text));
return { name, value };
}
function getLabels(labelMatchersNode: SyntaxNode, text: string): Label[] {
if (labelMatchersNode.type.name !== 'LabelMatchers') {
return [];
}
let listNode: SyntaxNode | null = walk(labelMatchersNode, [['firstChild', 'LabelMatchList']]);
const labels: Label[] = [];
while (listNode !== null) {
const matcherNode = walk(listNode, [['lastChild', 'LabelMatcher']]);
if (matcherNode === null) {
// unexpected, we stop
return [];
}
const label = getLabel(matcherNode, text);
if (label !== null) {
labels.push(label);
}
// there might be more labels
listNode = walk(listNode, [['firstChild', 'LabelMatchList']]);
}
// our labels-list is last-first, so we reverse it
labels.reverse();
return labels;
}
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, typeName: NodeTypeName): SyntaxNode | null {
// first we try the current node
if (node.type.name === typeName) {
return node;
}
// then we try the children
const children = getNodeChildren(node);
for (const child of children) {
const n = getNodeInSubtree(child, typeName);
if (n !== null) {
return n;
}
}
return null;
}
function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): Intent | 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, 'MetricIdentifier');
if (metricIdNode === null) {
return null;
}
const idNode = walk(metricIdNode, [['firstChild', 'Identifier']]);
if (idNode === null) {
return null;
}
const metricName = getNodeText(idNode, text);
return {
type: 'LABEL_NAMES_FOR_BY',
metricName,
otherLabels: [],
};
}
function resolveLabelMatcherError(node: SyntaxNode, text: string, pos: number): Intent | null {
// we are probably in the scenario where the user is before entering the
// label-value, like `{job=^}` (^ marks the cursor)
const parent = walk(node, [['parent', 'LabelMatcher']]);
if (parent === null) {
return null;
}
const labelNameNode = walk(parent, [['firstChild', 'LabelName']]);
if (labelNameNode === null) {
return null;
}
const labelName = getNodeText(labelNameNode, text);
// now we need to go up, to the parent of LabelMatcher,
// there can be one or many `LabelMatchList` parents, we have
// to go through all of them
const firstListNode = walk(parent, [['parent', 'LabelMatchList']]);
if (firstListNode === null) {
return null;
}
let listNode = firstListNode;
// we keep going through the parent-nodes
// as long as they are LabelMatchList.
// as soon as we reawch LabelMatchers, we stop
let labelMatchersNode: SyntaxNode | null = null;
while (labelMatchersNode === null) {
const p = listNode.parent;
if (p === null) {
return null;
}
const { name } = p.type;
switch (name) {
case 'LabelMatchList':
//we keep looping
listNode = p;
continue;
case 'LabelMatchers':
// we reached the end, we can stop the loop
labelMatchersNode = p;
continue;
default:
// we reached some other node, we stop
return null;
}
}
// now we need to find the other names
const otherLabels = getLabels(labelMatchersNode, text);
const metricNameNode = walk(labelMatchersNode, [
['parent', 'VectorSelector'],
['firstChild', 'MetricIdentifier'],
['firstChild', 'Identifier'],
]);
if (metricNameNode === null) {
// we are probably in a situation without a metric name
return {
type: 'LABEL_VALUES',
labelName,
otherLabels,
};
}
const metricName = getNodeText(metricNameNode, text);
return {
type: 'LABEL_VALUES',
metricName,
labelName,
otherLabels,
};
}
function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Intent {
return {
type: 'FUNCTIONS_AND_ALL_METRIC_NAMES',
};
}
function resolveInFunction(node: SyntaxNode, text: string, pos: number): Intent {
return {
type: 'ALL_METRIC_NAMES',
};
}
function resolveDurations(node: SyntaxNode, text: string, pos: number): Intent {
return {
type: 'ALL_DURATIONS',
};
}
function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Intent | null {
const metricNameNode = walk(node, [
['parent', 'VectorSelector'],
['firstChild', 'MetricIdentifier'],
['firstChild', 'Identifier'],
]);
const otherLabels = getLabels(node, text);
if (metricNameNode === null) {
// we are probably in a situation without a metric name.
return {
type: 'LABEL_NAMES_FOR_SELECTOR',
otherLabels,
};
}
const metricName = getNodeText(metricNameNode, text);
return {
type: 'LABEL_NAMES_FOR_SELECTOR',
metricName,
otherLabels,
};
}
// 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.cursor(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;
}
export function getIntent(text: string, pos: number): Intent | 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: 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES',
};
}
/*
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.cursor(pos);
const currentNode = cur.node;
const names = [cur.name];
while (cur.parent()) {
names.push(cur.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, names)) {
return resolver.fun(currentNode, text, pos);
}
}
return null;
}

View File

@ -0,0 +1,25 @@
// this helper class is used to make typescript warn you when you forget
// a case-block in a switch statement.
// example code that triggers the typescript-error:
//
// const x:'A'|'B'|'C' = 'A';
//
// switch(x) {
// case 'A':
// // something
// case 'B':
// // something
// default:
// throw new NeverCaseError(x);
// }
//
//
// typescript will show an error in this case,
// when you add the missing `case 'C'` code,
// the problem will be fixed.
export class NeverCaseError extends Error {
constructor(value: never) {
super('should never happen');
}
}

View File

@ -15383,6 +15383,23 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lezer-promql@0.20.0:
version "0.20.0"
resolved "https://registry.yarnpkg.com/lezer-promql/-/lezer-promql-0.20.0.tgz#d5d233fa5dfc5fb7fcd3efe0022fd31dd2f2d539"
integrity sha512-1CHG77fFghl032FfHT33buGyAHiTaMy2fqicEhcp2wWnbxZxS+Jt6gMzEUaf/TmRTIUJofj9uLar7iL22Jazug==
lezer-tree@0.13.2, lezer-tree@^0.13.2:
version "0.13.2"
resolved "https://registry.yarnpkg.com/lezer-tree/-/lezer-tree-0.13.2.tgz#00f4671309b15c27b131f637e430ce2d4d5f7065"
integrity sha512-15ZxW8TxVNAOkHIo43Iouv4zbSkQQ5chQHBpwXcD2bBFz46RB4jYLEEww5l1V0xyIx9U2clSyyrLes+hAUFrGQ==
lezer@0.13.5:
version "0.13.5"
resolved "https://registry.yarnpkg.com/lezer/-/lezer-0.13.5.tgz#6000536bca7e24a5bd62e8cb4feff28b37e7dd8f"
integrity sha512-cAiMQZGUo2BD8mpcz7Nv1TlKzWP7YIdIRrX41CiP5bk5t4GHxskOxWUx2iAOuHlz8dO+ivbuXr0J1bfHsWD+lQ==
dependencies:
lezer-tree "^0.13.2"
lilconfig@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd"
@ -16527,6 +16544,11 @@ monaco-editor@0.21.2:
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.21.2.tgz#37054e63e480d51a2dd17d609dcfb192304d5605"
integrity sha512-jS51RLuzMaoJpYbu7F6TPuWpnWTLD4kjRW0+AZzcryvbxrTwhNy1KC9yboyKpgMTahpUbDUsuQULoo0GV1EPqg==
monaco-promql@^1.7.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/monaco-promql/-/monaco-promql-1.7.2.tgz#e17cacb6abd0adece90a72a0f67221293dc64664"
integrity sha512-T8k6tKalUbhgD+anB5Nw9K9Cb/yWhGq5YU9PDhALyS+hkFsrk0SwAfRBL1uqGNu+XGJ4xCGOwzhDFLiemRf+bw==
moo-color@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.2.tgz#837c40758d2d58763825d1359a84e330531eca64"