diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx index f1ea5d4a399..6f9a4373d1e 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx @@ -178,7 +178,7 @@ export class LokiQueryField extends React.PureComponent = {}) { const datasource = createLokiDatasource(); - const languageProvider = new LokiLanguageProvider(datasource); render( = {}) { const datasource = createLokiDatasource(); - const languageProvider = new LokiLanguageProvider(datasource); render( { }; }; -const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initialValue }: Props) => { +const MonacoQueryField = ({ history, onBlur, onRunQuery, initialValue, datasource }: Props) => { const id = uuidv4(); // we need only one instance of `overrideServices` during the lifetime of the react component const overrideServicesRef = useRef(getOverrideServices()); const containerRef = useRef(null); - const langProviderRef = useLatest(languageProvider); + + const langProviderRef = useLatest(datasource.languageProvider); const historyRef = useLatest(history); const onRunQueryRef = useLatest(onRunQuery); const onBlurRef = useLatest(onBlur); @@ -123,6 +125,28 @@ const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initi isEditorFocused.set(false); onBlurRef.current(editor.getValue()); }); + editor.onDidChangeModelContent((e) => { + const model = editor.getModel(); + if (!model) { + return; + } + const query = model.getValue(); + const errors = + validateQuery( + query, + datasource.interpolateString(query, placeHolderScopedVars), + model.getLinesContent() + ) || []; + + const markers = errors.map(({ error, ...boundary }) => ({ + message: `${ + error ? `Error parsing "${error}"` : 'Parse error' + }. The query appears to be incorrect and could fail to be executed.`, + severity: monaco.MarkerSeverity.Error, + ...boundary, + })); + monaco.editor.setModelMarkers(model, 'owner', markers); + }); const dataProvider = new CompletionDataProvider(langProviderRef.current, historyRef.current); const completionProvider = getCompletionProvider(monaco, dataProvider); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts index 077c012b572..997256aeb32 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts @@ -1,6 +1,6 @@ import { HistoryItem } from '@grafana/data'; -import type LanguageProvider from '../../LanguageProvider'; +import { LokiDatasource } from '../../datasource'; import { LokiQuery } from '../../types'; // we need to store this in a separate file, @@ -9,8 +9,8 @@ import { LokiQuery } from '../../types'; // props as the sync-component. export type Props = { initialValue: string; - languageProvider: LanguageProvider; history: Array>; onRunQuery: (value: string) => void; onBlur: (value: string) => void; + datasource: LokiDatasource; }; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/validation.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/validation.test.ts new file mode 100644 index 00000000000..67e19a16c3b --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/validation.test.ts @@ -0,0 +1,101 @@ +import { validateQuery } from './validation'; + +describe('Monaco Query Validation', () => { + test('Identifies empty queries as valid', () => { + expect(validateQuery('', '', [])).toBeFalsy(); + }); + + test('Identifies valid queries', () => { + const query = '{place="luna"}'; + expect(validateQuery(query, query, [])).toBeFalsy(); + }); + + test('Validates logs queries', () => { + let query = '{place="incomplete"'; + expect(validateQuery(query, query, [query])).toEqual([ + { + endColumn: 20, + endLineNumber: 1, + error: '{place="incomplete"', + startColumn: 1, + startLineNumber: 1, + }, + ]); + + query = '{place="luna"} | notaparser'; + expect(validateQuery(query, query, [query])).toEqual([ + { + endColumn: 28, + endLineNumber: 1, + error: 'notaparser', + startColumn: 18, + startLineNumber: 1, + }, + ]); + + query = '{place="luna"} | logfmt |'; + expect(validateQuery(query, query, [query])).toEqual([ + { + endColumn: 26, + endLineNumber: 1, + error: '|', + startColumn: 25, + startLineNumber: 1, + }, + ]); + }); + + test('Validates metric queries', () => { + let query = 'sum(count_over_time({place="luna" | unwrap request_time [5m])) by (level)'; + expect(validateQuery(query, query, [query])).toEqual([ + { + endColumn: 35, + endLineNumber: 1, + error: '{place="luna" ', + startColumn: 21, + startLineNumber: 1, + }, + ]); + + query = 'sum(count_over_time({place="luna"} | unwrap [5m])) by (level)'; + expect(validateQuery(query, query, [query])).toEqual([ + { + endColumn: 45, + endLineNumber: 1, + error: '| unwrap ', + startColumn: 36, + startLineNumber: 1, + }, + ]); + + query = 'sum()'; + expect(validateQuery(query, query, [query])).toEqual([ + { + endColumn: 5, + endLineNumber: 1, + error: '', + startColumn: 5, + startLineNumber: 1, + }, + ]); + }); + + test('Validates multi-line queries', () => { + const query = ` +{place="luna"} +# this is a comment +| +logfmt fail +|= "a"`; + const queryLines = query.split('\n'); + expect(validateQuery(query, query, queryLines)).toEqual([ + { + endColumn: 12, + endLineNumber: 5, + error: 'fail', + startColumn: 8, + startLineNumber: 5, + }, + ]); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/validation.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/validation.ts new file mode 100644 index 00000000000..842feeef509 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/validation.ts @@ -0,0 +1,115 @@ +import { SyntaxNode } from '@lezer/common'; + +import { parser } from '@grafana/lezer-logql'; +import { ErrorId } from 'app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils'; + +interface ParserErrorBoundary { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + error: string; +} + +interface ParseError { + text: string; + node: SyntaxNode; +} + +export function validateQuery( + query: string, + interpolatedQuery: string, + queryLines: string[] +): ParserErrorBoundary[] | false { + if (!query) { + return false; + } + + /** + * To provide support to variable interpolation in query validation, we run the parser in the interpolated + * query. If there are errors there, we trace them back to the original unparsed query, so we can more + * accurately highlight the error in the query, since it's likely that the variable name and variable value + * have different lengths. With this, we also exclude irrelevant parser errors that are produced by + * lezer not understanding $variables and $__variables, which usually generate 2 or 3 error SyntaxNode. + */ + const interpolatedErrors: ParseError[] = parseQuery(interpolatedQuery); + if (!interpolatedErrors.length) { + return false; + } + + let parseErrors: ParseError[] = interpolatedErrors; + if (query !== interpolatedQuery) { + const queryErrors: ParseError[] = parseQuery(query); + parseErrors = interpolatedErrors.flatMap( + (interpolatedError) => + queryErrors.filter((queryError) => interpolatedError.text === queryError.text) || interpolatedError + ); + } + + return parseErrors.map((parseError) => findErrorBoundary(query, queryLines, parseError)).filter(isErrorBoundary); +} + +function parseQuery(query: string) { + const parseErrors: ParseError[] = []; + const tree = parser.parse(query); + tree.iterate({ + enter: (nodeRef): false | void => { + if (nodeRef.type.id === ErrorId) { + const node = nodeRef.node; + parseErrors.push({ + node: node, + text: query.substring(node.from, node.to), + }); + } + }, + }); + return parseErrors; +} + +function findErrorBoundary(query: string, queryLines: string[], parseError: ParseError): ParserErrorBoundary | null { + if (queryLines.length === 1) { + const isEmptyString = parseError.node.from === parseError.node.to; + const errorNode = isEmptyString && parseError.node.parent ? parseError.node.parent : parseError.node; + const error = isEmptyString ? query.substring(errorNode.from, errorNode.to) : parseError.text; + return { + startLineNumber: 1, + startColumn: errorNode.from + 1, + endLineNumber: 1, + endColumn: errorNode.to + 1, + error, + }; + } + + let startPos = 0, + endPos = 0; + for (let line = 0; line < queryLines.length; line++) { + endPos = startPos + queryLines[line].length; + + if (parseError.node.from > endPos) { + startPos += queryLines[line].length + 1; + continue; + } + + return { + startLineNumber: line + 1, + startColumn: parseError.node.from - startPos + 1, + endLineNumber: line + 1, + endColumn: parseError.node.to - startPos + 1, + error: parseError.text, + }; + } + + return null; +} + +function isErrorBoundary(boundary: ParserErrorBoundary | null): boundary is ParserErrorBoundary { + return boundary !== null; +} + +export const placeHolderScopedVars = { + __interval: { text: '1s', value: '1s' }, + __interval_ms: { text: '1000', value: 1000 }, + __range_ms: { text: '1000', value: 1000 }, + __range_s: { text: '1', value: 1 }, + __range: { text: '1s', value: '1s' }, +}; diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 6510fae6b2c..17de51538eb 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -794,8 +794,8 @@ export class LokiDatasource }; } - interpolateString(string: string) { - return this.templateSrv.replace(string, undefined, this.interpolateQueryExpr); + interpolateString(string: string, scopedVars?: ScopedVars) { + return this.templateSrv.replace(string, scopedVars, this.interpolateQueryExpr); } getVariables(): string[] {