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
This commit is contained in:
Matias Chomicki
2023-10-06 12:23:48 -05:00
committed by GitHub
parent 4474f19836
commit 16d77dda67
4 changed files with 258 additions and 240 deletions
@@ -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'],
};
@@ -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<Completion[]> {
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);
}
@@ -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',
@@ -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) {