diff --git a/package.json b/package.json index 0b25570bd12..df5f20e70d5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 430fa8d2cf9..0885efbec99 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -50,6 +50,7 @@ export interface FeatureToggles { accesscontrol: boolean; tempoServiceGraph: boolean; tempoSearch: boolean; + prometheusMonaco: boolean; } /** diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 59b48744923..d1a23c057fc 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -65,6 +65,7 @@ export class GrafanaBootConfig implements GrafanaConfig { trimDefaults: false, tempoServiceGraph: false, tempoSearch: false, + prometheusMonaco: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index b116066453f..789fd5fb6c7 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -88,12 +88,14 @@ class UnthemedCodeEditor extends React.PureComponent { 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) => { diff --git a/packages/grafana-ui/src/components/Monaco/types.ts b/packages/grafana-ui/src/components/Monaco/types.ts index 966c038d627..a1d11565cf1 100644 --- a/packages/grafana-ui/src/components/Monaco/types.ts +++ b/packages/grafana-ui/src/components/Monaco/types.ts @@ -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 */ diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index e580ac8faa7..667b99a84c1 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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 diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index f3acc28b59e..2407b80317b 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -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
- + {isMonacoEditorEnabled ? ( + + ) : ( + + )}
{labelBrowserVisible && ( diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx new file mode 100644 index 00000000000..ab4026256f1 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx @@ -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(null); + const { languageProvider, history, onChange, initialValue } = props; + + const lpRef = useLatest(languageProvider); + const historyRef = useLatest(history); + + return ( +
+ { + // 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: + // + 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(); + }); + }} + /> +
+ ); +}; + +// 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; diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldLazy.tsx b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldLazy.tsx new file mode 100644 index 00000000000..2dee83c6e2e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldLazy.tsx @@ -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 ( + + + + ); +}; diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldProps.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldProps.ts new file mode 100644 index 00000000000..a911c60d895 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldProps.ts @@ -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>; + onChange: (query: string) => void; + onRunQuery: () => void; +}; diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts new file mode 100644 index 00000000000..507136cc20c --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -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; + getAllMetricNames: () => Promise; + getSeries: (selector: string) => Promise>; +}; + +// we order items like: history, functions, metrics + +async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise { + 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 { + // 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 { + 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 { + return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider); +} +async function getLabelNamesForByCompletions( + metric: string | undefined, + otherLabels: Label[], + dataProvider: DataProvider +): Promise { + return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider); +} + +async function getLabelValuesForMetricCompletions( + metric: string | undefined, + labelName: string, + otherLabels: Label[], + dataProvider: DataProvider +): Promise { + 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 { + 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); + } +} diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts new file mode 100644 index 00000000000..91cbaabfb7f --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts @@ -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 => { + 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, + }; +} diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/intent.test.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/intent.test.ts new file mode 100644 index 00000000000..d07ffa97ba4 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/intent.test.ts @@ -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' }, + ], + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/intent.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/intent.ts new file mode 100644 index 00000000000..ff66cdbefff --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/intent.ts @@ -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; +} diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/util.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/util.ts new file mode 100644 index 00000000000..35babca0d09 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/util.ts @@ -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'); + } +} diff --git a/yarn.lock b/yarn.lock index 4a51a279928..564745f30b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"