Skip to content

Commit 5662acd

Browse files
authored
[ENHANCEMENT] Loki: Improve autocompletion (perses#526)
* enhancement(loki): Improve autocompletion beyond log stream selector Previously, autocompletion only worked within log stream selectors. LogQL supports additional pipeline expressions after the stream selector, including line filters (|=, !=, |~, !~) and parser functions (json, logfmt, pattern, regexp, unpack, unwrap). This commit adds context-aware completions for: - Line filter operators after stream selectors - Parser and formatting functions The completion detection is split into detectLabelCompletion() and detectPipeCompletion(). ERROR_NODE cases are handled to provide completions even when syntax is incomplete or malformed. Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com> * enhancement(loki): Add tests for loki completions Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com> --------- Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com>
1 parent 6896694 commit 5662acd

2 files changed

Lines changed: 617 additions & 59 deletions

File tree

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { EditorState, EditorView } from '@uiw/react-codemirror';
15+
import { parser } from '@grafana/lezer-logql';
16+
import { LRLanguage, ensureSyntaxTree } from '@codemirror/language';
17+
import { identifyCompletion, applyQuotedCompletion } from './complete';
18+
19+
const logQLExtension = LRLanguage.define({ parser: parser });
20+
21+
describe('complete', () => {
22+
describe('identifyCompletion', () => {
23+
it.each([
24+
// Empty query
25+
{
26+
expr: '',
27+
expected: undefined,
28+
},
29+
30+
// Label name completions - Selector
31+
{
32+
expr: '{',
33+
expected: { scope: { kind: 'LabelName' }, from: 1 },
34+
},
35+
{
36+
expr: '{ ',
37+
expected: { scope: { kind: 'LabelName' }, from: 2 },
38+
},
39+
{
40+
expr: '{}',
41+
pos: 1,
42+
expected: { scope: { kind: 'LabelName' }, from: 1 },
43+
},
44+
// After closing brace - parser treats this as still in selector context
45+
{
46+
expr: '{}',
47+
expected: { scope: { kind: 'LabelName' }, from: 1 },
48+
},
49+
50+
// Label name completions - after comma
51+
{
52+
expr: '{foo="bar",',
53+
expected: { scope: { kind: 'LabelName' }, from: 11 },
54+
},
55+
{
56+
expr: '{foo="bar", ',
57+
expected: { scope: { kind: 'LabelName' }, from: 12 },
58+
},
59+
60+
// Label name completions - partial identifier
61+
{
62+
expr: '{f',
63+
expected: { scope: { kind: 'LabelName' }, from: 1 },
64+
},
65+
{
66+
expr: '{fo',
67+
expected: { scope: { kind: 'LabelName' }, from: 1 },
68+
},
69+
{
70+
expr: '{foo="bar", e',
71+
expected: { scope: { kind: 'LabelName' }, from: 12 },
72+
},
73+
74+
// Label name completions - after complete matcher
75+
// Note: Without closing brace, parser doesn't detect this as completed matcher
76+
{
77+
expr: '{foo="bar" ',
78+
expected: undefined,
79+
},
80+
81+
// Label value completions - after operator
82+
{
83+
expr: '{foo=',
84+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 5 },
85+
},
86+
{
87+
expr: '{foo!=',
88+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
89+
},
90+
{
91+
expr: '{foo=~',
92+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
93+
},
94+
{
95+
expr: '{foo!~',
96+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
97+
},
98+
99+
// Label value completions - partial unquoted value
100+
// Note: Parser may not create ERROR_NODE for simple identifiers
101+
{
102+
expr: '{foo=ba',
103+
expected: undefined,
104+
},
105+
106+
// Label value completions - inside quotes
107+
{
108+
expr: '{foo="',
109+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
110+
},
111+
// Note: Incomplete string without closing quote may not trigger completion
112+
{
113+
expr: '{foo="ba',
114+
expected: undefined,
115+
},
116+
{
117+
expr: '{foo=""',
118+
pos: 6,
119+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
120+
},
121+
{
122+
expr: '{foo=""',
123+
expected: undefined,
124+
},
125+
126+
// Label value completions - with backticks
127+
{
128+
expr: '{foo=`',
129+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
130+
},
131+
132+
// Label value completions - complex label names
133+
{
134+
expr: '{env="prod", app=',
135+
expected: { scope: { kind: 'LabelValue', label: 'app' }, from: 17 },
136+
},
137+
138+
// Pipe function completions - after closing brace
139+
{
140+
expr: '{foo="bar"} ',
141+
expected: {
142+
scope: { kind: 'PipeFunction', afterPipe: false, hasSpace: true, afterExclamation: false },
143+
from: 12,
144+
},
145+
},
146+
147+
// Pipe function completions - after pipe
148+
{
149+
expr: '{foo="bar"} |',
150+
expected: {
151+
scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: false, afterExclamation: false },
152+
from: 13,
153+
},
154+
},
155+
{
156+
expr: '{foo="bar"} | ',
157+
expected: {
158+
scope: { kind: 'PipeFunction', afterPipe: true, hasSpace: true, afterExclamation: false },
159+
from: 14,
160+
},
161+
},
162+
163+
// Pipe function completions - after exclamation
164+
{
165+
expr: '{foo="bar"} !',
166+
expected: {
167+
scope: { kind: 'PipeFunction', afterPipe: false, hasSpace: true, afterExclamation: true },
168+
from: 12,
169+
},
170+
},
171+
172+
// Multiple matchers
173+
{
174+
expr: '{foo="bar", env="prod"}',
175+
pos: 13,
176+
expected: { scope: { kind: 'LabelName' }, from: 12 },
177+
},
178+
179+
// Label with regex operator
180+
{
181+
expr: '{foo=~"bar.*"}',
182+
pos: 7,
183+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 7 },
184+
},
185+
186+
// After pipe with partial function - parser sees 'j' as error/label identifier
187+
{
188+
expr: '{foo="bar"} | j',
189+
expected: { scope: { kind: 'LabelName' }, from: 14 },
190+
},
191+
192+
// No completion after complete query
193+
{
194+
expr: '{foo="bar"}',
195+
expected: undefined,
196+
},
197+
198+
// Cursor in middle of value - parser sees this as inside the string
199+
{
200+
expr: '{foo="bar"}',
201+
pos: 8,
202+
expected: { scope: { kind: 'LabelValue', label: 'foo' }, from: 6 },
203+
},
204+
])('should identify completion for: $expr', ({ expr, pos, expected }) => {
205+
if (pos === undefined) pos = expr.length;
206+
if (pos < 0) pos = expr.length + pos;
207+
208+
const state = EditorState.create({ doc: expr, extensions: logQLExtension });
209+
const tree = ensureSyntaxTree(state, expr.length, 1000);
210+
expect(tree).not.toBeNull();
211+
const completion = identifyCompletion(state, pos, tree!);
212+
expect(completion).toEqual(expected);
213+
});
214+
});
215+
216+
describe('applyQuotedCompletion', () => {
217+
it.each([
218+
// Basic quote addition
219+
{
220+
doc: '{foo=',
221+
completion: 'bar',
222+
from: 5,
223+
expected: '{foo="bar"',
224+
},
225+
226+
// Quote already present - opening
227+
{
228+
doc: '{foo="',
229+
completion: 'bar',
230+
from: 6,
231+
expected: '{foo="bar"',
232+
},
233+
234+
// Quote already present - cursor before opening quote
235+
{
236+
doc: '{foo="',
237+
completion: 'bar',
238+
from: 5,
239+
expected: '{foo="bar"',
240+
},
241+
242+
// Quote already present - both quotes
243+
{
244+
doc: '{foo=""',
245+
completion: 'bar',
246+
from: 6,
247+
expected: '{foo="bar"',
248+
},
249+
250+
// Partial value replacement
251+
{
252+
doc: '{foo=ba',
253+
completion: 'bar',
254+
from: 5,
255+
to: 7,
256+
expected: '{foo="bar"',
257+
},
258+
259+
// Partial value in quotes replacement
260+
{
261+
doc: '{foo="ba"',
262+
completion: 'bar',
263+
from: 6,
264+
to: 8,
265+
expected: '{foo="bar"',
266+
},
267+
268+
// Escaping - double quotes
269+
{
270+
doc: '{foo=',
271+
completion: 'my"value',
272+
from: 5,
273+
expected: '{foo="my\\"value"',
274+
},
275+
276+
// Escaping - backslashes
277+
{
278+
doc: '{foo=',
279+
completion: 'path\\to\\file',
280+
from: 5,
281+
expected: '{foo="path\\\\to\\\\file"',
282+
},
283+
284+
// Escaping - both quotes and backslashes
285+
{
286+
doc: '{foo=',
287+
completion: 'test\\"value',
288+
from: 5,
289+
expected: '{foo="test\\\\\\"value"',
290+
},
291+
292+
// Backticks - no escaping needed
293+
{
294+
doc: '{foo=`',
295+
completion: 'bar',
296+
from: 6,
297+
expected: '{foo=`bar`',
298+
},
299+
300+
// Backticks - cursor before opening backtick
301+
{
302+
doc: '{foo=`',
303+
completion: 'bar',
304+
from: 5,
305+
expected: '{foo=`bar`',
306+
},
307+
308+
// Backticks - with quotes inside (no escaping)
309+
{
310+
doc: '{foo=`',
311+
completion: 'my"value',
312+
from: 6,
313+
expected: '{foo=`my"value`',
314+
},
315+
316+
// Backticks - with backslashes (no escaping)
317+
{
318+
doc: '{foo=`',
319+
completion: 'path\\to\\file',
320+
from: 6,
321+
expected: '{foo=`path\\to\\file`',
322+
},
323+
324+
// Value contains backtick - switch to double quotes
325+
{
326+
doc: '{foo=`',
327+
completion: 'value`with`backticks',
328+
from: 6,
329+
expected: '{foo="value`with`backticks"',
330+
},
331+
332+
// Value contains backtick - switch to double quotes and escape
333+
{
334+
doc: '{foo=`',
335+
completion: 'value`with"quotes',
336+
from: 6,
337+
expected: '{foo="value`with\\"quotes"',
338+
},
339+
340+
// Empty value
341+
{
342+
doc: '{foo=',
343+
completion: '',
344+
from: 5,
345+
expected: '{foo=""',
346+
},
347+
348+
// Value with spaces
349+
{
350+
doc: '{foo=',
351+
completion: 'bar baz',
352+
from: 5,
353+
expected: '{foo="bar baz"',
354+
},
355+
])(
356+
'should apply quoted completion: $completion at pos $from in "$doc"',
357+
({ doc, completion, from, to, expected }) => {
358+
const state = EditorState.create({ doc });
359+
const view = new EditorView({ state });
360+
applyQuotedCompletion(view, { label: completion }, from, to ?? from);
361+
expect(view.state.doc.toString()).toBe(expected);
362+
}
363+
);
364+
});
365+
});

0 commit comments

Comments
 (0)