From 16d77dda676c6679325e274de589902a25aacfa7 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 6 Oct 2023 19:23:48 +0200 Subject: [PATCH] Loki Query Autocomplete: better suggestions and insertions for Logfmt (#75561) * Logfmt autocompletion: better handling of trailing commas * Situation: refactor getSituation and getErrorNode Looking for simplicity, removing redundancy, and looking for a more accurate node resolution * Autocompletion: refactor logfmt and top level * Update tests * Autocompletion: better handling of trailing characters * Completions: Update data to type changes * Update log query in test situation * Format code * Autocomplete: support offering partial labels * Completion: expand test cases --- .../completions.test.ts | 266 +++++++----------- .../monaco-completion-provider/completions.ts | 59 ++-- .../situation.test.ts | 39 +++ .../monaco-completion-provider/situation.ts | 134 ++++----- 4 files changed, 258 insertions(+), 240 deletions(-) diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts index fadcc604342..e41985f7f89 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -532,11 +532,13 @@ describe('IN_LOGFMT completions', () => { hasPack: false, }); }); - it('autocompleting logfmt should return flags, parsers, pipe operations, and labels', async () => { + it('autocompleting logfmt should return flags, pipe operations, and labels', async () => { const situation: Situation = { type: 'IN_LOGFMT', - logQuery: `{job="grafana"} | logfmt`, + logQuery: `{job="grafana"} | logfmt `, flags: false, + trailingSpace: true, + trailingComma: false, otherLabels: [], }; @@ -556,34 +558,70 @@ describe('IN_LOGFMT completions', () => { }, { "documentation": "Operator docs", - "insertText": "| json", - "label": "json", - "type": "PARSER", + "insertText": "| line_format "{{.$0}}"", + "isSnippet": true, + "label": "line_format", + "type": "PIPE_OPERATION", }, { "documentation": "Operator docs", - "insertText": "| logfmt", - "label": "logfmt", - "type": "PARSER", + "insertText": "| label_format", + "isSnippet": true, + "label": "label_format", + "type": "PIPE_OPERATION", }, { "documentation": "Operator docs", - "insertText": "| pattern", - "label": "pattern", - "type": "PARSER", + "insertText": "| unwrap", + "label": "unwrap", + "type": "PIPE_OPERATION", }, { "documentation": "Operator docs", - "insertText": "| regexp", - "label": "regexp", - "type": "PARSER", + "insertText": "| decolorize", + "label": "decolorize", + "type": "PIPE_OPERATION", }, { "documentation": "Operator docs", - "insertText": "| unpack", - "label": "unpack", - "type": "PARSER", + "insertText": "| drop", + "label": "drop", + "type": "PIPE_OPERATION", }, + { + "documentation": "Operator docs", + "insertText": "| keep", + "label": "keep", + "type": "PIPE_OPERATION", + }, + { + "insertText": "label1", + "label": "label1", + "triggerOnInsert": false, + "type": "LABEL_NAME", + }, + { + "insertText": "label2", + "label": "label2", + "triggerOnInsert": false, + "type": "LABEL_NAME", + }, + ] + `); + }); + + it('autocompleting logfmt with flags and trailing space should return pipe operations, and labels', async () => { + const situation: Situation = { + type: 'IN_LOGFMT', + logQuery: `{job="grafana"} | logfmt`, + flags: true, + trailingSpace: true, + trailingComma: false, + otherLabels: [], + }; + + expect(await getCompletions(situation, completionProvider)).toMatchInlineSnapshot(` + [ { "documentation": "Operator docs", "insertText": "| line_format "{{.$0}}"", @@ -638,84 +676,18 @@ describe('IN_LOGFMT completions', () => { `); }); - it('autocompleting logfmt with flags should return parser, pipe operations, and labels', async () => { + it('autocompleting logfmt with labels and trailing comma should only return labels', async () => { const situation: Situation = { type: 'IN_LOGFMT', - logQuery: `{job="grafana"} | logfmt`, - flags: true, + logQuery: `{job="grafana"} | logfmt,`, + flags: false, + trailingComma: true, + trailingSpace: false, otherLabels: [], }; expect(await getCompletions(situation, completionProvider)).toMatchInlineSnapshot(` [ - { - "documentation": "Operator docs", - "insertText": "| json", - "label": "json", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| logfmt", - "label": "logfmt", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| pattern", - "label": "pattern", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| regexp", - "label": "regexp", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| unpack", - "label": "unpack", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| line_format "{{.$0}}"", - "isSnippet": true, - "label": "line_format", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| label_format", - "isSnippet": true, - "label": "label_format", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| unwrap", - "label": "unwrap", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| decolorize", - "label": "decolorize", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| drop", - "label": "drop", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| keep", - "label": "keep", - "type": "PIPE_OPERATION", - }, { "insertText": "label1", "label": "label1", @@ -735,90 +707,60 @@ describe('IN_LOGFMT completions', () => { it('autocompleting logfmt should exclude already used labels from the suggestions', async () => { const situation: Situation = { type: 'IN_LOGFMT', - logQuery: `{job="grafana"} | logfmt`, + logQuery: `{job="grafana"} | logfmt label1, label2`, flags: true, + trailingSpace: true, + trailingComma: false, otherLabels: ['label1', 'label2'], }; + const completions = await getCompletions(situation, completionProvider); + const labelCompletions = completions.filter((completion) => completion.type === 'LABEL_NAME'); - expect(await getCompletions(situation, completionProvider)).toMatchInlineSnapshot(` - [ - { - "documentation": "Operator docs", - "insertText": "| json", - "label": "json", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| logfmt", - "label": "logfmt", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| pattern", - "label": "pattern", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| regexp", - "label": "regexp", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| unpack", - "label": "unpack", - "type": "PARSER", - }, - { - "documentation": "Operator docs", - "insertText": "| line_format "{{.$0}}"", - "isSnippet": true, - "label": "line_format", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| label_format", - "isSnippet": true, - "label": "label_format", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| unwrap", - "label": "unwrap", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| decolorize", - "label": "decolorize", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| drop", - "label": "drop", - "type": "PIPE_OPERATION", - }, - { - "documentation": "Operator docs", - "insertText": "| keep", - "label": "keep", - "type": "PIPE_OPERATION", - }, - ] - `); + expect(labelCompletions).toHaveLength(0); }); + it.each([ + // {label="value"} | logfmt ^ + [true, false, [], false], + // {label="value"} | logfmt otherLabel ^ + [true, false, ['otherLabel'], true], + // {label="value"} | logfmt otherLabel^ + [false, false, ['otherLabel'], false], + // {label="value"} | logfmt lab^ + [false, false, ['lab'], false], + // {label="value"} | logfmt otherLabel,^ + [false, true, ['otherLabel'], false], + // {label="value"} | logfmt lab, ^ + [true, true, ['otherLabel'], false], + // {label="value"} | logfmt otherLabel ^ + [true, false, ['otherLabel'], true], + ])( + 'when space is %p, comma %p, and other labels %o => inserting a comma should be %p', + async (trailingSpace: boolean, trailingComma: boolean, otherLabels: string[], shouldHaveComma: boolean) => { + const situation: Situation = { + type: 'IN_LOGFMT', + logQuery: `does not matter`, + flags: true, + trailingComma, + trailingSpace, + otherLabels, + }; + const completions = await getCompletions(situation, completionProvider); + const labelCompletions = completions.filter((completion) => completion.type === 'LABEL_NAME'); + + expect(labelCompletions).toHaveLength(2); + expect(labelCompletions[0].insertText.startsWith(',')).toBe(shouldHaveComma); + expect(labelCompletions[1].insertText.startsWith(',')).toBe(shouldHaveComma); + } + ); + it('autocompleting logfmt without flags should only offer labels when the user has a trailing comma', async () => { const situation: Situation = { type: 'IN_LOGFMT', logQuery: `{job="grafana"} | logfmt --strict label3,`, flags: false, + trailingComma: true, + trailingSpace: false, otherLabels: ['label1'], }; @@ -839,6 +781,8 @@ describe('IN_LOGFMT completions', () => { type: 'IN_LOGFMT', logQuery: `{job="grafana"} | logfmt --strict label3,`, flags: true, + trailingComma: true, + trailingSpace: false, otherLabels: ['label1'], }; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts index a6e9aed728d..ab7b829e41c 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -351,43 +351,63 @@ export async function getAfterSelectorCompletions( export async function getLogfmtCompletions( logQuery: string, flags: boolean, + trailingComma: boolean | undefined, + trailingSpace: boolean | undefined, otherLabels: string[], dataProvider: CompletionDataProvider ): Promise { - const trailingComma = logQuery.trimEnd().endsWith(','); if (trailingComma) { // The user is typing a new label, so we remove the last comma logQuery = trimEnd(logQuery, ', '); } - const { extractedLabelKeys, hasJSON, hasLogfmt, hasPack } = await dataProvider.getParserAndLabelKeys(logQuery); - const hasQueryParser = isQueryWithParser(logQuery).queryWithParser; let completions: Completion[] = []; - const parserCompletions = await getParserCompletions( - '| ', - hasJSON, - hasLogfmt, - hasPack, - extractedLabelKeys, - hasQueryParser - ); + const { extractedLabelKeys, hasJSON, hasLogfmt, hasPack } = await dataProvider.getParserAndLabelKeys(logQuery); const pipeOperations = getPipeOperationsCompletions('| '); - if (!flags && !trailingComma) { - completions = [...completions, ...LOGFMT_ARGUMENT_COMPLETIONS, ...parserCompletions, ...pipeOperations]; - } else if (!trailingComma) { + // {label="value"} | logfmt ^ + if (!trailingComma && !flags) { + completions = [...LOGFMT_ARGUMENT_COMPLETIONS]; + } + // {label="value"} | logfmt --flag ^ + // {label="value"} | logfmt label, label2 ^ + if (!trailingComma && trailingSpace) { + /** + * Don't offer parsers: {label="value"} | logfmt ^ + * Offer parsers: {label="value"} | logfmt label ^ + */ + const parserCompletions = + otherLabels.length > 0 + ? await getParserCompletions('| ', hasJSON, hasLogfmt, hasPack, extractedLabelKeys, true) + : []; completions = [...completions, ...parserCompletions, ...pipeOperations]; } - const labelPrefix = otherLabels.length === 0 || trailingComma ? '' : ', '; const labels = extractedLabelKeys.filter((label) => !otherLabels.includes(label)); + + /** + * {label="value"} | logfmt ^ + * - trailingSpace: true, trailingComma: false, otherLabels: [] + * {label="value"} | logfmt lab^ + * trailingSpace: false, trailignComma: false, otherLabels: [lab] + * {label="value"} | logfmt label,^ + * trailingSpace: false, trailingComma: true, otherLabels: [label] + * {label="value"} | logfmt label, ^ + * trailingSpace: true, trailingComma: true, otherLabels: [label] + */ + let labelPrefix = ''; + if (otherLabels.length > 0 && trailingSpace) { + labelPrefix = trailingComma ? '' : ', '; + } + const labelCompletions: Completion[] = labels.map((label) => ({ type: 'LABEL_NAME', label, insertText: labelPrefix + label, triggerOnInsert: false, })); + completions = [...completions, ...labelCompletions]; return completions; @@ -472,7 +492,14 @@ export async function getCompletions( case 'AFTER_KEEP_AND_DROP': return getAfterKeepAndDropCompletions(situation.logQuery, dataProvider); case 'IN_LOGFMT': - return getLogfmtCompletions(situation.logQuery, situation.flags, situation.otherLabels, dataProvider); + return getLogfmtCompletions( + situation.logQuery, + situation.flags, + situation.trailingComma, + situation.trailingSpace, + situation.otherLabels, + dataProvider + ); default: throw new NeverCaseError(situation); } diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts index 251de49706e..0945af69f09 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts @@ -97,88 +97,127 @@ describe('situation', () => { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt', }); assertSituation('{level="info"} | logfmt --strict ^', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt --strict', }); assertSituation('{level="info"} | logfmt --strict --keep-empty^', { type: 'IN_LOGFMT', otherLabels: [], flags: true, + trailingComma: false, + trailingSpace: false, logQuery: '{level="info"} | logfmt --strict --keep-empty', }); assertSituation('{level="info"} | logfmt --strict label, label1="expression"^', { type: 'IN_LOGFMT', otherLabels: ['label', 'label1'], flags: false, + trailingSpace: false, + trailingComma: false, logQuery: '{level="info"} | logfmt --strict label, label1="expression"', }); assertSituation('{level="info"} | logfmt --strict label, label1="expression",^', { type: 'IN_LOGFMT', otherLabels: ['label', 'label1'], flags: false, + trailingComma: true, + trailingSpace: false, logQuery: '{level="info"} | logfmt --strict label, label1="expression",', }); assertSituation('count_over_time({level="info"} | logfmt ^', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt', }); assertSituation('count_over_time({level="info"} | logfmt ^)', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt', }); assertSituation('count_over_time({level="info"} | logfmt ^ [$__auto])', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt', }); assertSituation('count_over_time({level="info"} | logfmt --keep-empty^)', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: false, + trailingComma: false, logQuery: '{level="info"} | logfmt --keep-empty', }); assertSituation('count_over_time({level="info"} | logfmt --keep-empty label1, label2^)', { type: 'IN_LOGFMT', otherLabels: ['label1', 'label2'], flags: false, + trailingSpace: false, + trailingComma: false, logQuery: '{level="info"} | logfmt --keep-empty label1, label2', }); assertSituation('sum by (test) (count_over_time({level="info"} | logfmt ^))', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt', }); assertSituation('sum by (test) (count_over_time({level="info"} | logfmt label ^))', { type: 'IN_LOGFMT', otherLabels: ['label'], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt label', }); assertSituation('sum by (test) (count_over_time({level="info"} | logfmt label,^))', { type: 'IN_LOGFMT', otherLabels: ['label'], flags: false, + trailingComma: true, + trailingSpace: false, logQuery: '{level="info"} | logfmt label,', }); assertSituation('sum by (test) (count_over_time({level="info"} | logfmt --strict ^))', { type: 'IN_LOGFMT', otherLabels: [], flags: false, + trailingSpace: true, + trailingComma: false, logQuery: '{level="info"} | logfmt --strict', }); }); + it('identifies AFTER_LOGFMT autocomplete situations when the cursor is not at the end', () => { + assertSituation('{level="info"} | logfmt ^ label1, label2', { + type: 'IN_LOGFMT', + otherLabels: ['label1', 'label2'], + flags: false, + trailingSpace: true, + trailingComma: false, + logQuery: '{level="info"} | logfmt label1, label2', + }); + }); + it('identifies IN_AGGREGATION autocomplete situations', () => { assertSituation('sum(^)', { type: 'IN_AGGREGATION', diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts index 3b69a725000..0a9c78e33d4 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -1,4 +1,4 @@ -import type { Tree, SyntaxNode } from '@lezer/common'; +import type { SyntaxNode, TreeCursor } from '@lezer/common'; import { parser, @@ -28,6 +28,7 @@ import { ParserFlag, LabelExtractionExpression, LabelExtractionExpressionList, + LogfmtExpressionParser, } from '@grafana/lezer-logql'; import { getLogQueryFromMetricsQueryAtPosition, getNodesFromQuery } from '../../../queryUtils'; @@ -108,6 +109,8 @@ export type Situation = type: 'IN_LOGFMT'; otherLabels: string[]; flags: boolean; + trailingSpace: boolean; + trailingComma: boolean; logQuery: string; } | { @@ -158,11 +161,7 @@ const ERROR_NODE_ID = 0; const RESOLVERS: Resolver[] = [ { - paths: [[Selector]], - fun: resolveSelector, - }, - { - paths: [[ERROR_NODE_ID, Matchers, Selector]], + paths: [[Selector], [ERROR_NODE_ID, Matchers, Selector]], fun: resolveSelector, }, { @@ -174,11 +173,12 @@ const RESOLVERS: Resolver[] = [ [LogRangeExpr], [ERROR_NODE_ID, LabelExtractionExpressionList], [LabelExtractionExpressionList], + [LogfmtExpressionParser], ], fun: resolveLogfmtParser, }, { - paths: [[LogQL]], + paths: [[LogQL], [ERROR_NODE_ID, Selector]], fun: resolveTopLevel, }, { @@ -210,7 +210,10 @@ const RESOLVERS: Resolver[] = [ fun: () => ({ type: 'IN_AGGREGATION' }), }, { - paths: [[ERROR_NODE_ID, PipelineStage, PipelineExpr]], + paths: [ + [ERROR_NODE_ID, PipelineStage, PipelineExpr], + [PipelineStage, PipelineExpr], + ], fun: resolvePipeError, }, { @@ -308,24 +311,23 @@ function resolveAfterUnwrap(node: SyntaxNode, text: string, pos: number): Situat } function resolvePipeError(node: SyntaxNode, text: string, pos: number): Situation | null { - // for example `{level="info"} |` - const exprNode = walk(node, [ - ['parent', PipelineStage], - ['parent', PipelineExpr], - ]); - - if (exprNode === null) { - return null; + /** + * Examples: + * - {level="info"} |^ + * - count_over_time({level="info"} |^ [4m]) + */ + let exprNode: SyntaxNode | null = null; + if (node.type.id === ERROR_NODE_ID) { + exprNode = walk(node, [ + ['parent', PipelineStage], + ['parent', PipelineExpr], + ]); + } else if (node.type.id === PipelineStage) { + exprNode = walk(node, [['parent', PipelineExpr]]); } - const { parent } = exprNode; - - if (parent === null) { - return null; - } - - if (parent.type.id === LogExpr || parent.type.id === LogRangeExpr) { - return resolveLogOrLogRange(parent, text, pos, true); + if (exprNode?.parent?.type.id === LogExpr || exprNode?.parent?.type.id === LogRangeExpr) { + return resolveLogOrLogRange(exprNode.parent, text, pos, true); } return null; @@ -427,7 +429,11 @@ function resolveMatcher(node: SyntaxNode, text: string, pos: number): Situation function resolveLogfmtParser(_: SyntaxNode, text: string, cursorPosition: number): Situation | null { // We want to know if the cursor if after a log query with logfmt parser. // E.g. `{x="y"} | logfmt ^` - + /** + * Wait until the user adds a space to be sure of what the last identifier is. Otherwise + * it creates suggestion bugs with queries like {label="value"} | parser^ suggest "parser" + * and it can be inserted with extra pipes or commas. + */ const tree = parser.parse(text); // Adjust the cursor position if there are spaces at the end of the text. @@ -461,24 +467,38 @@ function resolveLogfmtParser(_: SyntaxNode, text: string, cursorPosition: number .filter((label: SyntaxNode | null): label is SyntaxNode => label !== null) .map((label: SyntaxNode) => getNodeText(label, text)); + const logQuery = getLogQueryFromMetricsQueryAtPosition(text, position).trim(); + const trailingSpace = text.charAt(cursorPosition - 1) === ' '; + const trailingComma = text.trimEnd().charAt(position - 1) === ','; + return { type: 'IN_LOGFMT', otherLabels, flags, - logQuery: getLogQueryFromMetricsQueryAtPosition(text, position).trim(), + trailingSpace, + trailingComma, + logQuery, }; } function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Situation | null { - // we try a couply specific paths here. - // `{x="y"}` situation, with the cursor at the end - + /** + * Top level examples: + * - Empty query + * - {label="value"} + * - {label="value"} | parser + */ const logExprNode = walk(node, [ ['lastChild', Expr], ['lastChild', LogExpr], ]); - if (logExprNode != null) { + /** + * Wait until the user adds a space to be sure of what the last identifier is. Otherwise + * it creates suggestion bugs with queries like {label="value"} | parser^ suggest "parser" + * and it can be inserted with extra pipes. + */ + if (logExprNode != null && text.endsWith(' ')) { return resolveLogOrLogRange(logExprNode, text, pos, false); } @@ -603,28 +623,26 @@ function resolveAfterKeepAndDrop(node: SyntaxNode, text: string, pos: number): S }; } -// we find the first error-node in the tree that is at the cursor-position. -// NOTE: this might be too slow, might need to optimize it -// (ideas: we do not need to go into every subtree, based on from/to) -// also, only go to places that are in the sub-tree of the node found -// by default by lezer. problem is, `next()` will go upward too, -// and we do not want to go higher than our node -function getErrorNode(tree: Tree, text: string, cursorPos: number): SyntaxNode | null { - // sometimes the cursor is a couple spaces after the end of the expression. - // to account for this situation, we "move" the cursor position back, - // so that there are no spaces between the end-of-expression and the cursor +// If there is an error in the current cursor position, it's likely that the user is +// in the middle of writing a query. If we can't find an error node, we use the node +// at the cursor position to identify the situation. +function resolveCursor(text: string, cursorPos: number): TreeCursor { + // Sometimes the cursor is a couple spaces after the end of the expression. + // To account for this situation, we "move" the cursor position back to the real end + // of the expression. const trimRightTextLen = text.trimEnd().length; const pos = trimRightTextLen < cursorPos ? trimRightTextLen : cursorPos; - const cur = tree.cursorAt(pos); + + const tree = parser.parse(text); + const cursor = tree.cursorAt(pos); + do { - if (cur.from === pos && cur.to === pos) { - const { node } = cur; - if (node.type.isError) { - return node; - } + if (cursor.from === pos && cursor.to === pos && cursor.node.type.isError) { + return cursor; } - } while (cur.next()); - return null; + } while (cursor.next()); + + return tree.cursorAt(pos); } export function getSituation(text: string, pos: number): Situation | null { @@ -637,22 +655,12 @@ export function getSituation(text: string, pos: number): Situation | null { }; } - const tree = parser.parse(text); + const cursor = resolveCursor(text, pos); + const currentNode = cursor.node; - // if the tree contains error, it is very probable that - // our node is one of those error nodes. - // also, if there are errors, the node lezer finds us, - // might not be the best node. - // so first we check if there is an error node at the cursor position - const maybeErrorNode = getErrorNode(tree, text, pos); - - const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(pos); - - const currentNode = cur.node; - - const ids = [cur.type.id]; - while (cur.parent()) { - ids.push(cur.type.id); + const ids = [cursor.type.id]; + while (cursor.parent()) { + ids.push(cursor.type.id); } for (let resolver of RESOLVERS) {