diff --git a/package.json b/package.json index 4f7c0a3a03b..c9e0f197fa9 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "@grafana/faro-web-sdk": "1.1.2", "@grafana/google-sdk": "0.1.1", "@grafana/lezer-logql": "0.1.8", + "@grafana/lezer-traceql": "0.0.4", "@grafana/monaco-logql": "^0.0.7", "@grafana/runtime": "workspace:*", "@grafana/scenes": "0.22.0", diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts index 98b0c4c4ab0..d9e4a7aff77 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts @@ -45,7 +45,7 @@ describe('CompletionProvider', () => { }); it('does not wrap the tag value in quotes if the type in the response is something other than "string"', async () => { - const { provider, model } = setup('{foo=}', 5, v1Tags); + const { provider, model } = setup('{.foo=}', 6, v1Tags); jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => @@ -70,7 +70,7 @@ describe('CompletionProvider', () => { }); it('wraps the tag value in quotes if the type in the response is set to "string"', async () => { - const { provider, model } = setup('{foo=}', 5, v1Tags); + const { provider, model } = setup('{.foo=}', 6, v1Tags); jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => @@ -95,7 +95,7 @@ describe('CompletionProvider', () => { }); it('inserts the tag value without quotes if the user has entered quotes', async () => { - const { provider, model } = setup('{foo="}', 6, v1Tags); + const { provider, model } = setup('{.foo="}', 6, v1Tags); jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => @@ -119,7 +119,7 @@ describe('CompletionProvider', () => { }); it('suggests nothing without tags', async () => { - const { provider, model } = setup('{foo="}', 7, emptyTags); + const { provider, model } = setup('{.foo="}', 8, emptyTags); const result = await provider.provideCompletionItems( model as unknown as monacoTypes.editor.ITextModel, {} as monacoTypes.Position @@ -180,13 +180,15 @@ describe('CompletionProvider', () => { }); it('suggests operators after a space after the tag name', async () => { - const { provider, model } = setup('{ foo }', 6, v1Tags); + 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.operators.map((s) => expect.objectContaining({ label: s, insertText: s })) + [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((s) => + expect.objectContaining({ label: s, insertText: s }) + ) ); }); @@ -224,38 +226,16 @@ describe('CompletionProvider', () => { }); it('suggests logical operators and close bracket after the value', async () => { - const { provider, model } = setup('{foo=300 }', 9, v1Tags); + const { provider, model } = setup('{.foo=300 }', 10, 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.map((s) => expect.objectContaining({ label: s, insertText: s })), - expect.objectContaining({ label: '}', insertText: '}' }), - ]); - }); - - it('suggests tag values after a space inside a string', async () => { - const { provider, model } = setup('{foo="bar test " }', 15, v1Tags); - - jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( - () => - new Promise((resolve) => { - resolve([ - { - value: 'foobar', - label: 'foobar', - }, - ]); - }) + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((s) => + expect.objectContaining({ label: s, insertText: s }) + ) ); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); - expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ - expect.objectContaining({ label: 'foobar', insertText: 'foobar' }), - ]); }); }); diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts index 311130316f6..77a73a3b4bd 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts @@ -7,6 +7,7 @@ import { notifyApp } from '../../../../core/reducers/appNotification'; import { dispatch } from '../../../../store/store'; import TempoLanguageProvider from '../language_provider'; +import { getSituation, Situation } from './situation'; import { intrinsics, scopes } from './traceql'; interface Props { @@ -52,8 +53,8 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP } const { range, offset } = getRangeAndOffset(this.monaco, model, position); - const situation = this.getSituation(model.getValue(), offset); - const completionItems = this.getCompletions(situation); + const situation = getSituation(model.getValue(), offset); + const completionItems = situation != null ? this.getCompletions(situation) : Promise.resolve([]); return completionItems.then((items) => { // monaco by-default alphabetically orders the items. @@ -124,8 +125,8 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions()); case 'SPANSET_IN_NAME_SCOPE': return this.getTagsCompletions(undefined, situation.scope); - case 'SPANSET_AFTER_NAME': - return CompletionProvider.operators.map((key) => ({ + case 'SPANSET_EXPRESSION_OPERATORS': + return [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((key) => ({ label: key, insertText: key, type: 'OPERATOR', @@ -198,115 +199,6 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP type: 'SCOPE', })); } - - private getSituationInSpanSet(textUntilCaret: string): Situation { - const nameRegex = /(?[\w./-]+)?/; - const opRegex = /(?[!=+\-<>]+)/; - // only allow spaces in the value if it's enclosed by quotes - const valueRegex = /(?(?")([^"\n&|]+)?(?")?|([^"\n\s&|]+))?/; - - // prettier-ignore - const fullRegex = new RegExp( - '([\\s{])' + // Space(s) or initial opening bracket { - '(' + // Open full set group - nameRegex.source + - '(?\\s*)' + // Optional space(s) between name and operator - '(' + // Open operator + value group - opRegex.source + - '(?\\s*)' + // Optional space(s) between operator and value - valueRegex.source + - ')?' + // Close operator + value group - ')' + // Close full set group - '(?\\s*)$' // Optional space(s) at the end of the set - ); - - const matched = textUntilCaret.match(fullRegex); - - if (matched) { - const nameFull = matched.groups?.name; - const op = matched.groups?.op; - - if (!nameFull) { - return { - type: 'SPANSET_EMPTY', - }; - } - - if (nameFull === '.') { - return { - type: 'SPANSET_ONLY_DOT', - }; - } - - const nameMatched = nameFull.match(/^(?\.)?(?\w[\w./-]*\w)(?\.)?$/); - - // We already have a (potentially partial) tag name so let's check if there's an operator declared - // { .tag_name| - if (!op) { - // There's no operator so we check if the name is one of the known scopes - // { resource.| - if (scopes.filter((w) => w === nameMatched?.groups?.word) && nameMatched?.groups?.post_dot) { - return { - type: 'SPANSET_IN_NAME_SCOPE', - scope: nameMatched?.groups?.word || '', - }; - } - // It's not one of the scopes, so we now check if we're after the name (there's a space after the word) or if we still have to autocomplete the rest of the name - // In case there's a space we start autocompleting the operators { .http.method | - // Otherwise we keep showing the tags/intrinsics/scopes list { .http.met| - return { - type: matched.groups?.space1 ? 'SPANSET_AFTER_NAME' : 'SPANSET_IN_NAME', - }; - } - - // In case there's a space after the full [name + operator + value] group we can start autocompleting logical operators or close the spanset - // To avoid triggering this situation when we are writing a space inside a string we check the state of the open and close quotes - // { .http.method = "GET" | - if (matched.groups?.space3 && matched.groups.open_quote === matched.groups.close_quote) { - return { - type: 'SPANSET_AFTER_VALUE', - }; - } - - // We already have an operator and know that the set isn't complete so let's autocomplete the possible values for the tag name - // { .http.method = | - return { - type: 'SPANSET_IN_VALUE', - tagName: nameFull, - betweenQuotes: !!matched.groups?.open_quote, - }; - } - - return { - type: 'EMPTY', - }; - } - - /** - * Figure out where is the cursor and what kind of suggestions are appropriate. - * @param text - * @param offset - */ - private getSituation(text: string, offset: number): Situation { - if (text === '' || offset === 0) { - return { - type: 'EMPTY', - }; - } - - const textUntilCaret = text.substring(0, offset); - - // Check if we're inside a span set - let isInSpanSet = textUntilCaret.lastIndexOf('{') > textUntilCaret.lastIndexOf('}'); - if (isInSpanSet) { - return this.getSituationInSpanSet(textUntilCaret); - } - - // Will happen only if user writes something that isn't really a tag selector - return { - type: 'UNKNOWN', - }; - } } /** @@ -343,38 +235,6 @@ export type Tag = { value: string; }; -export type Situation = - | { - type: 'UNKNOWN'; - } - | { - type: 'EMPTY'; - } - | { - type: 'SPANSET_EMPTY'; - } - | { - type: 'SPANSET_ONLY_DOT'; - } - | { - type: 'SPANSET_AFTER_NAME'; - } - | { - type: 'SPANSET_IN_NAME'; - } - | { - type: 'SPANSET_IN_NAME_SCOPE'; - scope: string; - } - | { - type: 'SPANSET_IN_VALUE'; - tagName: string; - betweenQuotes: boolean; - } - | { - type: 'SPANSET_AFTER_VALUE'; - }; - function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) { const word = model.getWordAtPosition(position); const range = diff --git a/public/app/plugins/datasource/tempo/traceql/situation.test.ts b/public/app/plugins/datasource/tempo/traceql/situation.test.ts new file mode 100644 index 00000000000..4a0eb421b49 --- /dev/null +++ b/public/app/plugins/datasource/tempo/traceql/situation.test.ts @@ -0,0 +1,68 @@ +import { getSituation, Situation } from './situation'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), +})); + +interface SituationTest { + query: string; + cursorPos: number; + expected: Situation; +} + +describe('situation', () => { + const tests: SituationTest[] = [ + { + query: '{}', + cursorPos: 1, + expected: { type: 'SPANSET_EMPTY' }, + }, + { + query: '{.}', + cursorPos: 2, + expected: { type: 'SPANSET_ONLY_DOT' }, + }, + { + query: '{foo}', + cursorPos: 4, + expected: { type: 'SPANSET_IN_NAME' }, + }, + { + query: '{span.}', + cursorPos: 6, + expected: { type: 'SPANSET_IN_NAME_SCOPE', scope: 'span' }, + }, + { + query: '{span.foo }', + cursorPos: 10, + expected: { type: 'SPANSET_EXPRESSION_OPERATORS' }, + }, + { + query: '{span.foo = }', + cursorPos: 12, + expected: { type: 'SPANSET_IN_VALUE', tagName: 'span.foo', betweenQuotes: false }, + }, + { + query: '{span.foo = "val" }', + cursorPos: 18, + expected: { type: 'SPANSET_EXPRESSION_OPERATORS' }, + }, + { + query: '{span.foo = "val" && }', + cursorPos: 21, + expected: { type: 'SPANSET_EMPTY' }, + }, + { + query: '{span.foo = "val" && resource.}', + cursorPos: 30, + expected: { type: 'SPANSET_IN_NAME_SCOPE', scope: 'resource' }, + }, + ]; + + tests.forEach((test) => { + it(`${test.query} at ${test.cursorPos} is ${test.expected.type}`, async () => { + const sit = getSituation(test.query, test.cursorPos); + expect(sit).toEqual(test.expected); + }); + }); +}); diff --git a/public/app/plugins/datasource/tempo/traceql/situation.ts b/public/app/plugins/datasource/tempo/traceql/situation.ts new file mode 100644 index 00000000000..6e0c669012e --- /dev/null +++ b/public/app/plugins/datasource/tempo/traceql/situation.ts @@ -0,0 +1,217 @@ +// 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 +import { SyntaxNode, Tree } from '@lezer/common'; + +import { AttributeField, FieldExpression, FieldOp, parser, SpansetFilter } from '@grafana/lezer-traceql'; + +type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling' | 'prevSibling'; +type NodeType = number; +export type Situation = + | { + type: 'UNKNOWN'; + } + | { + type: 'EMPTY'; + } + | { + type: 'SPANSET_EMPTY'; + } + | { + type: 'SPANSET_ONLY_DOT'; + } + | { + type: 'SPANSET_EXPRESSION_OPERATORS'; + } + | { + type: 'SPANSET_IN_NAME'; + } + | { + type: 'SPANSET_IN_NAME_SCOPE'; + scope: string; + } + | { + type: 'SPANSET_IN_VALUE'; + tagName: string; + betweenQuotes: boolean; + } + | { + type: 'SPANSET_AFTER_VALUE'; + }; + +type Path = Array<[Direction, NodeType[]]>; + +type Resolver = { + path: NodeType[]; + fun: (node: SyntaxNode, text: string, pos: number) => Situation | null; +}; + +function getErrorNode(tree: Tree, cursorPos: number): SyntaxNode | null { + const cur = tree.cursorAt(cursorPos); + do { + if (cur.from === cursorPos || cur.to === cursorPos) { + const { node } = cur; + if (node.type.isError) { + return node; + } + } + } while (cur.next()); + return null; +} + +function move(node: SyntaxNode, direction: Direction): SyntaxNode | null { + return node[direction]; +} + +function walk(node: SyntaxNode, path: Path): SyntaxNode | null { + let current: SyntaxNode | null = node; + for (const [direction, expectedNodes] 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)) { + // the reached node has wrong type, we stop + return null; + } + } + return current; +} + +function getNodeText(node: SyntaxNode, text: string): string { + // if the from and to are them same (e.g. for an error node) we can subtract 1 from the start/from index + return text.slice(node.from === node.to ? node.from - 1 : node.from, node.to); +} + +function isPathMatch(resolverPath: NodeType[], cursorPath: number[]): boolean { + return resolverPath.every((item, index) => item === cursorPath[index]); +} + +/** + * Figure out where is the cursor and what kind of suggestions are appropriate. + * @param text + * @param offset + */ +export function getSituation(text: string, offset: number): Situation | null { + // there is a special case when we are at the start of writing text, + // so we handle that case first + if (text === '') { + return { + type: 'EMPTY', + }; + } + + const tree = parser.parse(text); + + // 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); + if (!maybeErrorNode) { + // try again with the previous character + maybeErrorNode = getErrorNode(tree, offset - 1); + } + + const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(offset); + + const currentNode = cur.node; + + const ids = [cur.type.id]; + while (cur.parent()) { + ids.push(cur.type.id); + } + + for (let resolver of RESOLVERS) { + if (isPathMatch(resolver.path, ids)) { + return resolver.fun(currentNode, text, offset); + } + } + + return null; +} + +const ERROR_NODE_ID = 0; + +const RESOLVERS: Resolver[] = [ + { + path: [ERROR_NODE_ID, AttributeField], + fun: resolveAttribute, + }, + { + path: [ERROR_NODE_ID, FieldExpression], + fun: resolveExpression, + }, + { + path: [ERROR_NODE_ID, SpansetFilter], + fun: resolveErrorInFilterRoot, + }, + { + path: [SpansetFilter], + fun: resolveSpanset, + }, +]; + +function resolveSpanset(node: SyntaxNode, text: string, pos: number): Situation { + const lastFieldExpression = walk(node, [['lastChild', [FieldExpression]]]); + if (lastFieldExpression) { + return { + type: 'SPANSET_EXPRESSION_OPERATORS', + }; + } + + return { + type: 'SPANSET_EMPTY', + }; +} + +function resolveAttribute(node: SyntaxNode, text: string, pos: number): Situation { + const attributeFieldParent = walk(node, [['parent', [AttributeField]]]); + const attributeFieldParentText = attributeFieldParent ? getNodeText(attributeFieldParent, text) : ''; + + if (attributeFieldParentText === '.') { + return { + type: 'SPANSET_ONLY_DOT', + }; + } + + const indexOfDot = attributeFieldParentText.indexOf('.'); + const attributeFieldUpToDot = attributeFieldParentText.slice(0, indexOfDot); + + if (['span', 'resource', 'parent'].find((item) => item === attributeFieldUpToDot)) { + return { + type: 'SPANSET_IN_NAME_SCOPE', + scope: attributeFieldUpToDot, + }; + } + return { + type: 'SPANSET_IN_NAME', + }; +} + +function resolveExpression(node: SyntaxNode, text: string, pos: number): Situation { + if (node.prevSibling?.type.id === FieldOp) { + let attributeField = node.prevSibling.prevSibling; + if (attributeField) { + return { + type: 'SPANSET_IN_VALUE', + tagName: getNodeText(attributeField, text), + betweenQuotes: false, + }; + } + } + return { + type: 'SPANSET_EMPTY', + }; +} + +function resolveErrorInFilterRoot(node: SyntaxNode, text: string, pos: number): Situation { + return { + type: 'SPANSET_IN_NAME', + }; +} diff --git a/yarn.lock b/yarn.lock index d6115c629bf..61c4810a9cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3868,6 +3868,15 @@ __metadata: languageName: node linkType: hard +"@grafana/lezer-traceql@npm:0.0.4": + version: 0.0.4 + resolution: "@grafana/lezer-traceql@npm:0.0.4" + peerDependencies: + "@lezer/lr": ^1.3.0 + checksum: 69acea33476d3cdabfb99f3eb62bb34289bc205da69920e0eccc82f40407f9d584fa03f9662706ab667ee97d3ea84b964b22a11925e6699deef5afe1ca9e1906 + languageName: node + linkType: hard + "@grafana/monaco-logql@npm:^0.0.7": version: 0.0.7 resolution: "@grafana/monaco-logql@npm:0.0.7" @@ -19282,6 +19291,7 @@ __metadata: "@grafana/faro-web-sdk": 1.1.2 "@grafana/google-sdk": 0.1.1 "@grafana/lezer-logql": 0.1.8 + "@grafana/lezer-traceql": 0.0.4 "@grafana/monaco-logql": ^0.0.7 "@grafana/runtime": "workspace:*" "@grafana/scenes": 0.22.0