mirror of https://github.com/grafana/grafana
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>pull/38588/head^2
parent
8d3b31ff23
commit
a5d11a3bef
@ -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; |
@ -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> |
||||
); |
||||
}; |
@ -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; |
||||
}; |
@ -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); |
||||
} |
||||
} |
@ -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, |
||||
}; |
||||
} |
@ -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' }, |
||||
], |
||||
}); |
||||
}); |
||||
}); |
@ -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; |
||||
} |
@ -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'); |
||||
} |
||||
} |
Loading…
Reference in new issue