diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/filterQuery.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/filterQuery.ts new file mode 100644 index 00000000000..3cb71d829cd --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/filterQuery.ts @@ -0,0 +1,17 @@ +import { monacoTypes } from '@grafana/ui'; + +import { LogsTokenTypes } from '../../language/logs/completion/types'; +import { CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID } from '../../language/logs/definition'; + +export const filterQuery = { + query: `filter logGroup `, + tokens: [ + [ + { offset: 0, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 6, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 7, type: LogsTokenTypes.Identifier, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 15, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + ], + ] as monacoTypes.Token[][], + position: { lineNumber: 1, column: 16 }, +}; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/index.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/index.ts index e656502929d..03832032916 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/index.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/index.ts @@ -3,3 +3,6 @@ export { whitespaceOnlyQuery } from './whitespaceQuery'; export { commentOnlyQuery } from './commentOnlyQuery'; export { singleLineFullQuery } from './singleLineFullQuery'; export { multiLineFullQuery } from './multiLineFullQuery'; +export { filterQuery } from './filterQuery'; +export { newCommandQuery } from './newCommandQuery'; +export { sortQuery } from './sortQuery'; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/newCommandQuery.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/newCommandQuery.ts new file mode 100644 index 00000000000..f9bbf983e52 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/newCommandQuery.ts @@ -0,0 +1,19 @@ +import { monacoTypes } from '@grafana/ui'; + +import { LogsTokenTypes } from '../../language/logs/completion/types'; +import { CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID } from '../../language/logs/definition'; + +export const newCommandQuery = { + query: `fields @timestamp | `, + tokens: [ + [ + { offset: 0, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 6, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 7, type: LogsTokenTypes.Identifier, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 17, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 18, type: LogsTokenTypes.Delimiter, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 19, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + ], + ] as monacoTypes.Token[][], + position: { lineNumber: 1, column: 21 }, +}; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/sortQuery.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/sortQuery.ts new file mode 100644 index 00000000000..ba535cd8479 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/cloudwatch-logs-test-data/sortQuery.ts @@ -0,0 +1,21 @@ +import { monacoTypes } from '@grafana/ui'; + +import { LogsTokenTypes } from '../../language/logs/completion/types'; +import { CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID } from '../../language/logs/definition'; + +export const sortQuery = { + query: `fields @timestamp | sort `, + tokens: [ + [ + { offset: 0, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 6, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 7, type: LogsTokenTypes.Identifier, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 17, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 18, type: LogsTokenTypes.Delimiter, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 19, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 20, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + { offset: 24, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID }, + ], + ] as monacoTypes.Token[][], + position: { lineNumber: 1, column: 26 }, +}; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts index a20518aef5a..3f7e5438d1e 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts @@ -47,6 +47,9 @@ const MonacoMock: Monaco = { [CloudwatchLogsTestData.commentOnlyQuery.query]: CloudwatchLogsTestData.commentOnlyQuery.tokens, [CloudwatchLogsTestData.singleLineFullQuery.query]: CloudwatchLogsTestData.singleLineFullQuery.tokens, [CloudwatchLogsTestData.multiLineFullQuery.query]: CloudwatchLogsTestData.multiLineFullQuery.tokens, + [CloudwatchLogsTestData.filterQuery.query]: CloudwatchLogsTestData.filterQuery.tokens, + [CloudwatchLogsTestData.newCommandQuery.query]: CloudwatchLogsTestData.newCommandQuery.tokens, + [CloudwatchLogsTestData.sortQuery.query]: CloudwatchLogsTestData.sortQuery.tokens, }; return TestData[value]; } diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.test.ts b/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.test.ts new file mode 100644 index 00000000000..cbdfaf73d18 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.test.ts @@ -0,0 +1,80 @@ +import { CustomVariableModel } from '@grafana/data'; +import { Monaco, monacoTypes } from '@grafana/ui'; + +import { setupMockedTemplateService, logGroupNamesVariable } from '../../../__mocks__/CloudWatchDataSource'; +import { emptyQuery, filterQuery, newCommandQuery, sortQuery } from '../../../__mocks__/cloudwatch-logs-test-data'; +import MonacoMock from '../../../__mocks__/monarch/Monaco'; +import TextModel from '../../../__mocks__/monarch/TextModel'; +import { ResourcesAPI } from '../../../resources/ResourcesAPI'; +import cloudWatchLogsLanguageDefinition from '../definition'; +import { LOGS_COMMANDS, LOGS_FUNCTION_OPERATORS, SORT_DIRECTION_KEYWORDS } from '../language'; + +import { LogsCompletionItemProvider } from './CompletionItemProvider'; + +jest.mock('monaco-editor/esm/vs/editor/editor.api', () => ({ + Token: jest.fn((offset, type, language) => ({ offset, type, language })), +})); + +const getSuggestions = async ( + value: string, + position: monacoTypes.IPosition, + variables: CustomVariableModel[] = [] +) => { + const setup = new LogsCompletionItemProvider( + { + getActualRegion: () => 'us-east-2', + } as ResourcesAPI, + setupMockedTemplateService(variables) + ); + const monaco = MonacoMock as Monaco; + const provider = setup.getCompletionProvider(monaco, cloudWatchLogsLanguageDefinition); + const { suggestions } = await provider.provideCompletionItems( + TextModel(value) as monacoTypes.editor.ITextModel, + position + ); + return suggestions; +}; + +describe('LogsCompletionItemProvider', () => { + describe('getSuggestions', () => { + it('returns commands for an empty query', async () => { + const suggestions = await getSuggestions(emptyQuery.query, emptyQuery.position); + const suggestionLabels = suggestions.map((s) => s.label); + expect(suggestionLabels).toEqual(expect.arrayContaining(LOGS_COMMANDS)); + }); + + it('returns commands for a query when a new command is started', async () => { + const suggestions = await getSuggestions(newCommandQuery.query, newCommandQuery.position); + const suggestionLabels = suggestions.map((s) => s.label); + expect(suggestionLabels).toEqual(expect.arrayContaining(LOGS_COMMANDS)); + }); + + it('returns sort order directions for the sort keyword', async () => { + const suggestions = await getSuggestions(sortQuery.query, sortQuery.position); + const suggestionLabels = suggestions.map((s) => s.label); + expect(suggestionLabels).toEqual(expect.arrayContaining(SORT_DIRECTION_KEYWORDS)); + }); + + it('returns function suggestions after a command', async () => { + const suggestions = await getSuggestions(sortQuery.query, sortQuery.position); + const suggestionLabels = suggestions.map((s) => s.label); + expect(suggestionLabels).toEqual(expect.arrayContaining(LOGS_FUNCTION_OPERATORS)); + }); + + it('returns `in []` snippet for the `in` keyword', async () => { + const suggestions = await getSuggestions(filterQuery.query, filterQuery.position); + const suggestionLabels = suggestions.map((s) => s.label); + expect(suggestionLabels).toEqual(expect.arrayContaining(['in []'])); + }); + + it('returns template variables appended to list of suggestions', async () => { + const suggestions = await getSuggestions(newCommandQuery.query, newCommandQuery.position, [ + logGroupNamesVariable, + ]); + const suggestionLabels = suggestions.map((s) => s.label); + const expectedTemplateVariableLabel = `$${logGroupNamesVariable.name}`; + const expectedLabels = [...LOGS_COMMANDS, expectedTemplateVariableLabel]; + expect(suggestionLabels).toEqual(expect.arrayContaining(expectedLabels)); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts new file mode 100644 index 00000000000..4f804a08ea8 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts @@ -0,0 +1,100 @@ +import { getTemplateSrv, type TemplateSrv } from '@grafana/runtime'; +import { Monaco, monacoTypes } from '@grafana/ui'; + +import { type ResourcesAPI } from '../../../resources/ResourcesAPI'; +import { CompletionItemProvider } from '../../monarch/CompletionItemProvider'; +import { LinkedToken } from '../../monarch/LinkedToken'; +import { TRIGGER_SUGGEST } from '../../monarch/commands'; +import { CompletionItem, CompletionItemPriority, StatementPosition, SuggestionKind } from '../../monarch/types'; +import { LOGS_COMMANDS, LOGS_FUNCTION_OPERATORS, SORT_DIRECTION_KEYWORDS } from '../language'; + +import { getStatementPosition } from './statementPosition'; +import { getSuggestionKinds } from './suggestionKinds'; +import { LogsTokenTypes } from './types'; + +export class LogsCompletionItemProvider extends CompletionItemProvider { + constructor(resources: ResourcesAPI, templateSrv: TemplateSrv = getTemplateSrv()) { + super(resources, templateSrv); + this.getStatementPosition = getStatementPosition; + this.getSuggestionKinds = getSuggestionKinds; + this.tokenTypes = LogsTokenTypes; + } + + async getSuggestions( + monaco: Monaco, + currentToken: LinkedToken | null, + suggestionKinds: SuggestionKind[], + statementPosition: StatementPosition, + position: monacoTypes.IPosition + ): Promise { + const suggestions: CompletionItem[] = []; + const invalidRangeToken = currentToken?.isWhiteSpace() || currentToken?.isParenthesis(); + const range = + invalidRangeToken || !currentToken?.range ? monaco.Range.fromPositions(position) : currentToken?.range; + + function toCompletionItem(value: string, rest: Partial = {}) { + const item: monacoTypes.languages.CompletionItem = { + label: value, + insertText: value, + kind: monaco.languages.CompletionItemKind.Field, + range, + sortText: CompletionItemPriority.Medium, + ...rest, + }; + return item; + } + + function addSuggestion(value: string, rest: Partial = {}) { + suggestions.push(toCompletionItem(value, rest)); + } + + for (const kind of suggestionKinds) { + switch (kind) { + case SuggestionKind.Command: + LOGS_COMMANDS.forEach((command) => { + addSuggestion(command, { + insertText: `${command} $0`, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + command: TRIGGER_SUGGEST, + }); + }); + break; + case SuggestionKind.Function: + LOGS_FUNCTION_OPERATORS.forEach((f) => { + addSuggestion(f, { + insertText: `${f}($0)`, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + command: TRIGGER_SUGGEST, + }); + }); + break; + case SuggestionKind.SortOrderDirectionKeyword: + SORT_DIRECTION_KEYWORDS.forEach((direction) => { + addSuggestion(direction, { sortText: CompletionItemPriority.High }); + }); + break; + case SuggestionKind.InKeyword: + addSuggestion('in []', { + insertText: 'in ["$0"]', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + kind: monaco.languages.CompletionItemKind.Snippet, + sortText: CompletionItemPriority.High, + }); + break; + } + + this.templateSrv.getVariables().map((v) => { + const variable = `$${v.name}`; + addSuggestion(variable, { + range, + label: variable, + insertText: variable, + kind: monaco.languages.CompletionItemKind.Variable, + sortText: CompletionItemPriority.Low, + }); + }); + } + + return suggestions; + } +} diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/completion/suggestionKinds.ts b/public/app/plugins/datasource/cloudwatch/language/logs/completion/suggestionKinds.ts new file mode 100644 index 00000000000..f311a5769eb --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language/logs/completion/suggestionKinds.ts @@ -0,0 +1,28 @@ +import { StatementPosition, SuggestionKind } from '../../monarch/types'; + +export function getSuggestionKinds(statementPosition: StatementPosition): SuggestionKind[] { + switch (statementPosition) { + case StatementPosition.NewCommand: + return [SuggestionKind.Command]; + case StatementPosition.AfterSortKeyword: + case StatementPosition.SortArg: + return [SuggestionKind.SortOrderDirectionKeyword, SuggestionKind.Function]; + case StatementPosition.AfterDisplayKeyword: + case StatementPosition.AfterFieldsKeyword: + case StatementPosition.AfterFilterKeyword: + case StatementPosition.AfterStatsKeyword: + case StatementPosition.AfterLimitKeyword: + case StatementPosition.AfterParseKeyword: + case StatementPosition.AfterDedupKeyword: + case StatementPosition.CommandArg: + case StatementPosition.FunctionArg: + case StatementPosition.ArithmeticOperatorArg: + case StatementPosition.BooleanOperatorArg: + case StatementPosition.ComparisonOperatorArg: + return [SuggestionKind.Function]; + case StatementPosition.FilterArg: + return [SuggestionKind.InKeyword, SuggestionKind.Function]; + } + + return []; +} diff --git a/public/app/plugins/datasource/cloudwatch/language/monarch/types.ts b/public/app/plugins/datasource/cloudwatch/language/monarch/types.ts index 4e7a87fcd7e..5d08361a1f6 100644 --- a/public/app/plugins/datasource/cloudwatch/language/monarch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/language/monarch/types.ts @@ -2,6 +2,8 @@ import { monacoTypes } from '@grafana/ui'; import { LanguageDefinition } from './register'; +export type CompletionItem = monacoTypes.languages.CompletionItem; + export interface TokenTypes { Parenthesis: string; Whitespace: string; @@ -113,6 +115,11 @@ export enum SuggestionKind { Operators, Statistic, Period, + + // logs + Command, + Function, + InKeyword, } export enum CompletionItemPriority {