Tempo: Improve autocompletion and syntax highlighting for TraceQL tab (#73707)

This commit is contained in:
Fabrizio 2023-09-07 12:36:23 +02:00 committed by GitHub
parent 94c9bee181
commit 96facbdfa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 619 additions and 133 deletions

View File

@ -242,7 +242,7 @@
"@grafana/faro-web-sdk": "1.1.2",
"@grafana/google-sdk": "0.1.1",
"@grafana/lezer-logql": "0.1.11",
"@grafana/lezer-traceql": "0.0.4",
"@grafana/lezer-traceql": "0.0.5",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^0.29.0",

View File

@ -9,6 +9,8 @@ import { Scope, TempoJsonData } from '../types';
import { CompletionProvider } from './autocomplete';
import { intrinsics, scopes } from './traceql';
const emptyPosition = {} as monacoTypes.Position;
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
}));
@ -16,10 +18,7 @@ jest.mock('@grafana/runtime', () => ({
describe('CompletionProvider', () => {
it('suggests tags, intrinsics and scopes (API v1)', async () => {
const { provider, model } = setup('{}', 1, v1Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })),
@ -31,10 +30,7 @@ describe('CompletionProvider', () => {
it('suggests tags, intrinsics and scopes (API v2)', async () => {
const { provider, model } = setup('{}', 1, undefined, v2Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })),
@ -60,10 +56,7 @@ describe('CompletionProvider', () => {
})
);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
]);
@ -85,10 +78,7 @@ describe('CompletionProvider', () => {
})
);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: '"foobar"' }),
]);
@ -109,10 +99,7 @@ describe('CompletionProvider', () => {
})
);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
]);
@ -120,19 +107,13 @@ describe('CompletionProvider', () => {
it('suggests nothing without tags', async () => {
const { provider, model } = setup('{.foo="}', 8, emptyTags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it('suggests tags on empty input (API v1)', async () => {
const { provider, model } = setup('', 0, v1Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })),
@ -144,10 +125,7 @@ describe('CompletionProvider', () => {
it('suggests tags on empty input (API v2)', async () => {
const { provider, model } = setup('', 0, undefined, v2Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })),
@ -159,10 +137,7 @@ describe('CompletionProvider', () => {
it('only suggests tags after typing the global attribute scope (API v1)', async () => {
const { provider, model } = setup('{.}', 2, v1Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s }))
);
@ -170,34 +145,15 @@ describe('CompletionProvider', () => {
it('only suggests tags after typing the global attribute scope (API v2)', async () => {
const { provider, model } = setup('{.}', 2, undefined, v2Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['cluster', 'container', 'db'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests operators after a space after the tag name', async () => {
const { provider, model } = setup('{ .foo }', 7, v1Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((s) =>
expect.objectContaining({ label: s, insertText: s })
)
);
});
it('suggests tags after a scope (API v1)', async () => {
const { provider, model } = setup('{ resource. }', 11, v1Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s }))
);
@ -205,10 +161,7 @@ describe('CompletionProvider', () => {
it('suggests correct tags after the resource scope (API v2)', async () => {
const { provider, model } = setup('{ resource. }', 11, undefined, v2Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['cluster', 'container'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
@ -216,10 +169,7 @@ describe('CompletionProvider', () => {
it('suggests correct tags after the span scope (API v2)', async () => {
const { provider, model } = setup('{ span. }', 7, undefined, v2Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['db'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
@ -227,16 +177,149 @@ describe('CompletionProvider', () => {
it('suggests logical operators and close bracket after the value', async () => {
const { provider, model } = setup('{.foo=300 }', 10, v1Tags);
const result = await provider.provideCompletionItems(
model as unknown as monacoTypes.editor.ITextModel,
{} as monacoTypes.Position
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((s) =>
expect.objectContaining({ label: s, insertText: s })
[...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps, ...CompletionProvider.comparisonOps].map(
(s) => expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
});
it('suggests spanset combining operators after spanset selector', async () => {
const { provider, model } = setup('{.foo=300} ', 11);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.spansetOps.map((s) => expect.objectContaining({ label: s.label, insertText: s.insertText }))
);
});
it.each([
['{.foo=300} | ', 13],
['{.foo=300} && {.bar=200} | ', 27],
['{.foo=300} && {.bar=300} && {.foo=300} | ', 41],
])(
'suggests operators that go after `|` (aggregators, selectorts, ...) - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.functions.map((s) =>
expect.objectContaining({ label: s.label, insertText: s.insertText, documentation: s.documentation })
)
);
}
);
it.each([
['{.foo=300} | avg(.value) ', 25],
['{.foo=300} && {.foo=300} | avg(.value) ', 39],
])(
'suggests comparison operators after aggregator (avg, max, ...) - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.comparisonOps.map((s) =>
expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
}
);
it.each([
['{.foo=300} | avg(.value) = ', 27],
['{.foo=300} && {.foo=300} | avg(.value) = ', 41],
])('does not suggest after aggregator and comparison operator - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it('suggests when `}` missing', async () => {
const { provider, model } = setup('{ span.http.status_code ', 24);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((s) =>
expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
});
it.each([
['{ .foo }', 6],
['{ .foo }', 7],
['{.foo 300}', 5],
['{.foo 300}', 6],
['{.foo 300}', 7],
['{.foo 300}', 8],
['{.foo 300 && .bar = 200}', 5],
['{.foo 300 && .bar = 200}', 6],
['{.foo 300 && .bar = 200}', 7],
['{.foo 300 && .bar 200}', 18],
['{.foo 300 && .bar 200}', 19],
['{.foo 300 && .bar 200}', 20],
['{ .foo = 1 && .bar }', 18],
['{ .foo = 1 && .bar }', 19],
['{ .foo = 1 && .bar }', 19],
])('suggests with incomplete spanset - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps].map(
(s) => expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
});
it.each([
['{.foo=1} {.bar=2}', 8],
['{.foo=1} {.bar=2}', 9],
['{.foo=1} {.bar=2}', 10],
])(
'suggests spanset combining operators in an incomplete, multi-spanset query - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.spansetOps.map((completionItem) =>
expect.objectContaining({
detail: completionItem.detail,
documentation: completionItem.documentation,
insertText: completionItem.insertText,
label: completionItem.label,
})
)
);
}
);
it.each([
// After spanset
['{ span.http.status_code = 200 && }', 33],
['{ span.http.status_code = 200 || }', 33],
['{ span.http.status_code = 200 && }', 34],
['{ span.http.status_code = 200 || }', 34],
['{ span.http.status_code = 200 && }', 35],
['{ span.http.status_code = 200 || }', 35],
['{ .foo = 200 } && ', 18],
['{ .foo = 200 } && ', 19],
['{ .foo = 200 } || ', 18],
['{ .foo = 200 } >> ', 18],
// Between spansets
['{ .foo = 1 } && { .bar = 2 }', 16],
// Inside `()`
['{.foo=1} | avg()', 15],
['{.foo=1} | avg() < 1s', 15],
['{.foo=1} | max() = 3', 15],
['{.foo=1} | by()', 14],
['{.foo=1} | select()', 18],
])('suggests attributes - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...scopes, ...intrinsics].map((s) => expect.objectContaining({ label: s }))
);
});
});
function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[]) {
@ -268,7 +351,7 @@ function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[
},
} as any;
return { provider, model };
return { provider, model } as unknown as { provider: CompletionProvider; model: monacoTypes.editor.ITextModel };
}
function makeModel(value: string, offset: number) {

View File

@ -1,3 +1,5 @@
import { IMarkdownString } from 'monaco-editor';
import { SelectableValue } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import type { Monaco, monacoTypes } from '@grafana/ui';
@ -14,9 +16,19 @@ interface Props {
languageProvider: TempoLanguageProvider;
}
type MinimalCompletionItem = {
label: string;
insertText: string;
detail?: string;
documentation?: string | IMarkdownString;
};
/**
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
* autocomplete system.
*
* Here we want to provide suggestions for TraceQL. Please refer to
* https://grafana.com/docs/tempo/latest/traceql for the syntax of the language.
*/
export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider {
languageProvider: TempoLanguageProvider;
@ -28,8 +40,171 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
}
triggerCharacters = ['{', '.', '[', '(', '=', '~', ' ', '"'];
static readonly operators: string[] = ['=', '-', '+', '<', '>', '>=', '<=', '=~'];
static readonly logicalOps: string[] = ['&&', '||'];
// Operators
static readonly arithmeticOps: MinimalCompletionItem[] = [
{
label: '+',
insertText: '+',
detail: 'Plus',
},
{
label: '-',
insertText: '-',
detail: 'Minus',
},
{
label: '*',
insertText: '*',
detail: 'Times',
},
{
label: '/',
insertText: '/',
detail: 'Over',
},
];
static readonly logicalOps: MinimalCompletionItem[] = [
{
label: '&&',
insertText: '&&',
detail: 'And',
documentation: 'And (intersection) operator. Checks that both conditions found matches.',
},
{
label: '||',
insertText: '||',
detail: 'Or',
documentation: 'Or (union) operator. Checks that either condition found matches.',
},
];
static readonly comparisonOps: MinimalCompletionItem[] = [
{
label: '=',
insertText: '=',
detail: 'Equality',
},
{
label: '!=',
insertText: '!=',
detail: 'Inequality',
},
{
label: '>',
insertText: '>',
detail: 'Greater than',
},
{
label: '>=',
insertText: '>=',
detail: 'Greater than or equal to',
},
{
label: '<',
insertText: '<',
detail: 'Less than',
},
{
label: '<=',
insertText: '<=',
detail: 'Less than or equal to',
},
{
label: '=~',
insertText: '=~',
detail: 'Regular expression',
},
{
label: '!~',
insertText: '!~',
detail: 'Negated regular expression',
},
];
static readonly structuralOps: MinimalCompletionItem[] = [
{
label: '>>',
insertText: '>>',
detail: 'Descendant',
documentation:
'Descendant operator. Looks for spans matching {condB} that are descendants of a span matching {condA}',
},
{
label: '>',
insertText: '>',
detail: 'Child',
documentation:
'Child operator. Looks for spans matching {condB} that are direct child spans of a parent matching {condA}',
},
{
label: '~',
insertText: '~',
detail: 'Sibling',
documentation:
'Sibling operator. Checks that spans matching {condA} and {condB} are siblings of the same parent span.',
},
];
static readonly spansetOps: MinimalCompletionItem[] = [
{
label: '|',
insertText: '|',
detail: 'Pipe',
},
...CompletionProvider.logicalOps,
...CompletionProvider.structuralOps,
];
// Functions (aggregator, selector, and combining operators)
static readonly spansetAggregatorOps: MinimalCompletionItem[] = [
{
label: 'count',
insertText: 'count()$0',
detail: 'Number of spans',
documentation: 'Counts the number of spans in a spanset',
},
{
label: 'avg',
insertText: 'avg($0)',
detail: 'Average of attribute',
documentation: 'Computes the average of a given numeric attribute or intrinsic for a spanset.',
},
{
label: 'max',
insertText: 'max($0)',
detail: 'Max value of attribute',
documentation: 'Computes the maximum value of a given numeric attribute or intrinsic for a spanset.',
},
{
label: 'min',
insertText: 'min($0)',
detail: 'Min value of attribute',
documentation: 'Computes the minimum value of a given numeric attribute or intrinsic for a spanset.',
},
{
label: 'sum',
insertText: 'sum($0)',
detail: 'Sum value of attribute',
documentation: 'Computes the sum value of a given numeric attribute or intrinsic for a spanset.',
},
];
static readonly functions: MinimalCompletionItem[] = [
...this.spansetAggregatorOps,
{
label: 'by',
insertText: 'by($0)',
detail: 'Grouping of attributes',
documentation: 'Groups by arbitrary attributes.',
},
{
label: 'select',
insertText: 'select($0)',
detail: 'Selection of fields',
documentation: 'Selects arbitrary fields from spans.',
},
];
// We set these directly and ae required for the provider to function.
monaco: Monaco | undefined;
@ -66,6 +241,9 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
label: item.label,
insertText: item.insertText,
insertTextRules: item.insertTextRules,
detail: item.detail,
documentation: item.documentation,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
command: {
@ -107,7 +285,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
*/
private async getCompletions(situation: Situation): Promise<Completion[]> {
switch (situation.type) {
// Not really sure what would make sense to suggest in this case so just leave it
// This should only happen for cases that we do not support yet
case 'UNKNOWN': {
return [];
}
@ -121,14 +299,52 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
case 'SPANSET_ONLY_DOT': {
return this.getTagsCompletions();
}
case 'SPANSET_IN_THE_MIDDLE':
return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE':
return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_IN_NAME':
return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions());
case 'SPANSET_IN_NAME_SCOPE':
return this.getTagsCompletions(undefined, situation.scope);
case 'SPANSET_EXPRESSION_OPERATORS':
return [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((key) => ({
label: key,
insertText: key,
return [
...CompletionProvider.comparisonOps,
...CompletionProvider.logicalOps,
...CompletionProvider.arithmeticOps,
].map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANFIELD_COMBINING_OPERATORS':
return [
...CompletionProvider.logicalOps,
...CompletionProvider.arithmeticOps,
...CompletionProvider.comparisonOps,
].map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_COMBINING_OPERATORS':
return CompletionProvider.spansetOps.map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_PIPELINE_AFTER_OPERATOR':
return CompletionProvider.functions.map((key) => ({
...key,
insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet,
type: 'FUNCTION',
}));
case 'SPANSET_COMPARISON_OPERATORS':
return CompletionProvider.comparisonOps.map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_IN_VALUE':
@ -163,11 +379,17 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
});
return items;
case 'SPANSET_AFTER_VALUE':
return CompletionProvider.logicalOps.concat('}').map((key) => ({
label: key,
insertText: key,
return CompletionProvider.logicalOps.map((key) => ({
label: key.label,
insertText: key.insertText + '}',
type: 'OPERATOR',
}));
case 'NEW_SPANSET':
return this.getScopesCompletions('{ ', '$0 }')
.concat(this.getIntrinsicsCompletions('{ ', '$0 }'))
.concat(this.getTagsCompletions('.'));
case 'ATTRIBUTE_FOR_FUNCTION':
return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions('.'));
default:
throw new Error(`Unexpected situation ${situation}`);
}
@ -184,19 +406,21 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
}));
}
private getIntrinsicsCompletions(prepend?: string): Completion[] {
private getIntrinsicsCompletions(prepend?: string, append?: string): Completion[] {
return intrinsics.map((key) => ({
label: key,
insertText: (prepend || '') + key,
insertText: (prepend || '') + key + (append || ''),
type: 'KEYWORD',
insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet,
}));
}
private getScopesCompletions(prepend?: string): Completion[] {
private getScopesCompletions(prepend?: string, append?: string): Completion[] {
return scopes.map((key) => ({
label: key,
insertText: (prepend || '') + key,
insertText: (prepend || '') + key + (append || ''),
type: 'SCOPE',
insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet,
}));
}
}
@ -218,16 +442,21 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona
return monaco.languages.CompletionItemKind.EnumMember;
case 'SCOPE':
return monaco.languages.CompletionItemKind.Class;
case 'FUNCTION':
return monaco.languages.CompletionItemKind.Function;
default:
throw new Error(`Unexpected CompletionType: ${type}`);
}
}
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE';
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE' | 'FUNCTION';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
insertTextRules?: monacoTypes.languages.CompletionItemInsertTextRule; // we used it to position the cursor
documentation?: string | IMarkdownString;
detail?: string;
};
export type Tag = {

View File

@ -23,9 +23,9 @@ describe('situation', () => {
expected: { type: 'SPANSET_ONLY_DOT' },
},
{
query: '{foo}',
cursorPos: 4,
expected: { type: 'SPANSET_IN_NAME' },
query: '{.foo}',
cursorPos: 5,
expected: { type: 'SPANSET_EXPRESSION_OPERATORS' },
},
{
query: '{span.}',
@ -45,7 +45,12 @@ describe('situation', () => {
{
query: '{span.foo = "val" }',
cursorPos: 18,
expected: { type: 'SPANSET_EXPRESSION_OPERATORS' },
expected: { type: 'SPANFIELD_COMBINING_OPERATORS' },
},
{
query: '{span.foo = 200 }',
cursorPos: 15,
expected: { type: 'SPANFIELD_COMBINING_OPERATORS' },
},
{
query: '{span.foo = "val" && }',
@ -57,6 +62,11 @@ describe('situation', () => {
cursorPos: 30,
expected: { type: 'SPANSET_IN_NAME_SCOPE', scope: 'resource' },
},
{
query: '{ .sla && span.http.status_code && span.http.status_code = 200 }',
cursorPos: 57,
expected: { type: 'SPANSET_EXPRESSION_OPERATORS' },
},
];
tests.forEach((test) => {

View File

@ -1,6 +1,24 @@
import { SyntaxNode, Tree } from '@lezer/common';
import { AttributeField, FieldExpression, FieldOp, parser, SpansetFilter } from '@grafana/lezer-traceql';
import {
Aggregate,
And,
AttributeField,
ComparisonOp,
FieldExpression,
FieldOp,
GroupOperation,
IntrinsicField,
Or,
parser,
ScalarFilter,
SelectArgs,
SpansetFilter,
SpansetPipeline,
SpansetPipelineExpression,
Static,
TraceQL,
} from '@grafana/lezer-traceql';
type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling' | 'prevSibling';
type NodeType = number;
@ -23,6 +41,9 @@ export type SituationType =
| {
type: 'SPANSET_EXPRESSION_OPERATORS';
}
| {
type: 'SPANFIELD_COMBINING_OPERATORS';
}
| {
type: 'SPANSET_IN_NAME';
}
@ -37,6 +58,27 @@ export type SituationType =
}
| {
type: 'SPANSET_AFTER_VALUE';
}
| {
type: 'SPANSET_COMBINING_OPERATORS';
}
| {
type: 'SPANSET_PIPELINE_AFTER_OPERATOR';
}
| {
type: 'SPANSET_IN_THE_MIDDLE';
}
| {
type: 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE';
}
| {
type: 'NEW_SPANSET';
}
| {
type: 'ATTRIBUTE_FOR_FUNCTION';
}
| {
type: 'SPANSET_COMPARISON_OPERATORS';
};
type Path = Array<[Direction, NodeType[]]>;
@ -65,13 +107,15 @@ function move(node: SyntaxNode, direction: Direction): SyntaxNode | null {
function walk(node: SyntaxNode, path: Path): SyntaxNode | null {
let current: SyntaxNode | null = node;
for (const [direction, expectedNodes] of path) {
for (const [direction, expectedNodeIDs] of path) {
current = move(current, direction);
if (current === null) {
// we could not move in the direction, we stop
return null;
}
if (!expectedNodes.find((en) => en === current?.type.id)) {
// note that the found value can be 0, which is acceptable
if (expectedNodeIDs.find((id) => id === current?.type.id) === undefined) {
// the reached node has wrong type, we stop
return null;
}
@ -90,8 +134,8 @@ function isPathMatch(resolverPath: NodeType[], cursorPath: number[]): boolean {
/**
* Figure out where is the cursor and what kind of suggestions are appropriate.
* @param text
* @param offset
* @param text the user input
* @param offset the position of the cursor (starting from 0) in the user input
*/
export function getSituation(text: string, offset: number): Situation | null {
// there is a special case when we are at the start of writing text,
@ -105,21 +149,29 @@ export function getSituation(text: string, offset: number): Situation | null {
const tree = parser.parse(text);
// Whitespaces (especially when multiple) on the left of the text cursor can trick the Lezer parser,
// causing a wrong tree cursor to be picked.
// Example: `{ span.foo = ↓ }`, with `↓` being the cursor, tricks the parser.
// Quick and dirty hack: Shift the cursor to the left until we find a non-whitespace character on its left.
let shiftedOffset = offset;
while (shiftedOffset - 1 >= 0 && text[shiftedOffset - 1] === ' ') {
shiftedOffset -= 1;
}
// 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
let maybeErrorNode = getErrorNode(tree, offset);
let maybeErrorNode = getErrorNode(tree, shiftedOffset);
if (!maybeErrorNode) {
// try again with the previous character
maybeErrorNode = getErrorNode(tree, offset - 1);
maybeErrorNode = getErrorNode(tree, shiftedOffset - 1);
}
const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(offset);
const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(shiftedOffset);
const currentNode = cur.node;
const ids = [cur.type.id];
while (cur.parent()) {
ids.push(cur.type.id);
@ -128,7 +180,7 @@ export function getSituation(text: string, offset: number): Situation | null {
let situationType: SituationType | null = null;
for (let resolver of RESOLVERS) {
if (isPathMatch(resolver.path, ids)) {
situationType = resolver.fun(currentNode, text, offset);
situationType = resolver.fun(currentNode, text, shiftedOffset);
}
}
@ -138,6 +190,7 @@ export function getSituation(text: string, offset: number): Situation | null {
const ERROR_NODE_ID = 0;
const RESOLVERS: Resolver[] = [
// Incomplete query cases
{
path: [ERROR_NODE_ID, AttributeField],
fun: resolveAttribute,
@ -148,15 +201,85 @@ const RESOLVERS: Resolver[] = [
},
{
path: [ERROR_NODE_ID, SpansetFilter],
fun: resolveErrorInFilterRoot,
fun: () => ({
type: 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE',
}),
},
{
path: [ERROR_NODE_ID, SpansetPipeline],
fun: resolveSpansetPipeline,
},
{
path: [ERROR_NODE_ID, Aggregate],
fun: resolveAttributeForFunction,
},
{
path: [ERROR_NODE_ID, IntrinsicField],
fun: resolveAttributeForFunction,
},
{
path: [ERROR_NODE_ID, SpansetPipelineExpression],
fun: () => {
return {
type: 'NEW_SPANSET',
};
},
},
{
path: [ERROR_NODE_ID, ScalarFilter, SpansetPipeline],
fun: resolveArithmeticOperator,
},
{
path: [ERROR_NODE_ID, TraceQL],
fun: () => {
return {
type: 'UNKNOWN',
};
},
},
// Valid query cases
{
path: [FieldExpression],
fun: () => ({
type: 'SPANSET_EXPRESSION_OPERATORS',
}),
},
{
path: [SpansetFilter],
fun: resolveSpanset,
},
{
path: [SpansetPipelineExpression],
fun: resolveNewSpansetExpression,
},
{
path: [TraceQL],
fun: resolveNewSpansetExpression,
},
];
function resolveSpanset(node: SyntaxNode): SituationType {
const firstChild = walk(node, [
['firstChild', [FieldExpression]],
['firstChild', [AttributeField]],
]);
if (firstChild) {
return {
type: 'SPANSET_EXPRESSION_OPERATORS',
};
}
const lastFieldExpression1 = walk(node, [
['lastChild', [FieldExpression]],
['lastChild', [FieldExpression]],
['lastChild', [Static]],
]);
if (lastFieldExpression1) {
return {
type: 'SPANFIELD_COMBINING_OPERATORS',
};
}
const lastFieldExpression = walk(node, [['lastChild', [FieldExpression]]]);
if (lastFieldExpression) {
return {
@ -204,13 +327,67 @@ function resolveExpression(node: SyntaxNode, text: string): SituationType {
};
}
}
if (node.prevSibling?.type.name === 'And' || node.prevSibling?.type.name === 'Or') {
return {
type: 'SPANSET_EMPTY',
};
}
return {
type: 'SPANSET_EMPTY',
type: 'SPANSET_IN_THE_MIDDLE',
};
}
function resolveErrorInFilterRoot(): SituationType {
function resolveArithmeticOperator(node: SyntaxNode, _0: string, _1: number): SituationType {
if (node.prevSibling?.type.id === ComparisonOp) {
return {
type: 'UNKNOWN',
};
}
return {
type: 'SPANSET_IN_NAME',
type: 'SPANSET_COMPARISON_OPERATORS',
};
}
function resolveNewSpansetExpression(node: SyntaxNode, text: string, offset: number): SituationType {
// Select the node immediately before the one pointed by the cursor
let previousNode = node.firstChild;
try {
previousNode = node.firstChild;
while (previousNode!.to < offset) {
previousNode = previousNode!.nextSibling;
}
} catch (error) {
console.error('Unexpected error while searching for previous node', error);
}
if (previousNode?.type.id === And || previousNode?.type.id === Or) {
return {
type: 'NEW_SPANSET',
};
}
return {
type: 'SPANSET_COMBINING_OPERATORS',
};
}
function resolveAttributeForFunction(node: SyntaxNode, _0: string, _1: number): SituationType {
const parent = node?.parent;
if (!!parent && [IntrinsicField, Aggregate, GroupOperation, SelectArgs].includes(parent.type.id)) {
return {
type: 'ATTRIBUTE_FOR_FUNCTION',
};
}
return {
type: 'UNKNOWN',
};
}
function resolveSpansetPipeline(_0: SyntaxNode, _1: string, _2: number): SituationType {
return {
type: 'SPANSET_PIPELINE_AFTER_OPERATOR',
};
}

View File

@ -27,9 +27,10 @@ export const stringOperators = ['=', '!=', '=~'];
export const numberOperators = ['=', '!=', '>', '<', '>=', '<='];
export const intrinsics = ['duration', 'kind', 'name', 'status'];
export const scopes: string[] = ['resource', 'span'];
export const functions = ['avg', 'min', 'max', 'sum', 'count', 'by'];
const keywords = intrinsics.concat(scopes);
const statusValues = ['ok', 'unset', 'error', 'false', 'true'];
@ -42,8 +43,8 @@ export const language = {
keywords,
operators,
statusValues,
functions,
// we include these common regular expressions
symbols: /[=><!~?:&|+\-*\/^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
digits: /\d+(_+\d+)*/,
@ -52,24 +53,21 @@ export const language = {
tokenizer: {
root: [
// labels
[/[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/, 'tag'],
[/[a-z_.][\w./_-]*/, 'tag'],
// durations
[/[0-9.]+(s|ms|ns|m)/, 'number'],
[/[0-9]+(.[0-9]+)?(us|µs|ns|ms|s|m|h)/, 'number'],
// trace ID
[/^\s*[0-9A-Fa-f]+\s*$/, 'tag'],
// all keywords have the same color
// functions, keywords, predefined values
[
/[a-zA-Z_.]\w*/,
{
cases: {
'@keywords': 'type',
'@statusValues': 'type.identifier',
'@default': 'identifier',
'@functions': 'predefined',
'@keywords': 'keyword',
'@statusValues': 'type',
'@default': 'tag',
},
},
],
@ -80,12 +78,8 @@ export const language = {
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
// whitespace
{ include: '@whitespace' },
// delimiters and operators
[/[{}()\[\]]/, '@brackets'],
[/[<>](?!@symbols)/, '@brackets'],
[/[{}()\[\]]/, 'delimiter.bracket'],
[
/@symbols/,
{
@ -118,13 +112,6 @@ export const language = {
[/\\./, 'string.escape.invalid'],
[/'/, 'string', '@pop'],
],
clauses: [
[/[^(,)]/, 'tag'],
[/\)/, 'identifier', '@pop'],
],
whitespace: [[/[ \t\r\n]+/, 'white']],
},
};

View File

@ -3877,12 +3877,12 @@ __metadata:
languageName: node
linkType: hard
"@grafana/lezer-traceql@npm:0.0.4":
version: 0.0.4
resolution: "@grafana/lezer-traceql@npm:0.0.4"
"@grafana/lezer-traceql@npm:0.0.5":
version: 0.0.5
resolution: "@grafana/lezer-traceql@npm:0.0.5"
peerDependencies:
"@lezer/lr": ^1.3.0
checksum: 69acea33476d3cdabfb99f3eb62bb34289bc205da69920e0eccc82f40407f9d584fa03f9662706ab667ee97d3ea84b964b22a11925e6699deef5afe1ca9e1906
checksum: 6fcf48acde1e444c155a4b4009f4c7211843b07960713821a2649b1db0e0ef819fd1062eec101173c2e6b8249b253faf0b6052e96f551663618fd2fe0d17e3c9
languageName: node
linkType: hard
@ -19226,7 +19226,7 @@ __metadata:
"@grafana/faro-web-sdk": 1.1.2
"@grafana/google-sdk": 0.1.1
"@grafana/lezer-logql": 0.1.11
"@grafana/lezer-traceql": 0.0.4
"@grafana/lezer-traceql": 0.0.5
"@grafana/monaco-logql": ^0.0.7
"@grafana/runtime": "workspace:*"
"@grafana/scenes": ^0.29.0