Prometheus: Fix eager auto completion (#109128)

* fix auto completion

* introduce tests

* cleaning

* remove unnecessary test
This commit is contained in:
ismail simsek
2025-08-05 06:28:50 +02:00
committed by GitHub
parent f65e501e1b
commit a7ba662b02
3 changed files with 428 additions and 105 deletions
@@ -48,6 +48,7 @@ const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
suggest: getSuggestOptions(),
suggestFontSize: 12,
wordWrap: 'on',
quickSuggestionsDelay: 250,
};
// this number was chosen by testing various values. it might be necessary
@@ -0,0 +1,367 @@
import { dateTime, TimeRange } from '@grafana/data';
import type { Monaco, monacoTypes } from '@grafana/ui';
import { DataProvider } from './data_provider';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
// Mock the dependencies
jest.mock('./completions');
jest.mock('./situation');
const mockGetCompletions = jest.fn();
const mockGetSituation = jest.fn();
jest.mock('./completions', () => ({
getCompletions: (...args: Parameters<typeof mockGetCompletions>) => mockGetCompletions(...args),
}));
jest.mock('./situation', () => ({
getSituation: (...args: Parameters<typeof mockGetSituation>) => mockGetSituation(...args),
}));
// Create proper Monaco mocks without 'any'
const createMockMonaco = (): Monaco => {
const mockRange = {
lift: jest.fn((range: monacoTypes.IRange) => range),
fromPositions: jest.fn((position: monacoTypes.Position) => ({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: position.column,
endColumn: position.column,
})),
};
return {
languages: {
CompletionItemKind: {
Unit: 0,
Variable: 1,
Snippet: 2,
Enum: 3,
EnumMember: 4,
Constructor: 5,
} as const,
},
Range: mockRange,
} as unknown as Monaco;
};
const createMockModel = (
value: string,
mockWord: monacoTypes.editor.IWordAtPosition | null = null
): monacoTypes.editor.ITextModel => {
return {
getValue: () => value,
getValueInRange: jest.fn((range: monacoTypes.IRange) => {
// Convert to 0-based indexing
const startIndex = Math.max(0, range.startColumn - 1);
const endIndex = Math.min(value.length, range.endColumn - 1);
return value.substring(startIndex, endIndex);
}),
getWordAtPosition: jest.fn(() => mockWord),
getOffsetAt: jest.fn((position: monacoTypes.Position) => position.column - 1),
id: 'test-model',
} as unknown as monacoTypes.editor.ITextModel;
};
const createMockPosition = (column: number, lineNumber = 1): monacoTypes.Position =>
({
column,
lineNumber,
}) as monacoTypes.Position;
const createMockDataProvider = (): DataProvider => {
return {
monacoSettings: {
setInputInRange: jest.fn(),
suggestionsIncomplete: false,
},
} as unknown as DataProvider;
};
const createMockTimeRange = (): TimeRange => ({
from: dateTime(Date.now() - 3600000), // 1 hour ago
to: dateTime(Date.now()),
raw: { from: 'now-1h', to: 'now' },
});
describe('monaco-completion-provider', () => {
let monaco: Monaco;
let dataProvider: DataProvider;
let timeRange: TimeRange;
beforeEach(() => {
monaco = createMockMonaco();
dataProvider = createMockDataProvider();
timeRange = createMockTimeRange();
// Reset mocks
jest.clearAllMocks();
mockGetCompletions.mockResolvedValue([]);
mockGetSituation.mockReturnValue({ type: 'METRIC_NAME' });
// Mock window.getSelection
Object.defineProperty(window, 'getSelection', {
writable: true,
value: jest.fn(() => ({
toString: () => '',
})),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getSuggestOptions', () => {
it('should return options with showWords set to false', () => {
const options = getSuggestOptions();
expect(options).toEqual({
showWords: false,
});
});
});
describe('getCompletionProvider', () => {
it('should return provider and state objects', () => {
const result = getCompletionProvider(monaco, dataProvider, timeRange);
expect(result).toHaveProperty('provider');
expect(result).toHaveProperty('state');
expect(result.state).toHaveProperty('isManualTriggerRequested', false);
expect(result.provider).toHaveProperty('triggerCharacters');
expect(result.provider).toHaveProperty('provideCompletionItems');
});
it('should have correct trigger characters', () => {
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
expect(provider.triggerCharacters).toEqual(['{', ',', '[', '(', '=', '~', ' ', '"']);
});
});
describe('provideCompletionItems', () => {
it('should return empty suggestions when no situation is detected', async () => {
mockGetSituation.mockReturnValue(null);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
const model = createMockModel('test');
const position = createMockPosition(4);
const result = await (provider.provideCompletionItems as Function)(model, position);
expect(result).toEqual({
suggestions: [],
incomplete: false,
});
});
it('should call getCompletions with correct parameters for normal word', async () => {
const mockWord = { word: 'grafana', startColumn: 1, endColumn: 7 };
const model = createMockModel('grafana', mockWord);
const position = createMockPosition(7);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
await (provider.provideCompletionItems as Function)(model, position);
expect(mockGetCompletions).toHaveBeenCalledWith(
{ type: 'METRIC_NAME' },
dataProvider,
timeRange,
'grafana',
'full' // Should be 'full' because word length >= 3
);
});
it('should use partial trigger type for short words', async () => {
const mockWord = { word: 'go', startColumn: 1, endColumn: 3 };
const model = createMockModel('go', mockWord);
const position = createMockPosition(3);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
await (provider.provideCompletionItems as Function)(model, position);
expect(mockGetCompletions).toHaveBeenCalledWith(
{ type: 'METRIC_NAME' },
dataProvider,
timeRange,
'go',
'partial' // Should be 'partial' because word length < 3
);
});
it('should format completion items correctly', async () => {
const mockCompletions = [
{
label: 'test_metric',
detail: 'A test metric',
insertText: 'test_metric',
documentation: 'Test documentation',
insertTextRules: undefined,
type: 'METRIC_NAME' as const,
triggerOnInsert: false,
},
];
mockGetCompletions.mockResolvedValue(mockCompletions);
const mockWord = { word: 'test', startColumn: 1, endColumn: 5 };
const model = createMockModel('test', mockWord);
const position = createMockPosition(5);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
const result = await (provider.provideCompletionItems as Function)(model, position);
expect(result?.suggestions).toHaveLength(1);
expect(result?.suggestions?.[0]).toMatchObject({
label: 'test_metric',
detail: 'A test metric',
insertText: 'test_metric',
documentation: 'Test documentation',
kind: 5, // Constructor kind for METRIC_NAME
sortText: '0',
command: undefined,
});
});
it('should add trigger command for items with triggerOnInsert', async () => {
const mockCompletions = [
{
label: 'func(',
insertText: 'func(',
type: 'FUNCTION' as const,
triggerOnInsert: true,
},
];
mockGetCompletions.mockResolvedValue(mockCompletions);
const model = createMockModel('func');
const position = createMockPosition(4);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
const result = await (provider.provideCompletionItems as Function)(model, position);
expect(result?.suggestions?.[0]?.command).toEqual({
id: 'editor.action.triggerSuggest',
title: '',
});
});
});
describe('manual trigger handling', () => {
it('should use full trigger type for manual trigger', async () => {
const mockWord = { word: 'te', startColumn: 1, endColumn: 3 };
const model = createMockModel('te', mockWord);
const position = createMockPosition(3);
const { provider, state } = getCompletionProvider(monaco, dataProvider, timeRange);
// Set manual trigger flag
state.isManualTriggerRequested = true;
await (provider.provideCompletionItems as Function)(model, position);
expect(mockGetCompletions).toHaveBeenCalledWith(
{ type: 'METRIC_NAME' },
dataProvider,
timeRange,
'te',
'full' // Should be 'full' despite short word length
);
});
});
describe('trigger character handling', () => {
const triggerCharacters = ['{', ',', '[', '(', '=', '~', ' ', '"'];
triggerCharacters.forEach((triggerChar) => {
it(`should use full trigger type for trigger character "${triggerChar}"`, async () => {
const testString = `grafana${triggerChar}`;
const model = createMockModel(testString, null);
const position = createMockPosition(testString.length + 1); // After trigger character (1-indexed)
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
await (provider.provideCompletionItems as Function)(model, position);
expect(mockGetCompletions).toHaveBeenCalledWith(
{ type: 'METRIC_NAME' },
dataProvider,
timeRange,
undefined, // No word at position after trigger char
'full'
);
});
});
it('should handle trigger character at beginning of line', async () => {
const model = createMockModel('{', null);
const position = createMockPosition(2); // After the { character
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
await (provider.provideCompletionItems as Function)(model, position);
// Should not fail and should still call getCompletions
expect(mockGetCompletions).toHaveBeenCalled();
});
});
describe('selection handling', () => {
it('should adjust cursor position when text is selected', async () => {
// Mock selected text
Object.defineProperty(window, 'getSelection', {
writable: true,
value: jest.fn(() => ({
toString: () => 'selected',
})),
});
const model = createMockModel('grafana selected');
const position = createMockPosition(16); // End of string
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
await (provider.provideCompletionItems as Function)(model, position);
// Should call getOffsetAt with adjusted position
expect(model.getOffsetAt).toHaveBeenCalledWith({
column: 8, // 16 - 8 (length of 'selected')
lineNumber: 1,
});
});
});
describe('data provider integration', () => {
it('should set input range on data provider', async () => {
const mockWord = { word: 'test', startColumn: 1, endColumn: 5 };
const model = createMockModel('test', mockWord);
const position = createMockPosition(5);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
await (provider.provideCompletionItems as Function)(model, position);
expect(dataProvider.monacoSettings.setInputInRange).toHaveBeenCalled();
});
it('should return incomplete status from data provider', async () => {
dataProvider.monacoSettings.suggestionsIncomplete = true;
mockGetCompletions.mockResolvedValue([]);
const model = createMockModel('test');
const position = createMockPosition(4);
const { provider } = getCompletionProvider(monaco, dataProvider, timeRange);
const result = await (provider.provideCompletionItems as Function)(model, position);
expect(result?.incomplete).toBe(true);
});
});
});
@@ -9,6 +9,13 @@ import { NeverCaseError } from './util';
export type TriggerType = 'partial' | 'full';
export type MonacoQueryFieldLocalState = {
isManualTriggerRequested: boolean;
};
const TRIGGER_CHARACTERS = ['{', ',', '[', '(', '=', '~', ' ', '"'];
const MIN_WORD_LENGTH_FOR_FULL_COMPLETIONS = 3;
export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions {
return {
// monaco-editor sometimes provides suggestions automatically, i am not
@@ -50,19 +57,16 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona
}
function getTriggerType(
context: monacoTypes.languages.CompletionContext,
word: monacoTypes.editor.IWordAtPosition | null,
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position,
isManualTrigger: boolean
state: MonacoQueryFieldLocalState
): TriggerType {
// Manual trigger (Ctrl+Space)
if (isManualTrigger) {
// Manual trigger (Ctrl+Space) - always full completions
if (state.isManualTriggerRequested) {
return 'full';
}
// Trigger characters
const triggerChars = ['{', ',', '[', '(', '=', '~', ' ', '"'];
const charBeforeCursor = model.getValueInRange({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
@@ -70,12 +74,12 @@ function getTriggerType(
endColumn: position.column,
});
if (triggerChars.includes(charBeforeCursor)) {
if (TRIGGER_CHARACTERS.includes(charBeforeCursor)) {
return 'full';
}
// Word length >= 3
if (word && word.word.length >= 3) {
// For typed words of sufficient length, use full completions
if (word && word.word.length >= MIN_WORD_LENGTH_FOR_FULL_COMPLETIONS) {
return 'full';
}
@@ -86,20 +90,14 @@ export function getCompletionProvider(
monaco: Monaco,
dataProvider: DataProvider,
timeRange: TimeRange
): { provider: monacoTypes.languages.CompletionItemProvider; state: { isManualTriggerRequested: boolean } } {
// Short debounce to catch rapid typing
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_DELAY = 150; // Much shorter delay to catch rapid typing
// Simple local state
const state = {
): { provider: monacoTypes.languages.CompletionItemProvider; state: MonacoQueryFieldLocalState } {
const state: MonacoQueryFieldLocalState = {
isManualTriggerRequested: false,
};
const provideCompletionItems = (
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position,
context: monacoTypes.languages.CompletionContext
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => {
const word = model.getWordAtPosition(position);
const range =
@@ -112,111 +110,68 @@ export function getCompletionProvider(
})
: monaco.Range.fromPositions(position);
const isManualTrigger = state.isManualTriggerRequested;
if (isManualTrigger) {
state.isManualTriggerRequested = false;
// Set input range for data provider
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
// Get adjusted position for cursor/selection handling
const adjustedPosition = getAdjustedPosition(position);
const offset = model.getOffsetAt(adjustedPosition);
const situation = getSituation(model.getValue(), offset);
// Early exit if no situation detected
if (situation === null) {
return Promise.resolve({ suggestions: [], incomplete: false });
}
const triggerType: TriggerType = getTriggerType(context, word, model, position, isManualTrigger);
const triggerType: TriggerType = getTriggerType(word, model, position, state);
// For immediate triggers (manual, trigger chars, or already 3+ chars), execute immediately
const isImmediate = isManualTrigger || triggerType === 'full';
return getCompletions(situation, dataProvider, timeRange, word?.word, triggerType).then((items) => {
// Monaco by-default alphabetically orders the items.
// We use a number-as-string sortkey to maintain our custom order
const maxIndexDigits = items.length > 0 ? items.length.toString().length : 1;
const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({
kind: getMonacoCompletionItemKind(item.type, monaco),
label: item.label,
insertText: item.insertText,
insertTextRules: item.insertTextRules,
detail: item.detail,
documentation: item.documentation,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
command: item.triggerOnInsert
? {
id: 'editor.action.triggerSuggest',
title: '',
}
: undefined,
}));
if (isImmediate) {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
return executeCompletionLogic(model, position, range, dataProvider, timeRange, word?.word, triggerType);
}
// For typing scenarios, use short debounce to catch rapid typing
if (debounceTimer) {
clearTimeout(debounceTimer);
}
return new Promise((resolve) => {
debounceTimer = setTimeout(() => {
// Re-check if we should use full completions after debounce
const updatedWord = model.getWordAtPosition(position);
const updatedTriggerType: TriggerType = getTriggerType(context, updatedWord, model, position, false)
? 'full'
: 'partial';
executeCompletionLogic(
model,
position,
range,
dataProvider,
timeRange,
updatedWord?.word,
updatedTriggerType
).then(resolve);
}, DEBOUNCE_DELAY);
return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete };
});
};
const executeCompletionLogic = async (
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position,
range: monacoTypes.Range,
dataProvider: DataProvider,
timeRange: TimeRange,
wordText?: string,
triggerType: TriggerType = 'full'
): Promise<monacoTypes.languages.CompletionList> => {
// 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,
};
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
// Helper function to handle position adjustment for selection
function getAdjustedPosition(position: monacoTypes.Position): { column: number; lineNumber: number } {
let adjustedColumn = position.column;
// Check to see if the browser supports window.getSelection()
if (window.getSelection) {
const selectedText = window.getSelection()?.toString();
// If the user has selected text, adjust the cursor position to be at the start of the selection, instead of the end
// If the user has selected text, adjust the cursor position to be at the start of the selection
if (selectedText && selectedText.length > 0) {
positionClone.column = positionClone.column - selectedText.length;
adjustedColumn = Math.max(1, adjustedColumn - selectedText.length);
}
}
const offset = model.getOffsetAt(positionClone);
const situation = getSituation(model.getValue(), offset);
const completionsPromise =
situation != null
? getCompletions(situation, dataProvider, timeRange, wordText, triggerType)
: 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: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({
range,
label: item.label,
detail: item.detail,
insertText: item.insertText,
documentation: item.documentation,
insertTextRules: item.insertTextRules,
kind: getMonacoCompletionItemKind(item.type, monaco),
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
command: item.triggerOnInsert ? { id: 'editor.action.triggerSuggest', title: '' } : undefined,
}));
return {
suggestions,
incomplete: dataProvider.monacoSettings.suggestionsIncomplete,
};
});
};
return {
column: adjustedColumn,
lineNumber: position.lineNumber,
};
}
return {
provider: {
triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'],
triggerCharacters: TRIGGER_CHARACTERS,
provideCompletionItems,
},
state,