diff --git a/loki/src/components/complete.test.ts b/loki/src/components/complete.test.ts new file mode 100644 index 00000000..7dfdfe52 --- /dev/null +++ b/loki/src/components/complete.test.ts @@ -0,0 +1,365 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { EditorState, EditorView } from '@uiw/react-codemirror'; +import { parser } from '@grafana/lezer-logql'; +import { LRLanguage, ensureSyntaxTree } from '@codemirror/language'; +import { identifyCompletion, applyQuotedCompletion } from './complete'; + +const logQLExtension = LRLanguage.define({ parser: parser }); + +describe('complete', () => { + describe('identifyCompletion', () => { + it.each([ + // Empty query + { + expr: '', + expected: undefined, + }, + + // Label name completions - Selector + { + expr: '{', + expected: { scope: { kind: 'LabelName' }, from: 1 }, + }, + { + expr: '{ ', + expected: { scope: { kind: 'LabelName' }, from: 2 }, + }, + { + expr: '{}', + pos: 1, + expected: { scope: { kind: 'LabelName' }, from: 1 }, + }, + // After closing brace - parser treats this as still in selector context + { + expr: '{}', + expected: { scope: { kind: 'LabelName' }, from: 1 }, + }, + + // Label name completions - after comma + { + expr: '{foo="bar",', + expected: { scope: { kind: 'LabelName' }, from: 11 }, + }, + { + expr: '{foo="bar", ', + expected: { scope: { kind: 'LabelName' }, from: 12 }, + }, + + // Label name completions - partial identifier + { + expr: '{f', + expected: { scope: { kind: 'LabelName' }, from: 1 }, + }, + { + expr: '{fo', + expected: { scope: { kind: 'LabelName' }, from: 1 }, + }, + { + expr: '{foo="bar", e', + expected: { scope: { kind: 'LabelName' }, from: 12 }, + }, + + // Label name completions - after complete matcher + // Note: Without closing brace, parser doesn't detect this as completed matcher + { + expr: '{foo="bar" ', + expected: undefined, + }, + + // Label value completions - after operator + { + expr: '{foo=', + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 5 }, + }, + { + expr: '{foo!=', + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + { + expr: '{foo=~', + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + { + expr: '{foo!~', + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + + // Label value completions - partial unquoted value + // Note: Parser may not create ERROR_NODE for simple identifiers + { + expr: '{foo=ba', + expected: undefined, + }, + + // Label value completions - inside quotes + { + expr: '{foo="', + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + // Note: Incomplete string without closing quote may not trigger completion + { + expr: '{foo="ba', + expected: undefined, + }, + { + expr: '{foo=""', + pos: 6, + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + { + expr: '{foo=""', + expected: undefined, + }, + + // Label value completions - with backticks + { + expr: '{foo=`', + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + + // Label value completions - complex label names + { + expr: '{env="prod", app=', + expected: { scope: { kind: 'LabelValue', label: 'app' }, from: 17 }, + }, + + // Pipe function completions - after closing brace + { + expr: '{foo="bar"} ', + expected: { + scope: { kind: 'PipeFunction', afterPipe: false, hasSpace: true, afterExclamation: false }, + from: 12, + }, + }, + + // Pipe function completions - after pipe + { + expr: '{foo="bar"} |', + expected: { + scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: false, afterExclamation: false }, + from: 13, + }, + }, + { + expr: '{foo="bar"} | ', + expected: { + scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: true, afterExclamation: false }, + from: 14, + }, + }, + + // Pipe function completions - after exclamation + { + expr: '{foo="bar"} !', + expected: { + scope: { kind: 'PipeFunction', afterPipe: false, hasSpace: true, afterExclamation: true }, + from: 12, + }, + }, + + // Multiple matchers + { + expr: '{foo="bar", env="prod"}', + pos: 13, + expected: { scope: { kind: 'LabelName' }, from: 12 }, + }, + + // Label with regex operator + { + expr: '{foo=~"bar.*"}', + pos: 7, + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 7 }, + }, + + // After pipe with partial function - parser sees 'j' as error/label identifier + { + expr: '{foo="bar"} | j', + expected: { scope: { kind: 'LabelName' }, from: 14 }, + }, + + // No completion after complete query + { + expr: '{foo="bar"}', + expected: undefined, + }, + + // Cursor in middle of value - parser sees this as inside the string + { + expr: '{foo="bar"}', + pos: 8, + expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 }, + }, + ])('should identify completion for: $expr', ({ expr, pos, expected }) => { + if (pos === undefined) pos = expr.length; + if (pos < 0) pos = expr.length + pos; + + const state = EditorState.create({ doc: expr, extensions: logQLExtension }); + const tree = ensureSyntaxTree(state, expr.length, 1000); + expect(tree).not.toBeNull(); + const completion = identifyCompletion(state, pos, tree!); + expect(completion).toEqual(expected); + }); + }); + + describe('applyQuotedCompletion', () => { + it.each([ + // Basic quote addition + { + doc: '{foo=', + completion: 'bar', + from: 5, + expected: '{foo="bar"', + }, + + // Quote already present - opening + { + doc: '{foo="', + completion: 'bar', + from: 6, + expected: '{foo="bar"', + }, + + // Quote already present - cursor before opening quote + { + doc: '{foo="', + completion: 'bar', + from: 5, + expected: '{foo="bar"', + }, + + // Quote already present - both quotes + { + doc: '{foo=""', + completion: 'bar', + from: 6, + expected: '{foo="bar"', + }, + + // Partial value replacement + { + doc: '{foo=ba', + completion: 'bar', + from: 5, + to: 7, + expected: '{foo="bar"', + }, + + // Partial value in quotes replacement + { + doc: '{foo="ba"', + completion: 'bar', + from: 6, + to: 8, + expected: '{foo="bar"', + }, + + // Escaping - double quotes + { + doc: '{foo=', + completion: 'my"value', + from: 5, + expected: '{foo="my\\"value"', + }, + + // Escaping - backslashes + { + doc: '{foo=', + completion: 'path\\to\\file', + from: 5, + expected: '{foo="path\\\\to\\\\file"', + }, + + // Escaping - both quotes and backslashes + { + doc: '{foo=', + completion: 'test\\"value', + from: 5, + expected: '{foo="test\\\\\\"value"', + }, + + // Backticks - no escaping needed + { + doc: '{foo=`', + completion: 'bar', + from: 6, + expected: '{foo=`bar`', + }, + + // Backticks - cursor before opening backtick + { + doc: '{foo=`', + completion: 'bar', + from: 5, + expected: '{foo=`bar`', + }, + + // Backticks - with quotes inside (no escaping) + { + doc: '{foo=`', + completion: 'my"value', + from: 6, + expected: '{foo=`my"value`', + }, + + // Backticks - with backslashes (no escaping) + { + doc: '{foo=`', + completion: 'path\\to\\file', + from: 6, + expected: '{foo=`path\\to\\file`', + }, + + // Value contains backtick - switch to double quotes + { + doc: '{foo=`', + completion: 'value`with`backticks', + from: 6, + expected: '{foo="value`with`backticks"', + }, + + // Value contains backtick - switch to double quotes and escape + { + doc: '{foo=`', + completion: 'value`with"quotes', + from: 6, + expected: '{foo="value`with\\"quotes"', + }, + + // Empty value + { + doc: '{foo=', + completion: '', + from: 5, + expected: '{foo=""', + }, + + // Value with spaces + { + doc: '{foo=', + completion: 'bar baz', + from: 5, + expected: '{foo="bar baz"', + }, + ])( + 'should apply quoted completion: $completion at pos $from in "$doc"', + ({ doc, completion, from, to, expected }) => { + const state = EditorState.create({ doc }); + const view = new EditorView({ state }); + applyQuotedCompletion(view, { label: completion }, from, to ?? from); + expect(view.state.doc.toString()).toBe(expected); + } + ); + }); +}); diff --git a/loki/src/components/complete.ts b/loki/src/components/complete.ts index a0f6acd2..8cc02e7b 100644 --- a/loki/src/components/complete.ts +++ b/loki/src/components/complete.ts @@ -14,14 +14,28 @@ import { Completion, CompletionContext, CompletionResult, insertCompletionText } from '@codemirror/autocomplete'; import { syntaxTree } from '@codemirror/language'; import { EditorState } from '@codemirror/state'; -import { Tree } from '@lezer/common'; -import { Selector, Matchers, Matcher, Identifier, Eq, Neq, Re, Nre, String as StringType } from '@grafana/lezer-logql'; +import { SyntaxNode, Tree } from '@lezer/common'; +import { + Selector, + Matchers, + Matcher, + Identifier, + Eq, + Neq, + Re, + Nre, + String as StringType, + Pipe, +} from '@grafana/lezer-logql'; import { EditorView } from '@uiw/react-codemirror'; -import { toUnixSeconds } from '../model/loki-client'; +import { toUnixSeconds } from '../model'; import { CompletionConfig } from './logql-extension'; /** CompletionScope specifies the completion kind, e.g. whether to complete label names or values */ -type CompletionScope = { kind: 'LabelName' } | { kind: 'LabelValue'; label: string }; +type CompletionScope = + | { kind: 'LabelName' } + | { kind: 'LabelValue'; label: string } + | { kind: 'PipeFunction'; afterPipe: boolean; hasSpace: boolean; afterExclamation: boolean }; /** * CompletionInfo specifies the identified scope and position of the completion in the current editor text. @@ -34,6 +48,7 @@ export interface CompletionInfo { const quoteChars = ['"', '`']; const defaultQuoteChar = '"'; +const ERROR_NODE = 0; // Lezer parser creates error nodes for incomplete/malformed syntax export async function complete( completionCfg: CompletionConfig, @@ -59,19 +74,96 @@ export async function complete( export function identifyCompletion(state: EditorState, pos: number, tree: Tree): CompletionInfo | undefined { const node = tree.resolveInner(pos, -1); + switch (node.type.id) { + case Selector: + case Matchers: + case Matcher: + case Identifier: + case Eq: + case Neq: + case Re: + case Nre: + case StringType: { + const labelCompletion = detectLabelCompletion(state, pos, node); + if (labelCompletion) return labelCompletion; + break; + } + + case ERROR_NODE: { + // Check for pipe context first + const pipeCompletion = detectPipeCompletion(state, node); + if (pipeCompletion) return pipeCompletion; + + // Then check for label completion in error nodes + const labelCompletion = detectLabelCompletion(state, pos, node); + if (labelCompletion) return labelCompletion; + break; + } + + case Pipe: { + // Pipe operator: suggest parser functions and line filters + // Examples: {job="nginx"} |▯ or {job="nginx"} | ▯ + const hasSpaceAfterPipe = state.sliceDoc(pos - 1, pos) === ' '; + return { + scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: hasSpaceAfterPipe, afterExclamation: false }, + from: pos, + }; + } + } + + // Fallback checks for contexts not directly on a node + const textBeforeCursor = state.sliceDoc(0, pos).trim(); + const hasSpace = state.sliceDoc(pos - 1, pos).match(/\s/) !== null; + + // Check if cursor is after a pipe operator followed by whitespace + // This enables autocomplete after: {job="nginx"} | ▯ + if (textBeforeCursor.endsWith('|') && hasSpace) { + return { + scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: true, afterExclamation: false }, + from: pos, + }; + } + + // Check if cursor is after a closing brace (stream selector) followed by whitespace + // This enables autocomplete after: {job="nginx"} ▯ + if (textBeforeCursor.endsWith('}') && hasSpace) { + return { + scope: { kind: 'PipeFunction', afterPipe: false, hasSpace: true, afterExclamation: false }, + from: pos, + }; + } +} + +/** + * Retrieve completion options based on the identified completion scope. + */ +async function retrieveOptions(completionCfg: CompletionConfig, completion: CompletionScope): Promise { + switch (completion.kind) { + case 'LabelName': + return completeLabelName(completionCfg); + + case 'LabelValue': + return completeLabelValue(completionCfg, completion.label); + + case 'PipeFunction': + return completePipeFunctions(completion.afterPipe, completion.hasSpace, completion.afterExclamation); + } +} + +/** + * Detect label name or value completion contexts within selectors. + */ +function detectLabelCompletion(state: EditorState, pos: number, node: SyntaxNode): CompletionInfo | undefined { switch (node.type.id) { case Selector: // Selector is the entire {label matchers} expression // Autocomplete at start: {▯ or empty: {} // Do not autocomplete if cursor is after closing brace: {job="mysql"}▯ if ( - (node.firstChild === null || node.firstChild?.type.id === 0) && + (node.firstChild === null || node.firstChild?.type.id === ERROR_NODE) && !state.sliceDoc(node.from, pos).includes('}') ) { - return { - scope: { kind: 'LabelName' }, - from: pos, - }; + return { scope: { kind: 'LabelName' }, from: pos }; } break; @@ -80,10 +172,7 @@ export function identifyCompletion(state: EditorState, pos: number, tree: Tree): // Autocomplete after comma: { job="mysql",▯ or { job="mysql", ▯ const text = state.sliceDoc(node.from, pos); if (text.endsWith(',') || text.endsWith(', ')) { - return { - scope: { kind: 'LabelName' }, - from: pos, - }; + return { scope: { kind: 'LabelName' }, from: pos }; } break; } @@ -92,10 +181,7 @@ export function identifyCompletion(state: EditorState, pos: number, tree: Tree): // Single matcher like job="mysql" // Autocomplete when cursor is after a complete matcher: { job="mysql" ▯ if (node.parent?.type.id === Matchers) { - return { - scope: { kind: 'LabelName' }, - from: pos, - }; + return { scope: { kind: 'LabelName' }, from: pos }; } break; @@ -103,10 +189,7 @@ export function identifyCompletion(state: EditorState, pos: number, tree: Tree): // Identifier is a label name being typed // Autocomplete partial label names: { jo▯ or { job="mysql", na▯ if (node.parent?.type.id === Matcher) { - return { - scope: { kind: 'LabelName' }, - from: node.from, - }; + return { scope: { kind: 'LabelName' }, from: node.from }; } break; @@ -137,7 +220,7 @@ export function identifyCompletion(state: EditorState, pos: number, tree: Tree): } break; - case 0 /* error node */: + case ERROR_NODE: // Error nodes represent incomplete or malformed syntax // Autocomplete incomplete value after operator: { job=mys▯ or { job="mys▯ if ( @@ -158,26 +241,117 @@ export function identifyCompletion(state: EditorState, pos: number, tree: Tree): // Autocomplete partial label name: { j▯ or { job="mysql", n▯ if (node.parent?.type.id === Selector || node.parent?.type.id === Matchers) { - return { - scope: { kind: 'LabelName' }, - from: node.from, - }; + return { scope: { kind: 'LabelName' }, from: node.from }; } break; } + + return undefined; } /** - * Retrieve completion options based on the identified completion scope. + * Detect pipe function completion contexts (line filters, parsers, formatters). */ -async function retrieveOptions(completionCfg: CompletionConfig, completion: CompletionScope): Promise { - switch (completion.kind) { - case 'LabelName': - return completeLabelName(completionCfg); +function detectPipeCompletion(state: EditorState, node: SyntaxNode): CompletionInfo | undefined { + // Check if we're in an error node right after a pipe operator + // This handles cases like: {job="nginx"} | !▯ + if (node.prevSibling?.type.id === Pipe) { + return { + scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: true, afterExclamation: false }, + from: node.from, + }; + } - case 'LabelValue': - return completeLabelValue(completionCfg, completion.label); + // Check if we're after selector with space and text starts with ! + // This handles cases like: {job="nginx"} !▯ + const errorText = state.sliceDoc(node.from, node.to); + if (errorText.startsWith('!')) { + const textBeforeError = state.sliceDoc(0, node.from).trim(); + if (textBeforeError.endsWith('}')) { + return { + scope: { kind: 'PipeFunction', afterPipe: false, hasSpace: true, afterExclamation: true }, + from: node.from, + }; + } } + + return undefined; +} + +/** + * Complete LogQL pipe functions, line filters, and parser functions. + * Context-aware suggestions based on LogQL syntax: + * - After "{} ": Show all line filters (|=, !=, |~, !~) + parsers (with | prefix) + * - After "{} !": Show ONLY != and !~ + * - After "{} |": Show only pipe-based filters WITHOUT the | (=, ~) + parsers + * - After "{} | ": Show ONLY parsers, NO line filters + */ +function completePipeFunctions(afterPipe: boolean, hasSpace: boolean, afterExclamation: boolean): Completion[] { + const completions: Completion[] = []; + + // Line filter operators that START with pipe: |= and |~ + const pipeLineFilters = [ + { operator: '|=', detail: 'Line contains' }, + { operator: '|~', detail: 'Line matches regex' }, + ]; + + // Line filter operators that DON'T start with pipe: != and !~ + const nonPipeLineFilters = [ + { operator: '!=', detail: 'Line does not contain' }, + { operator: '!~', detail: 'Line does not match regex' }, + ]; + + // Context: After "{} !" - Show ONLY != and !~ + if (afterExclamation) { + nonPipeLineFilters.forEach(({ operator, detail }) => { + completions.push(createLineFilterCompletion(operator, detail)); + }); + return completions; // Don't show parsers after ! + } + + // Context 1: After "{} | " (pipe + space) - ONLY parsers, NO line filters + if (afterPipe && hasSpace) { + // Don't show any line filters + } + // Context 2: After "{} |" (pipe, no space) - Show = and ~ (without pipe prefix) + else if (afterPipe && !hasSpace) { + pipeLineFilters.forEach(({ operator, detail }) => { + // Strip the | since user already typed it + const strippedOp = operator.replace('|', ''); + completions.push(createLineFilterCompletion(strippedOp, detail)); + }); + } + // Context 3: After "{} " (no pipe) - Show ALL line filters with full syntax + else { + [...pipeLineFilters, ...nonPipeLineFilters].forEach(({ operator, detail }) => { + completions.push(createLineFilterCompletion(operator, detail)); + }); + } + + // Parser functions and pipe operations: always show + // Add pipe prefix when not after pipe (e.g., "{} " needs "| json" not " json") + const parserPrefix = !afterPipe ? '| ' : hasSpace ? '' : ' '; + + // Parsing expressions: Extract structured data + const parsingExpressions = ['json', 'logfmt', 'pattern', 'regexp', 'unpack', 'unwrap']; + parsingExpressions.forEach((parser) => { + completions.push({ + label: `${parserPrefix}${parser}`, + type: 'function', + boost: 5, + }); + }); + + // Formatting and labels expressions: Format output and manipulate labels + const formattingAndLabels = ['line_format', 'label_format', 'decolorize', 'drop', 'keep']; + formattingAndLabels.forEach((expr) => { + completions.push({ + label: `${parserPrefix}${expr}`, + type: 'method', + }); + }); + + return completions; } async function completeLabelName(completionCfg: CompletionConfig): Promise { @@ -202,6 +376,51 @@ async function completeLabelName(completionCfg: CompletionConfig): Promise { + if (!completionCfg.client) { + return []; + } + + const start = completionCfg.timeRange?.start + ? toUnixSeconds(new Date(completionCfg.timeRange.start).getTime()) + : undefined; + const end = completionCfg.timeRange?.end ? toUnixSeconds(new Date(completionCfg.timeRange.end).getTime()) : undefined; + + try { + const response = await completionCfg.client.labelValues(label, start, end); + if (response.status === 'success') { + return response.data.map((value: string) => ({ + label: value ?? '', + displayLabel: value ?? '(empty string)', + apply: applyQuotedCompletion, + })); + } + return []; + } catch (error) { + console.error(`Error fetching values for label ${label}:`, error); + return []; + } +} + +/** + * Create a line filter completion with consistent cursor positioning. + */ +function createLineFilterCompletion(operator: string, detail: string): Completion { + return { + label: `${operator} ""`, + detail, + apply: (view, _completion, from, to) => { + const insert = `${operator} ""`; + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + insert.length - 1 }, + }); + }, + type: 'text', + boost: 10, + }; +} + function escapeString(input: string, quoteChar: string) { // do not escape raw strings (when using backticks) if (quoteChar === '`') { @@ -242,29 +461,3 @@ export function applyQuotedCompletion(view: EditorView, completion: Completion, const insertText = `${quoteChar}${escapeString(completion.label, quoteChar)}${quoteChar}`; view.dispatch(insertCompletionText(view.state, insertText, from, to)); } - -async function completeLabelValue(completionCfg: CompletionConfig, label: string): Promise { - if (!completionCfg.client) { - return []; - } - - const start = completionCfg.timeRange?.start - ? toUnixSeconds(new Date(completionCfg.timeRange.start).getTime()) - : undefined; - const end = completionCfg.timeRange?.end ? toUnixSeconds(new Date(completionCfg.timeRange.end).getTime()) : undefined; - - try { - const response = await completionCfg.client.labelValues(label, start, end); - if (response.status === 'success') { - return response.data.map((value: string) => ({ - label: value ?? '', - displayLabel: value ?? '(empty string)', - apply: applyQuotedCompletion, - })); - } - return []; - } catch (error) { - console.error(`Error fetching values for label ${label}:`, error); - return []; - } -}