mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
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.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 };
|
|
}
|