mirror of https://github.com/grafana/grafana
Cloudwatch: Add syntax highlighting and autocomplete for "Metric Search" (#43985)
* Create a "monarch" folder with everything you need to do syntax highlighting and autocompletion. * Use this new monarch folder with existing cloudwatch sql. * Add metric math syntax highlighting and autocomplete. * Make autocomplete "smarter": - search always inserts a string as first arg - strings can't contain predefined functions - operators follow the last closing ) * Add some tests for Metric Math's CompletionItemProvider. * Fixes After CR: - refactor CompletionItemProvider, so that it only requires args that are dynamic or outside of it's responsibility - Update and add tests with mocked monaco - Add more autocomplete suggestions for SEARCH expression functions - sort keywords and give different priority from function to make more visually distinctive. * Change QueryEditor to auto-resize and look more like the one in Prometheus. * Add autocomplete for time periods for the third arg of Search. * More CR fixes: - fix missing break - add unit tests for statementPosition - fix broken time period - sort time periods * Bug fixpull/44748/head
parent
b2b584f611
commit
58a71c7e91
@ -1,38 +0,0 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
import { Monaco } from '../../cloudwatch-sql/completion/types'; |
||||
import { |
||||
multiLineFullQuery, |
||||
singleLineFullQuery, |
||||
singleLineEmptyQuery, |
||||
singleLineTwoQueries, |
||||
multiLineIncompleteQueryWithoutNamespace, |
||||
} from './test-data'; |
||||
|
||||
const TestData = { |
||||
[multiLineFullQuery.query]: multiLineFullQuery.tokens, |
||||
[singleLineFullQuery.query]: singleLineFullQuery.tokens, |
||||
[singleLineEmptyQuery.query]: singleLineEmptyQuery.tokens, |
||||
[singleLineTwoQueries.query]: singleLineTwoQueries.tokens, |
||||
[multiLineIncompleteQueryWithoutNamespace.query]: multiLineIncompleteQueryWithoutNamespace.tokens, |
||||
}; |
||||
|
||||
// Stub for the Monaco instance. Only implements the parts that are used in cloudwatch sql
|
||||
const MonacoMock: Monaco = { |
||||
editor: { |
||||
tokenize: (value: string, languageId: string) => { |
||||
return TestData[value]; |
||||
}, |
||||
}, |
||||
Range: { |
||||
containsPosition: (range: monacoTypes.IRange, position: monacoTypes.IPosition) => { |
||||
return ( |
||||
position.lineNumber >= range.startLineNumber && |
||||
position.lineNumber <= range.endLineNumber && |
||||
position.column >= range.startColumn && |
||||
position.column <= range.endColumn |
||||
); |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export default MonacoMock; |
@ -0,0 +1,28 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export const afterFunctionQuery = { |
||||
query: 'AVG() ', |
||||
tokens: [ |
||||
[ |
||||
{ |
||||
offset: 0, |
||||
type: 'predefined.cloudwatch-MetricMath', |
||||
language: 'cloudwatch-MetricMath', |
||||
}, |
||||
{ |
||||
offset: 3, |
||||
type: 'delimiter.parenthesis.cloudwatch-MetricMath', |
||||
language: 'cloudwatch-MetricMath', |
||||
}, |
||||
{ |
||||
offset: 5, |
||||
type: 'white.cloudwatch-MetricMath', |
||||
language: 'cloudwatch-MetricMath', |
||||
}, |
||||
], |
||||
] as monacoTypes.Token[][], |
||||
position: { |
||||
lineNumber: 1, |
||||
column: 7, |
||||
}, |
||||
}; |
@ -0,0 +1,6 @@ |
||||
export { singleLineEmptyQuery } from './singleLineEmptyQuery'; |
||||
export { afterFunctionQuery } from './afterFunctionQuery'; |
||||
export { secondArgQuery } from './secondArgQuery'; |
||||
export { secondArgAfterSearchQuery } from './secondArgAfterSearchQuery'; |
||||
export { thirdArgAfterSearchQuery } from './thirdArgAfterSearchQuery'; |
||||
export { withinStringQuery } from './withinStringQuery'; |
@ -0,0 +1,19 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export const secondArgAfterSearchQuery = { |
||||
query: "SEARCH('stuff', )", |
||||
tokens: [ |
||||
[ |
||||
{ offset: 0, type: 'predefined.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 6, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 7, type: 'string.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 14, type: 'delimiter.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 15, type: 'white.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 16, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
], |
||||
] as monacoTypes.Token[][], |
||||
position: { |
||||
lineNumber: 1, |
||||
column: 18, |
||||
}, |
||||
}; |
@ -0,0 +1,19 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export const secondArgQuery = { |
||||
query: 'FILL($first, )', |
||||
tokens: [ |
||||
[ |
||||
{ offset: 0, type: 'predefined.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 4, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 5, type: 'variable.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 11, type: 'delimiter.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 12, type: 'white.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 13, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
], |
||||
] as monacoTypes.Token[][], |
||||
position: { |
||||
lineNumber: 1, |
||||
column: 14, |
||||
}, |
||||
}; |
@ -0,0 +1,10 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export const singleLineEmptyQuery = { |
||||
query: '', |
||||
tokens: [] as monacoTypes.Token[][], |
||||
position: { |
||||
lineNumber: 1, |
||||
column: 1, |
||||
}, |
||||
}; |
@ -0,0 +1,22 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export const thirdArgAfterSearchQuery = { |
||||
query: "SEARCH('stuff', 'Average', )", |
||||
tokens: [ |
||||
[ |
||||
{ offset: 0, type: 'predefined.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 6, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 7, type: 'string.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 14, type: 'delimiter.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 15, type: 'white.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 16, type: 'string.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 25, type: 'delimiter.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 26, type: 'white.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 27, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
], |
||||
] as monacoTypes.Token[][], |
||||
position: { |
||||
lineNumber: 1, |
||||
column: 28, |
||||
}, |
||||
}; |
@ -0,0 +1,24 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export const withinStringQuery = { |
||||
query: `SEARCH(' {"Custom-Namespace", "Dimension Name With Spaces"}, `, |
||||
tokens: [ |
||||
[ |
||||
{ offset: 0, type: 'predefined.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 6, type: 'delimiter.parenthesis.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 7, type: 'string.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 8, type: 'white.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 9, type: 'delimiter.curly.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 10, type: 'type.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 28, type: 'source.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 30, type: 'type.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 58, type: 'delimiter.curly.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 59, type: 'delimiter.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
{ offset: 60, type: 'white.cloudwatch-MetricMath', language: 'cloudwatch-MetricMath' }, |
||||
], |
||||
] as monacoTypes.Token[][], |
||||
position: { |
||||
lineNumber: 1, |
||||
column: 62, |
||||
}, |
||||
}; |
@ -0,0 +1,58 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
import { Monaco } from '../../monarch/types'; |
||||
import * as SQLTestData from '../cloudwatch-sql-test-data'; |
||||
import * as MetricMathTestData from '../metric-math-test-data'; |
||||
|
||||
// Stub for the Monaco instance.
|
||||
const MonacoMock: Monaco = { |
||||
editor: { |
||||
tokenize: (value: string, languageId: string) => { |
||||
if (languageId === 'cloudwatch-sql') { |
||||
const TestData = { |
||||
[SQLTestData.multiLineFullQuery.query]: SQLTestData.multiLineFullQuery.tokens, |
||||
[SQLTestData.singleLineFullQuery.query]: SQLTestData.singleLineFullQuery.tokens, |
||||
[SQLTestData.singleLineEmptyQuery.query]: SQLTestData.singleLineEmptyQuery.tokens, |
||||
[SQLTestData.singleLineTwoQueries.query]: SQLTestData.singleLineTwoQueries.tokens, |
||||
[SQLTestData.multiLineIncompleteQueryWithoutNamespace.query]: |
||||
SQLTestData.multiLineIncompleteQueryWithoutNamespace.tokens, |
||||
}; |
||||
return TestData[value]; |
||||
} |
||||
if (languageId === 'cloudwatch-MetricMath') { |
||||
const TestData = { |
||||
[MetricMathTestData.singleLineEmptyQuery.query]: MetricMathTestData.singleLineEmptyQuery.tokens, |
||||
[MetricMathTestData.afterFunctionQuery.query]: MetricMathTestData.afterFunctionQuery.tokens, |
||||
[MetricMathTestData.secondArgQuery.query]: MetricMathTestData.secondArgQuery.tokens, |
||||
[MetricMathTestData.secondArgAfterSearchQuery.query]: MetricMathTestData.secondArgAfterSearchQuery.tokens, |
||||
[MetricMathTestData.withinStringQuery.query]: MetricMathTestData.withinStringQuery.tokens, |
||||
[MetricMathTestData.thirdArgAfterSearchQuery.query]: MetricMathTestData.thirdArgAfterSearchQuery.tokens, |
||||
}; |
||||
return TestData[value]; |
||||
} |
||||
return []; |
||||
}, |
||||
}, |
||||
Range: { |
||||
containsPosition: (range: monacoTypes.IRange, position: monacoTypes.IPosition) => { |
||||
return ( |
||||
position.lineNumber >= range.startLineNumber && |
||||
position.lineNumber <= range.endLineNumber && |
||||
position.column >= range.startColumn && |
||||
position.column <= range.endColumn |
||||
); |
||||
}, |
||||
fromPositions: (start: monacoTypes.IPosition, end?: monacoTypes.IPosition) => { |
||||
return ({} as any) as monacoTypes.Range; |
||||
}, |
||||
}, |
||||
languages: { |
||||
CompletionItemInsertTextRule: { |
||||
InsertAsSnippet: 4, |
||||
}, |
||||
CompletionItemKind: { |
||||
Function: 1, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export default MonacoMock; |
@ -1,58 +0,0 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
import MonacoMock from '../../__mocks__/cloudwatch-sql/Monaco'; |
||||
import TextModel from '../../__mocks__/cloudwatch-sql/TextModel'; |
||||
import { multiLineFullQuery, singleLineFullQuery } from '../../__mocks__/cloudwatch-sql/test-data'; |
||||
import { linkedTokenBuilder } from './linkedTokenBuilder'; |
||||
import { TokenType } from './types'; |
||||
import { DESC, SELECT } from '../language'; |
||||
|
||||
describe('linkedTokenBuilder', () => { |
||||
describe('singleLineFullQuery', () => { |
||||
const testModel = TextModel(singleLineFullQuery.query); |
||||
|
||||
it('should add correct references to next LinkedToken', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 1, column: 0 }; |
||||
const current = linkedTokenBuilder(MonacoMock, testModel as monacoTypes.editor.ITextModel, position); |
||||
expect(current?.is(TokenType.Keyword, SELECT)).toBeTruthy(); |
||||
expect(current?.getNextNonWhiteSpaceToken()?.is(TokenType.Function, 'AVG')).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should add correct references to previous LinkedToken', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 1, column: singleLineFullQuery.query.length }; |
||||
const current = linkedTokenBuilder(MonacoMock, testModel as monacoTypes.editor.ITextModel, position); |
||||
expect(current?.is(TokenType.Number, '10')).toBeTruthy(); |
||||
expect(current?.getPreviousNonWhiteSpaceToken()?.is(TokenType.Keyword, 'LIMIT')).toBeTruthy(); |
||||
expect( |
||||
current?.getPreviousNonWhiteSpaceToken()?.getPreviousNonWhiteSpaceToken()?.is(TokenType.Keyword, DESC) |
||||
).toBeTruthy(); |
||||
}); |
||||
}); |
||||
|
||||
describe('multiLineFullQuery', () => { |
||||
const testModel = TextModel(multiLineFullQuery.query); |
||||
|
||||
it('should add LinkedToken with whitespace in case empty lines', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 3, column: 0 }; |
||||
const current = linkedTokenBuilder(MonacoMock, testModel as monacoTypes.editor.ITextModel, position); |
||||
expect(current).not.toBeNull(); |
||||
expect(current?.isWhiteSpace()).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should add correct references to next LinkedToken', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 1, column: 0 }; |
||||
const current = linkedTokenBuilder(MonacoMock, testModel as monacoTypes.editor.ITextModel, position); |
||||
expect(current?.is(TokenType.Keyword, SELECT)).toBeTruthy(); |
||||
expect(current?.getNextNonWhiteSpaceToken()?.is(TokenType.Function, 'AVG')).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should add correct references to previous LinkedToken even when references spans over multiple lines', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 6, column: 7 }; |
||||
const current = linkedTokenBuilder(MonacoMock, testModel as monacoTypes.editor.ITextModel, position); |
||||
expect(current?.is(TokenType.Number, '10')).toBeTruthy(); |
||||
expect(current?.getPreviousNonWhiteSpaceToken()?.is(TokenType.Keyword, 'LIMIT')).toBeTruthy(); |
||||
expect( |
||||
current?.getPreviousNonWhiteSpaceToken()?.getPreviousNonWhiteSpaceToken()?.is(TokenType.Keyword, DESC) |
||||
).toBeTruthy(); |
||||
}); |
||||
}); |
||||
}); |
@ -1,76 +1,15 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export enum TokenType { |
||||
Parenthesis = 'delimiter.parenthesis.sql', |
||||
Whitespace = 'white.sql', |
||||
Keyword = 'keyword.sql', |
||||
Delimiter = 'delimiter.sql', |
||||
Operator = 'operator.sql', |
||||
Identifier = 'identifier.sql', |
||||
Type = 'type.sql', |
||||
Function = 'predefined.sql', |
||||
Number = 'number.sql', |
||||
String = 'string.sql', |
||||
Variable = 'variable.sql', |
||||
} |
||||
|
||||
export enum StatementPosition { |
||||
Unknown, |
||||
SelectKeyword, |
||||
AfterSelectKeyword, |
||||
AfterSelectFuncFirstArgument, |
||||
AfterFromKeyword, |
||||
SchemaFuncFirstArgument, |
||||
SchemaFuncExtraArgument, |
||||
FromKeyword, |
||||
AfterFrom, |
||||
WhereKey, |
||||
WhereComparisonOperator, |
||||
WhereValue, |
||||
AfterWhereValue, |
||||
AfterGroupByKeywords, |
||||
AfterGroupBy, |
||||
AfterOrderByKeywords, |
||||
AfterOrderByFunction, |
||||
AfterOrderByDirection, |
||||
} |
||||
|
||||
export enum SuggestionKind { |
||||
SelectKeyword, |
||||
FunctionsWithArguments, |
||||
Metrics, |
||||
FromKeyword, |
||||
SchemaKeyword, |
||||
Namespaces, |
||||
LabelKeys, |
||||
WhereKeyword, |
||||
GroupByKeywords, |
||||
OrderByKeywords, |
||||
FunctionsWithoutArguments, |
||||
LimitKeyword, |
||||
SortOrderDirectionKeyword, |
||||
ComparisonOperators, |
||||
LabelValues, |
||||
LogicalOperators, |
||||
} |
||||
|
||||
export enum CompletionItemPriority { |
||||
High = 'a', |
||||
MediumHigh = 'd', |
||||
Medium = 'g', |
||||
MediumLow = 'k', |
||||
Low = 'q', |
||||
} |
||||
|
||||
export interface Editor { |
||||
tokenize: (value: string, languageId: string) => monacoTypes.Token[][]; |
||||
} |
||||
|
||||
export interface Range { |
||||
containsPosition: (range: monacoTypes.IRange, position: monacoTypes.IPosition) => boolean; |
||||
} |
||||
|
||||
export interface Monaco { |
||||
editor: Editor; |
||||
Range: Range; |
||||
} |
||||
import { TokenTypes } from '../../monarch/types'; |
||||
|
||||
export const SQLTokenTypes: TokenTypes = { |
||||
Parenthesis: 'delimiter.parenthesis.sql', |
||||
Whitespace: 'white.sql', |
||||
Keyword: 'keyword.sql', |
||||
Delimiter: 'delimiter.sql', |
||||
Operator: 'operator.sql', |
||||
Identifier: 'identifier.sql', |
||||
Type: 'type.sql', |
||||
Function: 'predefined.sql', |
||||
Number: 'number.sql', |
||||
String: 'string.sql', |
||||
Variable: 'variable.sql', |
||||
}; |
||||
|
@ -1,7 +1,10 @@ |
||||
export default { |
||||
import { LanguageDefinition } from '../monarch/register'; |
||||
|
||||
const cloudWatchSqlLanguageDefinition: LanguageDefinition = { |
||||
id: 'cloudwatch-sql', |
||||
extensions: ['.cloudwatchSql'], |
||||
aliases: ['CloudWatch', 'cloudwatch', 'CloudWatchSQL'], |
||||
mimetypes: [], |
||||
loader: () => import('./language'), |
||||
}; |
||||
export default cloudWatchSqlLanguageDefinition; |
||||
|
@ -1,19 +0,0 @@ |
||||
import { Monaco } from '@grafana/ui'; |
||||
import { CompletionItemProvider } from './completion/CompletionItemProvider'; |
||||
import language from './definition'; |
||||
|
||||
export const registerLanguage = (monaco: Monaco, sqlCompletionItemProvider: CompletionItemProvider) => { |
||||
const { id, loader } = language; |
||||
|
||||
const languages = monaco.languages.getLanguages(); |
||||
if (languages.find((l) => l.id === id)) { |
||||
return; |
||||
} |
||||
|
||||
monaco.languages.register({ id }); |
||||
loader().then((monarch) => { |
||||
monaco.languages.setMonarchTokensProvider(id, monarch.language); |
||||
monaco.languages.setLanguageConfiguration(id, monarch.conf); |
||||
monaco.languages.registerCompletionItemProvider(id, sqlCompletionItemProvider.getCompletionProvider(monaco)); |
||||
}); |
||||
}; |
@ -1,28 +1,84 @@ |
||||
import React from 'react'; |
||||
import { Input } from '@grafana/ui'; |
||||
import React, { useCallback, useRef } from 'react'; |
||||
import { CodeEditor, Monaco } from '@grafana/ui'; |
||||
import language from '../metric-math/definition'; |
||||
import { registerLanguage } from '../monarch/register'; |
||||
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; |
||||
import { TRIGGER_SUGGEST } from '../monarch/commands'; |
||||
import { CloudWatchDatasource } from '../datasource'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: string) => void; |
||||
onRunQuery: () => void; |
||||
expression: string; |
||||
datasource: CloudWatchDatasource; |
||||
} |
||||
|
||||
export function MathExpressionQueryField({ expression: query, onChange, onRunQuery }: React.PropsWithChildren<Props>) { |
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
||||
if (event.key === 'Enter' && (event.shiftKey || event.ctrlKey)) { |
||||
event.preventDefault(); |
||||
onRunQuery(); |
||||
} |
||||
}; |
||||
export function MathExpressionQueryField({ |
||||
expression: query, |
||||
onChange, |
||||
onRunQuery, |
||||
datasource, |
||||
}: React.PropsWithChildren<Props>) { |
||||
const containerRef = useRef<HTMLDivElement>(null); |
||||
const onEditorMount = useCallback( |
||||
(editor: monacoType.editor.IStandaloneCodeEditor, monaco: Monaco) => { |
||||
editor.onDidFocusEditorText(() => editor.trigger(TRIGGER_SUGGEST.id, TRIGGER_SUGGEST.id, {})); |
||||
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { |
||||
const text = editor.getValue(); |
||||
onChange(text); |
||||
onRunQuery(); |
||||
}); |
||||
|
||||
// auto resizes the editor to be the height of the content it holds
|
||||
// this code comes from the Prometheus query editor.
|
||||
// We may wish to consider abstracting it into the grafana/ui repo in the future
|
||||
const updateElementHeight = () => { |
||||
const containerDiv = containerRef.current; |
||||
if (containerDiv !== null && editor.getContentHeight() < 200) { |
||||
const pixelHeight = editor.getContentHeight(); |
||||
containerDiv.style.height = `${pixelHeight}px`; |
||||
containerDiv.style.width = '100%'; |
||||
const pixelWidth = containerDiv.clientWidth; |
||||
editor.layout({ width: pixelWidth, height: pixelHeight }); |
||||
} |
||||
}; |
||||
|
||||
editor.onDidContentSizeChange(updateElementHeight); |
||||
updateElementHeight(); |
||||
}, |
||||
[onChange, onRunQuery] |
||||
); |
||||
|
||||
return ( |
||||
<Input |
||||
name="Query" |
||||
value={query} |
||||
placeholder="Enter a math expression" |
||||
onBlur={onRunQuery} |
||||
onChange={(e) => onChange(e.currentTarget.value)} |
||||
onKeyDown={onKeyDown} |
||||
/> |
||||
<div ref={containerRef}> |
||||
<CodeEditor |
||||
monacoOptions={{ |
||||
// without this setting, the auto-resize functionality causes an infinite loop, don't remove it!
|
||||
scrollBeyondLastLine: false, |
||||
|
||||
// These additional options are style focused and are a subset of those in the query editor in Prometheus
|
||||
fontSize: 14, |
||||
lineNumbers: 'off', |
||||
renderLineHighlight: 'none', |
||||
scrollbar: { |
||||
vertical: 'hidden', |
||||
horizontal: 'hidden', |
||||
}, |
||||
suggestFontSize: 12, |
||||
wordWrap: 'on', |
||||
}} |
||||
language={language.id} |
||||
value={query} |
||||
onBlur={(value) => { |
||||
if (value !== query) { |
||||
onChange(value); |
||||
} |
||||
}} |
||||
onBeforeEditorMount={(monaco: Monaco) => |
||||
registerLanguage(monaco, language, datasource.metricMathCompletionItemProvider) |
||||
} |
||||
onEditorDidMount={onEditorMount} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
@ -0,0 +1,72 @@ |
||||
import MonacoMock from '../../__mocks__/monarch/Monaco'; |
||||
import TextModel from '../../__mocks__/monarch/TextModel'; |
||||
import { MetricMathCompletionItemProvider } from './CompletionItemProvider'; |
||||
import { getTemplateSrv } from '@grafana/runtime'; |
||||
import { CloudWatchDatasource } from '../../datasource'; |
||||
import cloudWatchMetricMathLanguageDefinition from '../definition'; |
||||
import { Monaco, monacoTypes } from '@grafana/ui'; |
||||
import { IPosition } from 'monaco-editor'; |
||||
import { |
||||
METRIC_MATH_FNS, |
||||
METRIC_MATH_KEYWORDS, |
||||
METRIC_MATH_OPERATORS, |
||||
METRIC_MATH_PERIODS, |
||||
METRIC_MATH_STATISTIC_KEYWORD_STRINGS, |
||||
} from '../language'; |
||||
import * as MetricMathTestData from '../../__mocks__/metric-math-test-data'; |
||||
|
||||
const getSuggestions = async (value: string, position: IPosition) => { |
||||
const setup = new MetricMathCompletionItemProvider( |
||||
({ |
||||
getVariables: () => [], |
||||
getActualRegion: () => 'us-east-2', |
||||
} as any) as CloudWatchDatasource, |
||||
getTemplateSrv() |
||||
); |
||||
const monaco = MonacoMock as Monaco; |
||||
const provider = setup.getCompletionProvider(monaco, cloudWatchMetricMathLanguageDefinition); |
||||
const { suggestions } = await provider.provideCompletionItems( |
||||
TextModel(value) as monacoTypes.editor.ITextModel, |
||||
position |
||||
); |
||||
return suggestions; |
||||
}; |
||||
describe('MetricMath: CompletionItemProvider', () => { |
||||
describe('getSuggestions', () => { |
||||
it('returns a suggestion for every metric math function when the input field is empty', async () => { |
||||
const { query, position } = MetricMathTestData.singleLineEmptyQuery; |
||||
const suggestions = await getSuggestions(query, position); |
||||
expect(suggestions.length).toEqual(METRIC_MATH_FNS.length); |
||||
}); |
||||
|
||||
it('returns a suggestion for every metric math operator when at the end of a function', async () => { |
||||
const { query, position } = MetricMathTestData.afterFunctionQuery; |
||||
const suggestions = await getSuggestions(query, position); |
||||
expect(suggestions.length).toEqual(METRIC_MATH_OPERATORS.length); |
||||
}); |
||||
|
||||
it('returns a suggestion for every metric math function and keyword if at the start of the second argument of a function', async () => { |
||||
const { query, position } = MetricMathTestData.secondArgQuery; |
||||
const suggestions = await getSuggestions(query, position); |
||||
expect(suggestions.length).toEqual(METRIC_MATH_FNS.length + METRIC_MATH_KEYWORDS.length); |
||||
}); |
||||
|
||||
it('does not have any particular suggestions if within a string', async () => { |
||||
const { query, position } = MetricMathTestData.withinStringQuery; |
||||
const suggestions = await getSuggestions(query, position); |
||||
expect(suggestions.length).toEqual(0); |
||||
}); |
||||
|
||||
it('returns a suggestion for every statistic if the second arg of a search function', async () => { |
||||
const { query, position } = MetricMathTestData.secondArgAfterSearchQuery; |
||||
const suggestions = await getSuggestions(query, position); |
||||
expect(suggestions.length).toEqual(METRIC_MATH_STATISTIC_KEYWORD_STRINGS.length); |
||||
}); |
||||
|
||||
it('returns a suggestion for every period if the third arg of a search function', async () => { |
||||
const { query, position } = MetricMathTestData.thirdArgAfterSearchQuery; |
||||
const suggestions = await getSuggestions(query, position); |
||||
expect(suggestions.length).toEqual(METRIC_MATH_PERIODS.length); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,123 @@ |
||||
import type { Monaco, monacoTypes } from '@grafana/ui'; |
||||
import { TRIGGER_SUGGEST } from '../../monarch/commands'; |
||||
import { SuggestionKind, CompletionItemPriority, StatementPosition } from '../../monarch/types'; |
||||
import { LinkedToken } from '../../monarch/LinkedToken'; |
||||
import { |
||||
METRIC_MATH_FNS, |
||||
METRIC_MATH_KEYWORDS, |
||||
METRIC_MATH_OPERATORS, |
||||
METRIC_MATH_PERIODS, |
||||
METRIC_MATH_STATISTIC_KEYWORD_STRINGS, |
||||
} from '../language'; |
||||
import { CompletionItemProvider } from '../../monarch/CompletionItemProvider'; |
||||
import { MetricMathTokenTypes } from './types'; |
||||
import { CloudWatchDatasource } from '../../datasource'; |
||||
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; |
||||
import { getStatementPosition } from './statementPosition'; |
||||
import { getSuggestionKinds } from './suggestionKind'; |
||||
|
||||
type CompletionItem = monacoTypes.languages.CompletionItem; |
||||
|
||||
export class MetricMathCompletionItemProvider extends CompletionItemProvider { |
||||
constructor(datasource: CloudWatchDatasource, templateSrv: TemplateSrv = getTemplateSrv()) { |
||||
super(datasource, templateSrv); |
||||
this.getStatementPosition = getStatementPosition; |
||||
this.getSuggestionKinds = getSuggestionKinds; |
||||
this.tokenTypes = MetricMathTokenTypes; |
||||
} |
||||
|
||||
async getSuggestions( |
||||
monaco: Monaco, |
||||
currentToken: LinkedToken | null, |
||||
suggestionKinds: SuggestionKind[], |
||||
statementPosition: StatementPosition, |
||||
position: monacoTypes.IPosition |
||||
): Promise<CompletionItem[]> { |
||||
let suggestions: CompletionItem[] = []; |
||||
const invalidRangeToken = currentToken?.isWhiteSpace() || currentToken?.isParenthesis(); |
||||
const range = |
||||
invalidRangeToken || !currentToken?.range ? monaco.Range.fromPositions(position) : currentToken?.range; |
||||
|
||||
const toCompletionItem = (value: string, rest: Partial<CompletionItem> = {}) => { |
||||
const item: CompletionItem = { |
||||
label: value, |
||||
insertText: value, |
||||
kind: monaco.languages.CompletionItemKind.Field, |
||||
range, |
||||
sortText: CompletionItemPriority.Medium, |
||||
...rest, |
||||
}; |
||||
return item; |
||||
}; |
||||
|
||||
function addSuggestion(value: string, rest: Partial<CompletionItem> = {}) { |
||||
suggestions = [...suggestions, toCompletionItem(value, rest)]; |
||||
} |
||||
|
||||
for (const suggestion of suggestionKinds) { |
||||
switch (suggestion) { |
||||
case SuggestionKind.FunctionsWithArguments: |
||||
METRIC_MATH_FNS.map((f) => |
||||
addSuggestion(f, { |
||||
insertText: f === 'SEARCH' ? `${f}('$0')` : `${f}($0)`, |
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, |
||||
command: TRIGGER_SUGGEST, |
||||
kind: monaco.languages.CompletionItemKind.Function, |
||||
}) |
||||
); |
||||
break; |
||||
|
||||
case SuggestionKind.KeywordArguments: |
||||
METRIC_MATH_KEYWORDS.map((s) => |
||||
addSuggestion(s, { |
||||
insertText: s, |
||||
command: TRIGGER_SUGGEST, |
||||
kind: monaco.languages.CompletionItemKind.Keyword, |
||||
sortText: CompletionItemPriority.MediumHigh, |
||||
}) |
||||
); |
||||
break; |
||||
|
||||
case SuggestionKind.Statistic: |
||||
METRIC_MATH_STATISTIC_KEYWORD_STRINGS.map((s) => |
||||
addSuggestion(s, { |
||||
insertText: `'${s}', `, |
||||
command: TRIGGER_SUGGEST, |
||||
}) |
||||
); |
||||
break; |
||||
|
||||
case SuggestionKind.Operators: |
||||
METRIC_MATH_OPERATORS.map((s) => |
||||
addSuggestion(s, { |
||||
insertText: `${s} `, |
||||
command: TRIGGER_SUGGEST, |
||||
}) |
||||
); |
||||
break; |
||||
|
||||
case SuggestionKind.Period: |
||||
METRIC_MATH_PERIODS.map((s, idx) => |
||||
addSuggestion(s.toString(), { |
||||
kind: monaco.languages.CompletionItemKind.Value, |
||||
sortText: String.fromCharCode(97 + idx), // converts index 0, 1 to "a", "b", etc needed to show the time periods in numerical order
|
||||
}) |
||||
); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
// always suggest template variables
|
||||
this.templateVariables.map((v) => { |
||||
addSuggestion(v, { |
||||
range, |
||||
label: v, |
||||
insertText: v, |
||||
kind: monaco.languages.CompletionItemKind.Variable, |
||||
sortText: CompletionItemPriority.Low, |
||||
}); |
||||
}); |
||||
|
||||
return suggestions; |
||||
} |
||||
} |
@ -0,0 +1,69 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
import MonacoMock from '../../__mocks__/monarch/Monaco'; |
||||
import TextModel from '../../__mocks__/monarch/TextModel'; |
||||
import * as MetricMathTestQueries from '../../__mocks__/metric-math-test-data'; |
||||
import { linkedTokenBuilder } from '../../monarch/linkedTokenBuilder'; |
||||
import { StatementPosition } from '../../monarch/types'; |
||||
import { getStatementPosition } from './statementPosition'; |
||||
import cloudWatchSqlLanguageDefinition from '../definition'; |
||||
import { MetricMathTokenTypes } from './types'; |
||||
|
||||
describe('statementPosition', () => { |
||||
function createToken(query: string, position: monacoTypes.IPosition) { |
||||
const testModel = TextModel(query); |
||||
return linkedTokenBuilder( |
||||
MonacoMock, |
||||
cloudWatchSqlLanguageDefinition, |
||||
testModel as monacoTypes.editor.ITextModel, |
||||
position, |
||||
MetricMathTokenTypes |
||||
); |
||||
} |
||||
|
||||
it('returns PredefinedFunction when at the beginning of an empty query', () => { |
||||
const token = createToken( |
||||
MetricMathTestQueries.singleLineEmptyQuery.query, |
||||
MetricMathTestQueries.singleLineEmptyQuery.position |
||||
); |
||||
expect(getStatementPosition(token)).toEqual(StatementPosition.PredefinedFunction); |
||||
}); |
||||
|
||||
it('returns PredefinedFuncSecondArg when in the second arg of a predefined function', () => { |
||||
const token = createToken( |
||||
MetricMathTestQueries.secondArgQuery.query, |
||||
MetricMathTestQueries.secondArgQuery.position |
||||
); |
||||
expect(getStatementPosition(token)).toEqual(StatementPosition.PredefinedFuncSecondArg); |
||||
}); |
||||
|
||||
it('returns SearchFuncSecondArg when in the second arg of a Search function', () => { |
||||
const token = createToken( |
||||
MetricMathTestQueries.secondArgAfterSearchQuery.query, |
||||
MetricMathTestQueries.secondArgAfterSearchQuery.position |
||||
); |
||||
expect(getStatementPosition(token)).toEqual(StatementPosition.SearchFuncSecondArg); |
||||
}); |
||||
|
||||
it('returns SearchFuncThirdArg when in the third arg of a Search function', () => { |
||||
const token = createToken( |
||||
MetricMathTestQueries.thirdArgAfterSearchQuery.query, |
||||
MetricMathTestQueries.thirdArgAfterSearchQuery.position |
||||
); |
||||
expect(getStatementPosition(token)).toEqual(StatementPosition.SearchFuncThirdArg); |
||||
}); |
||||
it('returns AfterFunction when after a function', () => { |
||||
const token = createToken( |
||||
MetricMathTestQueries.afterFunctionQuery.query, |
||||
MetricMathTestQueries.afterFunctionQuery.position |
||||
); |
||||
expect(getStatementPosition(token)).toEqual(StatementPosition.AfterFunction); |
||||
}); |
||||
|
||||
it('returns WithinString when within a string', () => { |
||||
const token = createToken( |
||||
MetricMathTestQueries.withinStringQuery.query, |
||||
MetricMathTestQueries.withinStringQuery.position |
||||
); |
||||
expect(getStatementPosition(token)).toEqual(StatementPosition.WithinString); |
||||
}); |
||||
}); |
@ -0,0 +1,54 @@ |
||||
import { LinkedToken } from '../../monarch/LinkedToken'; |
||||
import { StatementPosition } from '../../monarch/types'; |
||||
import { MetricMathTokenTypes } from './types'; |
||||
|
||||
export function getStatementPosition(currentToken: LinkedToken | null): StatementPosition { |
||||
const previousNonWhiteSpace = currentToken?.getPreviousNonWhiteSpaceToken(); |
||||
|
||||
if (currentToken && currentToken.isString()) { |
||||
return StatementPosition.WithinString; |
||||
} |
||||
|
||||
if (currentToken && previousNonWhiteSpace) { |
||||
const currentFunction = currentToken.getPreviousOfType(MetricMathTokenTypes.Function); |
||||
const isAfterComma = previousNonWhiteSpace.is(MetricMathTokenTypes.Delimiter, ','); |
||||
const isWithinSearch = currentFunction && currentFunction.value === 'SEARCH'; |
||||
const allTokensAfterStartOfSearch = |
||||
currentToken.getPreviousUntil(MetricMathTokenTypes.Function, [], 'SEARCH') || []; |
||||
|
||||
if (isWithinSearch) { |
||||
// if there's only one ' then we're still within the first arg
|
||||
if (allTokensAfterStartOfSearch.filter(({ value }) => value === "'").length === 1) { |
||||
return StatementPosition.WithinString; |
||||
} |
||||
|
||||
// if there was a , before the last , and it happened after the start of SEARCH
|
||||
const lastComma = previousNonWhiteSpace.getPreviousOfType(MetricMathTokenTypes.Delimiter, ','); |
||||
if (lastComma) { |
||||
const lastCommaIsAfterSearch = |
||||
lastComma.range.startColumn > currentFunction.range.startColumn && |
||||
lastComma.range.startLineNumber >= currentFunction.range.startLineNumber; |
||||
if (lastCommaIsAfterSearch) { |
||||
return StatementPosition.SearchFuncThirdArg; |
||||
} |
||||
} |
||||
|
||||
// otherwise assume it's the second arg
|
||||
return StatementPosition.SearchFuncSecondArg; |
||||
} |
||||
|
||||
if (!isWithinSearch && isAfterComma) { |
||||
return StatementPosition.PredefinedFuncSecondArg; |
||||
} |
||||
} |
||||
|
||||
if (previousNonWhiteSpace?.endsWith(')')) { |
||||
return StatementPosition.AfterFunction; |
||||
} |
||||
|
||||
if (!currentToken || !currentToken.isString()) { |
||||
return StatementPosition.PredefinedFunction; |
||||
} |
||||
|
||||
return StatementPosition.Unknown; |
||||
} |
@ -0,0 +1,18 @@ |
||||
import { StatementPosition, SuggestionKind } from '../../monarch/types'; |
||||
|
||||
export function getSuggestionKinds(statementPosition: StatementPosition): SuggestionKind[] { |
||||
switch (statementPosition) { |
||||
case StatementPosition.PredefinedFunction: |
||||
return [SuggestionKind.FunctionsWithArguments]; |
||||
case StatementPosition.PredefinedFuncSecondArg: |
||||
return [SuggestionKind.FunctionsWithArguments, SuggestionKind.KeywordArguments]; |
||||
case StatementPosition.AfterFunction: |
||||
return [SuggestionKind.Operators]; |
||||
case StatementPosition.SearchFuncSecondArg: |
||||
return [SuggestionKind.Statistic]; |
||||
case StatementPosition.SearchFuncThirdArg: |
||||
return [SuggestionKind.Period]; |
||||
} |
||||
|
||||
return []; |
||||
} |
@ -0,0 +1,15 @@ |
||||
import { TokenTypes } from '../../monarch/types'; |
||||
|
||||
export const MetricMathTokenTypes: TokenTypes = { |
||||
Parenthesis: 'delimiter.parenthesis.cloudwatch-MetricMath', |
||||
Whitespace: 'white.cloudwatch-MetricMath', |
||||
Keyword: 'keyword.cloudwatch-MetricMath', |
||||
Delimiter: 'delimiter.cloudwatch-MetricMath', |
||||
Operator: 'operator.cloudwatch-MetricMath', |
||||
Identifier: 'identifier.cloudwatch-MetricMath', |
||||
Type: 'type.cloudwatch-MetricMath', |
||||
Function: 'predefined.cloudwatch-MetricMath', |
||||
Number: 'number.cloudwatch-MetricMath', |
||||
String: 'string.cloudwatch-MetricMath', |
||||
Variable: 'variable.cloudwatch-MetricMath', |
||||
}; |
@ -0,0 +1,10 @@ |
||||
import { LanguageDefinition } from '../monarch/register'; |
||||
|
||||
const cloudWatchMetricMathLanguageDefinition: LanguageDefinition = { |
||||
id: 'cloudwatch-MetricMath', |
||||
extensions: [], |
||||
aliases: [], |
||||
mimetypes: [], |
||||
loader: () => import('./language'), |
||||
}; |
||||
export default cloudWatchMetricMathLanguageDefinition; |
@ -0,0 +1,156 @@ |
||||
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; |
||||
|
||||
// Metric Math: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html
|
||||
export const METRIC_MATH_FNS = [ |
||||
'ABS', |
||||
'ANOMALY_DETECTION_BAND', |
||||
'AVG', |
||||
'CEIL', |
||||
'DATAPOINT_COUNT', |
||||
'DIFF', |
||||
'DIFF_TIME', |
||||
'FILL', |
||||
'FIRST', |
||||
'LAST', |
||||
'FLOOR', |
||||
'IF', |
||||
'INSIGHT_RULE_METRIC', |
||||
'LOG', |
||||
'LOG10', |
||||
'MAX', |
||||
'METRIC_COUNT', |
||||
'METRICS', |
||||
'MIN', |
||||
'MINUTE', |
||||
'HOUR', |
||||
'DAY', |
||||
'DATE', |
||||
'MONTH', |
||||
'YEAR', |
||||
'EPOCH', |
||||
'PERIOD', |
||||
'RATE', |
||||
'REMOVE_EMPTY', |
||||
'RUNNING_SUM', |
||||
'SEARCH', |
||||
'SERVICE_QUOTA', |
||||
'SLICE', |
||||
'SORT', |
||||
'STDDEV', |
||||
'SUM', |
||||
'TIME_SERIES', |
||||
]; |
||||
|
||||
export const METRIC_MATH_STATISTIC_KEYWORD_STRINGS = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount']; // second arguments to SEARCH function
|
||||
|
||||
export const METRIC_MATH_KEYWORDS = ['REPEAT', 'LINEAR', 'ASC', 'DSC']; // standalone magic arguments to functions
|
||||
|
||||
export const METRIC_MATH_OPERATORS = [ |
||||
'+', |
||||
'-', |
||||
'*', |
||||
'/', |
||||
'^', |
||||
'==', |
||||
'!=', |
||||
'<=', |
||||
'>=', |
||||
'<', |
||||
'>', |
||||
'AND', |
||||
'&&', |
||||
'OR', |
||||
'||', |
||||
]; |
||||
|
||||
export const METRIC_MATH_PERIODS = [10, 60, 300, 900, 3000, 21600, 86400]; |
||||
|
||||
export const language: monacoType.languages.IMonarchLanguage = { |
||||
id: 'metricMath', |
||||
ignoreCase: false, |
||||
brackets: [ |
||||
{ open: '[', close: ']', token: 'delimiter.square' }, |
||||
{ open: '(', close: ')', token: 'delimiter.parenthesis' }, |
||||
{ open: '{', close: '}', token: 'delimiter.curly' }, |
||||
], |
||||
tokenizer: { |
||||
root: [{ include: '@nonNestableStates' }, { include: '@strings' }], |
||||
nonNestableStates: [ |
||||
{ include: '@variables' }, |
||||
{ include: '@whitespace' }, |
||||
{ include: '@numbers' }, |
||||
{ include: '@assignment' }, |
||||
{ include: '@keywords' }, |
||||
{ include: '@operators' }, |
||||
{ include: '@builtInFunctions' }, |
||||
[/[;,.]/, 'delimiter'], |
||||
[/[(){}\[\]]/, '@brackets'], // [], (), {} are all brackets
|
||||
], |
||||
keywords: [[METRIC_MATH_KEYWORDS.map(escapeRegExp).join('|'), 'keyword']], |
||||
operators: [[METRIC_MATH_OPERATORS.map(escapeRegExp).join('|'), 'operator']], |
||||
builtInFunctions: [[METRIC_MATH_FNS.map(escapeRegExp).join('|'), 'predefined']], |
||||
variables: [ |
||||
[/\$[a-zA-Z0-9-_]+/, 'variable'], // $ followed by any letter/number we assume could be grafana template variable
|
||||
], |
||||
whitespace: [[/\s+/, 'white']], |
||||
assignment: [[/=/, 'tag']], |
||||
numbers: [ |
||||
[/0[xX][0-9a-fA-F]*/, 'number'], |
||||
[/[$][+-]*\d*(\.\d*)?/, 'number'], |
||||
[/((\d+(\.\d*)?)|(\.\d+))([eE][\-+]?\d+)?/, 'number'], |
||||
], |
||||
// states that start other states (aka nested states):
|
||||
strings: [ |
||||
[/'/, { token: 'string', next: '@string' }], |
||||
[/"/, { token: 'type', next: '@string_double' }], |
||||
], |
||||
string: [ |
||||
[/{/, { token: 'delimiter.curly', next: '@nestedCurly' }], // escape out of string and into nestedCurly
|
||||
[/\(/, { token: 'delimiter.parenthesis', next: '@nestedParens' }], // escape out of string and into nestedCurly
|
||||
[/"/, { token: 'type', next: '@string_double' }], // jump into double string
|
||||
[/'/, { token: 'string', next: '@pop' }], // stop being a string
|
||||
{ include: '@nonNestableStates' }, |
||||
[/[^']/, 'string'], // anything that is not a quote, is marked as string
|
||||
], |
||||
string_double: [ |
||||
[/[^"]/, 'type'], // mark anything not a quote as a "type" (different type of string for visual difference)
|
||||
[/"/, { token: 'type', next: '@pop' }], // mark also as a type and stop being in the double string state
|
||||
], |
||||
nestedCurly: [ |
||||
[/}/, { token: 'delimiter.curly', next: '@pop' }], // escape out of string and into braces
|
||||
[/'/, { token: 'string', next: '@string' }], // go to string if see start of string
|
||||
[/"/, { token: 'type', next: '@string_double' }], // go to string_double if see start of double string
|
||||
], |
||||
nestedParens: [ |
||||
[/\)/, { token: 'delimiter.parenthesis', next: '@pop' }], // escape out of string and into braces
|
||||
[/'/, { token: 'string', next: '@string' }], // go to string if see start of string
|
||||
[/"/, { token: 'type', next: '@string_double' }], // go to string_double if see start of double string
|
||||
], |
||||
}, |
||||
}; |
||||
|
||||
export const conf: monacoType.languages.LanguageConfiguration = { |
||||
brackets: [ |
||||
['{', '}'], |
||||
['[', ']'], |
||||
['(', ')'], |
||||
], |
||||
autoClosingPairs: [ |
||||
{ open: '{', close: '}' }, |
||||
{ open: '[', close: ']' }, |
||||
{ open: '(', close: ')' }, |
||||
{ open: '"', close: '"' }, |
||||
{ open: "'", close: "'" }, |
||||
], |
||||
surroundingPairs: [ |
||||
{ open: '{', close: '}' }, |
||||
{ open: '[', close: ']' }, |
||||
{ open: '(', close: ')' }, |
||||
{ open: '"', close: '"' }, |
||||
{ open: "'", close: "'" }, |
||||
], |
||||
}; |
||||
|
||||
function escapeRegExp(string: string) { |
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
} |
@ -0,0 +1,91 @@ |
||||
import type { Monaco, monacoTypes } from '@grafana/ui'; |
||||
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; |
||||
import { CloudWatchDatasource } from '../datasource'; |
||||
import { linkedTokenBuilder } from './linkedTokenBuilder'; |
||||
|
||||
import { LinkedToken } from './LinkedToken'; |
||||
import { LanguageDefinition } from './register'; |
||||
import { StatementPosition, SuggestionKind, TokenTypes } from './types'; |
||||
|
||||
type CompletionItem = monacoTypes.languages.CompletionItem; |
||||
|
||||
/* |
||||
CompletionItemProvider is an extendable class which needs to implement : |
||||
- tokenTypes |
||||
- getStatementPosition |
||||
- getSuggestionKinds |
||||
- getSuggestions |
||||
*/ |
||||
export class CompletionItemProvider { |
||||
templateVariables: string[]; |
||||
datasource: CloudWatchDatasource; |
||||
templateSrv: TemplateSrv; |
||||
tokenTypes: TokenTypes; |
||||
|
||||
constructor(datasource: CloudWatchDatasource, templateSrv: TemplateSrv = getTemplateSrv()) { |
||||
this.datasource = datasource; |
||||
this.templateSrv = templateSrv; |
||||
this.templateVariables = this.datasource.getVariables(); |
||||
this.templateSrv = templateSrv; |
||||
|
||||
// implement with more specific tokens when extending this class
|
||||
this.tokenTypes = { |
||||
Parenthesis: 'delimiter.parenthesis', |
||||
Whitespace: 'white', |
||||
Keyword: 'keyword', |
||||
Delimiter: 'delimiter', |
||||
Operator: 'operator', |
||||
Identifier: 'identifier', |
||||
Type: 'type', |
||||
Function: 'predefined', |
||||
Number: 'number', |
||||
String: 'string', |
||||
Variable: 'variable', |
||||
}; |
||||
} |
||||
|
||||
// implemented by subclasses, given a token, returns a lexical position in a query
|
||||
getStatementPosition(currentToken: LinkedToken | null): StatementPosition { |
||||
return StatementPosition.Unknown; |
||||
} |
||||
|
||||
// implemented by subclasses, given a lexical statement position, returns potential kinds of suggestions
|
||||
getSuggestionKinds(position: StatementPosition): SuggestionKind[] { |
||||
return []; |
||||
} |
||||
|
||||
// implemented by subclasses, given potential suggestions kinds, returns suggestion objects for monaco aka "CompletionItem"
|
||||
getSuggestions( |
||||
monaco: Monaco, |
||||
currentToken: LinkedToken | null, |
||||
suggestionKinds: SuggestionKind[], |
||||
statementPosition: StatementPosition, |
||||
position: monacoTypes.IPosition |
||||
): Promise<CompletionItem[]> { |
||||
return Promise.reject([]); |
||||
} |
||||
|
||||
// called by registerLanguage and passed to monaco with registerCompletionItemProvider
|
||||
// returns an object that implements https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.CompletionItemProvider.html
|
||||
getCompletionProvider(monaco: Monaco, languageDefinition: LanguageDefinition) { |
||||
return { |
||||
triggerCharacters: [' ', '$', ',', '(', "'"], // one of these characters indicates that it is time to look for a suggestion
|
||||
provideCompletionItems: async (model: monacoTypes.editor.ITextModel, position: monacoTypes.IPosition) => { |
||||
const currentToken = linkedTokenBuilder(monaco, languageDefinition, model, position, this.tokenTypes); |
||||
const statementPosition = this.getStatementPosition(currentToken); |
||||
const suggestionKinds = this.getSuggestionKinds(statementPosition); |
||||
const suggestions = await this.getSuggestions( |
||||
monaco, |
||||
currentToken, |
||||
suggestionKinds, |
||||
statementPosition, |
||||
position |
||||
); |
||||
|
||||
return { |
||||
suggestions, |
||||
}; |
||||
}, |
||||
}; |
||||
} |
||||
} |
@ -0,0 +1,90 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
import MonacoMock from '../__mocks__/monarch/Monaco'; |
||||
import TextModel from '../__mocks__/monarch/TextModel'; |
||||
import { multiLineFullQuery, singleLineFullQuery } from '../__mocks__/cloudwatch-sql-test-data'; |
||||
import { linkedTokenBuilder } from './linkedTokenBuilder'; |
||||
import { DESC, SELECT } from '../cloudwatch-sql/language'; |
||||
import cloudWatchSqlLanguageDefinition from '../cloudwatch-sql/definition'; |
||||
import { SQLTokenTypes } from '../cloudwatch-sql/completion/types'; |
||||
|
||||
describe('linkedTokenBuilder', () => { |
||||
describe('singleLineFullQuery', () => { |
||||
const testModel = TextModel(singleLineFullQuery.query); |
||||
|
||||
it('should add correct references to next LinkedToken', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 1, column: 0 }; |
||||
const current = linkedTokenBuilder( |
||||
MonacoMock, |
||||
cloudWatchSqlLanguageDefinition, |
||||
testModel as monacoTypes.editor.ITextModel, |
||||
position, |
||||
SQLTokenTypes |
||||
); |
||||
|
||||
expect(current?.is(SQLTokenTypes.Keyword, SELECT)).toBeTruthy(); |
||||
expect(current?.getNextNonWhiteSpaceToken()?.is(SQLTokenTypes.Function, 'AVG')).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should add correct references to previous LinkedToken', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 1, column: singleLineFullQuery.query.length }; |
||||
const current = linkedTokenBuilder( |
||||
MonacoMock, |
||||
cloudWatchSqlLanguageDefinition, |
||||
testModel as monacoTypes.editor.ITextModel, |
||||
position, |
||||
SQLTokenTypes |
||||
); |
||||
expect(current?.is(SQLTokenTypes.Number, '10')).toBeTruthy(); |
||||
expect(current?.getPreviousNonWhiteSpaceToken()?.is(SQLTokenTypes.Keyword, 'LIMIT')).toBeTruthy(); |
||||
expect( |
||||
current?.getPreviousNonWhiteSpaceToken()?.getPreviousNonWhiteSpaceToken()?.is(SQLTokenTypes.Keyword, DESC) |
||||
).toBeTruthy(); |
||||
}); |
||||
}); |
||||
|
||||
describe('multiLineFullQuery', () => { |
||||
const testModel = TextModel(multiLineFullQuery.query); |
||||
|
||||
it('should add LinkedToken with whitespace in case empty lines', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 3, column: 0 }; |
||||
const current = linkedTokenBuilder( |
||||
MonacoMock, |
||||
cloudWatchSqlLanguageDefinition, |
||||
testModel as monacoTypes.editor.ITextModel, |
||||
position, |
||||
SQLTokenTypes |
||||
); |
||||
expect(current).not.toBeNull(); |
||||
expect(current?.isWhiteSpace()).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should add correct references to next LinkedToken', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 1, column: 0 }; |
||||
const current = linkedTokenBuilder( |
||||
MonacoMock, |
||||
cloudWatchSqlLanguageDefinition, |
||||
testModel as monacoTypes.editor.ITextModel, |
||||
position, |
||||
SQLTokenTypes |
||||
); |
||||
expect(current?.is(SQLTokenTypes.Keyword, SELECT)).toBeTruthy(); |
||||
expect(current?.getNextNonWhiteSpaceToken()?.is(SQLTokenTypes.Function, 'AVG')).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should add correct references to previous LinkedToken even when references spans over multiple lines', () => { |
||||
const position: monacoTypes.IPosition = { lineNumber: 6, column: 7 }; |
||||
const current = linkedTokenBuilder( |
||||
MonacoMock, |
||||
cloudWatchSqlLanguageDefinition, |
||||
testModel as monacoTypes.editor.ITextModel, |
||||
position, |
||||
SQLTokenTypes |
||||
); |
||||
expect(current?.is(SQLTokenTypes.Number, '10')).toBeTruthy(); |
||||
expect(current?.getPreviousNonWhiteSpaceToken()?.is(SQLTokenTypes.Keyword, 'LIMIT')).toBeTruthy(); |
||||
expect( |
||||
current?.getPreviousNonWhiteSpaceToken()?.getPreviousNonWhiteSpaceToken()?.is(SQLTokenTypes.Keyword, DESC) |
||||
).toBeTruthy(); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,34 @@ |
||||
import { Monaco } from '@grafana/ui'; |
||||
import { CompletionItemProvider } from './CompletionItemProvider'; |
||||
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; |
||||
|
||||
export type LanguageDefinition = { |
||||
id: string; |
||||
extensions: string[]; |
||||
aliases: string[]; |
||||
mimetypes: string[]; |
||||
loader: () => Promise<{ |
||||
language: monacoType.languages.IMonarchLanguage; |
||||
conf: monacoType.languages.LanguageConfiguration; |
||||
}>; |
||||
}; |
||||
|
||||
export const registerLanguage = ( |
||||
monaco: Monaco, |
||||
language: LanguageDefinition, |
||||
completionItemProvider: CompletionItemProvider |
||||
) => { |
||||
const { id, loader } = language; |
||||
|
||||
const languages = monaco.languages.getLanguages(); |
||||
if (languages.find((l) => l.id === id)) { |
||||
return; |
||||
} |
||||
|
||||
monaco.languages.register({ id }); |
||||
loader().then((monarch) => { |
||||
monaco.languages.setMonarchTokensProvider(id, monarch.language); |
||||
monaco.languages.setLanguageConfiguration(id, monarch.conf); |
||||
monaco.languages.registerCompletionItemProvider(id, completionItemProvider.getCompletionProvider(monaco, language)); |
||||
}); |
||||
}; |
@ -0,0 +1,100 @@ |
||||
import { monacoTypes } from '@grafana/ui'; |
||||
|
||||
export interface TokenTypes { |
||||
Parenthesis: string; |
||||
Whitespace: string; |
||||
Keyword: string; |
||||
Delimiter: string; |
||||
Operator: string; |
||||
Identifier: string; |
||||
Type: string; |
||||
Function: string; |
||||
Number: string; |
||||
String: string; |
||||
Variable: string; |
||||
} |
||||
|
||||
export enum StatementPosition { |
||||
Unknown, |
||||
// sql
|
||||
SelectKeyword, |
||||
AfterSelectKeyword, |
||||
AfterSelectFuncFirstArgument, |
||||
AfterFromKeyword, |
||||
SchemaFuncFirstArgument, |
||||
SchemaFuncExtraArgument, |
||||
FromKeyword, |
||||
AfterFrom, |
||||
WhereKey, |
||||
WhereComparisonOperator, |
||||
WhereValue, |
||||
AfterWhereValue, |
||||
AfterGroupByKeywords, |
||||
AfterGroupBy, |
||||
AfterOrderByKeywords, |
||||
AfterOrderByFunction, |
||||
AfterOrderByDirection, |
||||
// metric math
|
||||
PredefinedFunction, |
||||
SearchFuncSecondArg, |
||||
SearchFuncThirdArg, |
||||
PredefinedFuncSecondArg, |
||||
AfterFunction, |
||||
WithinString, |
||||
} |
||||
|
||||
export enum SuggestionKind { |
||||
SelectKeyword, |
||||
FunctionsWithArguments, |
||||
Metrics, |
||||
FromKeyword, |
||||
SchemaKeyword, |
||||
Namespaces, |
||||
LabelKeys, |
||||
WhereKeyword, |
||||
GroupByKeywords, |
||||
OrderByKeywords, |
||||
FunctionsWithoutArguments, |
||||
LimitKeyword, |
||||
SortOrderDirectionKeyword, |
||||
ComparisonOperators, |
||||
LabelValues, |
||||
LogicalOperators, |
||||
|
||||
// metricmath,
|
||||
KeywordArguments, |
||||
Operators, |
||||
Statistic, |
||||
Period, |
||||
} |
||||
|
||||
export enum CompletionItemPriority { |
||||
High = 'a', |
||||
MediumHigh = 'd', |
||||
Medium = 'g', |
||||
MediumLow = 'k', |
||||
Low = 'q', |
||||
} |
||||
|
||||
export interface Editor { |
||||
tokenize: (value: string, languageId: string) => monacoTypes.Token[][]; |
||||
} |
||||
|
||||
export interface Range { |
||||
containsPosition: (range: monacoTypes.IRange, position: monacoTypes.IPosition) => boolean; |
||||
fromPositions: (start: monacoTypes.IPosition, end?: monacoTypes.IPosition) => monacoTypes.Range; |
||||
} |
||||
|
||||
export interface Languages { |
||||
CompletionItemInsertTextRule: { |
||||
InsertAsSnippet: 4; |
||||
}; |
||||
CompletionItemKind: { |
||||
Function: 1; |
||||
}; |
||||
} |
||||
export interface Monaco { |
||||
editor: Editor; |
||||
Range: Range; |
||||
languages: Languages; |
||||
} |
Loading…
Reference in new issue