From a7ba662b028db307d5a82a24e554ae4bc02c1740 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Tue, 5 Aug 2025 06:28:50 +0200 Subject: [PATCH] Prometheus: Fix eager auto completion (#109128) * fix auto completion * introduce tests * cleaning * remove unnecessary test --- .../monaco-query-field/MonacoQueryField.tsx | 1 + .../monaco-completion-provider.test.ts | 367 ++++++++++++++++++ .../monaco-completion-provider.ts | 165 +++----- 3 files changed, 428 insertions(+), 105 deletions(-) create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.test.ts diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx index e37f9b47f57..69119d2de92 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx @@ -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 diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.test.ts new file mode 100644 index 00000000000..e4d122d8a18 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.test.ts @@ -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) => mockGetCompletions(...args), +})); + +jest.mock('./situation', () => ({ + getSituation: (...args: Parameters) => 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); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts index 576985fae86..e156d5119dc 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts @@ -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 | 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 => { 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 => { - // 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,