The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/autocomplete.ts

224 lines
7.1 KiB

import { monacoTypes, Monaco } from '@grafana/ui';
/**
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
* autocomplete system.
*
* At this moment we just pass it all the labels/values we get from Pyroscope backend later on we may do something a bit
* smarter if there will be lots of labels.
*/
export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider {
triggerCharacters = ['{', ',', '[', '(', '=', '~', ' ', '"'];
// We set these directly and ae required for the provider to function.
monaco: Monaco | undefined;
editor: monacoTypes.editor.IStandaloneCodeEditor | undefined;
private labels: string[] = [];
private getLabelValues: (label: string) => Promise<string[]> = () => Promise.resolve([]);
init(labels: string[], getLabelValues: (label: string) => Promise<string[]>) {
this.labels = labels;
this.getLabelValues = getLabelValues;
}
provideCompletionItems(
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> {
// Should not happen, this should not be called before it is initialized
if (!(this.monaco && this.editor)) {
throw new Error('provideCompletionItems called before CompletionProvider was initialized');
}
// if the model-id does not match, then this call is from a different editor-instance,
// not "our instance", so return nothing
if (this.editor.getModel()?.id !== model.id) {
return { suggestions: [] };
}
const { range, offset } = getRangeAndOffset(this.monaco, model, position);
const situation = getSituation(model.getValue(), offset);
return this.getCompletions(situation).then((completionItems) => {
// 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 = completionItems.length.toString().length;
const suggestions: monacoTypes.languages.CompletionItem[] = completionItems.map((item, index) => ({
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
label: item.label,
insertText: item.insertText,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
}));
return { suggestions };
});
}
/**
* Get suggestion based on the situation we are in like whether we should suggest label names or values.
* @param situation
* @private
*/
private async getCompletions(situation: Situation): Promise<Completion[]> {
switch (situation.type) {
// Not really sure what would make sense to suggest in this case so just leave it
case 'UNKNOWN': {
return [];
}
case 'EMPTY': {
return this.labels.map((key) => {
return {
label: key,
insertText: `{${key}="`,
type: 'LABEL_NAME',
};
});
}
case 'IN_LABEL_NAME':
return this.labels.map((key) => {
return {
label: key,
insertText: key,
type: 'LABEL_NAME',
};
});
case 'IN_LABEL_VALUE':
let values = await this.getLabelValues(situation.labelName);
return values
? values.map((key) => {
return {
label: key,
insertText: situation.betweenQuotes ? key : `"${key}"`,
type: 'LABEL_VALUE',
};
})
: [];
default:
throw new Error(`Unexpected situation ${situation}`);
}
}
}
/**
* Get item kind which is used for icon next to the suggestion.
* @param type
* @param monaco
*/
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
switch (type) {
case 'LABEL_NAME':
return monaco.languages.CompletionItemKind.Enum;
case 'LABEL_VALUE':
return monaco.languages.CompletionItemKind.EnumMember;
default:
throw new Error(`Unexpected CompletionType: ${type}`);
}
}
export type CompletionType = 'LABEL_NAME' | 'LABEL_VALUE';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
};
export type Label = {
name: string;
value: string;
};
export type Situation =
| {
type: 'UNKNOWN';
}
| {
type: 'EMPTY';
}
| {
type: 'IN_LABEL_NAME';
otherLabels: Label[];
}
| {
type: 'IN_LABEL_VALUE';
labelName: string;
betweenQuotes: boolean;
otherLabels: Label[];
};
const labelNameRegex = /[a-zA-Z_][a-zA-Z0-9_]*/;
const labelValueRegex = /[^"]*/; // anything except a double quote
const labelPairsRegex = new RegExp(`(${labelNameRegex.source})="(${labelValueRegex.source})"`, 'g');
const inLabelValueRegex = new RegExp(`(${labelNameRegex.source})=("?)${labelValueRegex.source}$`);
const inLabelNameRegex = new RegExp(/[{,]\s*[a-zA-Z0-9_]*$/);
/**
* Figure out where is the cursor and what kind of suggestions are appropriate.
* As currently Pyroscope handles just a simple {foo="bar", baz="zyx"} kind of values we can do with simple regex to figure
* out where we are with the cursor.
* @param text
* @param offset
*/
function getSituation(text: string, offset: number): Situation {
if (text === '') {
return {
type: 'EMPTY',
};
}
// Get all the labels so far in the query, so we can do some more filtering.
const matches = text.matchAll(labelPairsRegex);
const existingLabels = Array.from(matches).reduce<Label[]>((acc, match) => {
const [_, name, value] = match[1];
acc.push({ name, value });
return acc;
}, []);
// Check if we are editing a label value right now. If so also get name of the label
const matchLabelValue = text.substring(0, offset).match(inLabelValueRegex);
if (matchLabelValue) {
return {
type: 'IN_LABEL_VALUE',
labelName: matchLabelValue[1],
betweenQuotes: !!matchLabelValue[2],
otherLabels: existingLabels,
};
}
// Check if we are editing a label name
const matchLabelName = text.substring(0, offset).match(inLabelNameRegex);
if (matchLabelName) {
return {
type: 'IN_LABEL_NAME',
otherLabels: existingLabels,
};
}
// Will happen only if user writes something that isn't really a label selector
return {
type: 'UNKNOWN',
};
}
function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) {
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` so we clone it here just for sure.
const positionClone = {
column: position.column,
lineNumber: position.lineNumber,
};
const offset = model.getOffsetAt(positionClone);
return { offset, range };
}