Replace entire identifier when autocompleting inside of it

When accepting an autocompletion result within an Identifier node (could be a
metric name, function name, keyword, etc.), the inserted completion should
replace the entire Identifier node all the way to its last character, not only
to the current cursor position.

A limitation is that the correct replacement-until-end-of-identifier only works
when e.g. a function name is currently incomplete (which is likely anyway when
trying to replace it with a different one). This is because otherwise the
Identifier node gets replaced with a more specific function node type (like
`Rate`, `SumOverTime`, etc.), and handling all those adds more complexity.

https://github.com/prometheus/prometheus/issues/15839

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2026-01-05 15:14:34 +01:00
parent e14795bbf4
commit dbb3fc65b6
2 changed files with 111 additions and 2 deletions

View File

@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { analyzeCompletion, computeStartCompletePosition, ContextKind, durationWithUnitRegexp } from './hybrid';
import { analyzeCompletion, computeStartCompletePosition, computeEndCompletePosition, ContextKind, durationWithUnitRegexp } from './hybrid';
import { createEditorState, mockedMetricsTerms, mockPrometheusServer } from '../test/utils-test';
import { Completion, CompletionContext } from '@codemirror/autocomplete';
import {
@ -866,6 +866,73 @@ describe('computeStartCompletePosition test', () => {
});
});
describe('computeEndCompletePosition test', () => {
const testCases = [
{
title: 'cursor at end of metric name',
expr: 'metric_name',
pos: 11, // cursor is at the end
expectedEnd: 11,
},
{
title: 'cursor in middle of metric name - should extend to end',
expr: 'coredns_cache_hits_total',
pos: 14, // cursor is after 'coredns_cache_' (before 'hits')
expectedEnd: 24, // should extend to end of 'coredns_cache_hits_total'
},
{
title: 'cursor in middle of metric name inside rate() - should extend to end',
expr: 'rate(coredns_cache_hits_total[2m])',
pos: 19, // cursor is after 'coredns_cache_' (before 'hits')
expectedEnd: 29, // should extend to end of 'coredns_cache_hits_total'
},
{
title: 'cursor in middle of metric name inside sum(rate()) - should extend to end',
expr: 'sum(rate(coredns_cache_hits_total[2m]))',
pos: 24, // cursor is after 'coredns_cache_' (before 'hits')
expectedEnd: 33, // should extend to end of 'coredns_cache_hits_total'
},
{
title: 'cursor at beginning of metric name - should extend to end',
expr: 'metric_name',
pos: 1, // cursor after 'm'
expectedEnd: 11,
},
{
title: 'cursor in middle of incomplete function name - should extend to end',
expr: 'sum_ov',
pos: 4, // cursor after 'sum_' (before 'ov')
expectedEnd: 6, // should extend to end of 'sum_ov'
},
{
title: 'cursor in middle of incomplete function name within aggregator - should extend to end',
expr: 'sum(sum_ov(foo[5m]))',
pos: 8, // cursor after 'sum_' (before 'ov')
expectedEnd: 10, // should extend to end of 'sum_ov'
},
{
title: 'empty bracket - returns pos',
expr: '{}',
pos: 1,
expectedEnd: 1,
},
{
title: 'cursor in label matchers - returns pos',
expr: 'metric_name{label="value"}',
pos: 12, // cursor after '{'
expectedEnd: 12,
},
];
testCases.forEach((value) => {
it(value.title, () => {
const state = createEditorState(value.expr);
const node = syntaxTree(state).resolve(value.pos, -1);
const result = computeEndCompletePosition(state, node, value.pos);
expect(result).toEqual(value.expectedEnd);
});
});
});
describe('autocomplete promQL test', () => {
beforeEach(() => {
mockPrometheusServer();
@ -915,6 +982,28 @@ describe('autocomplete promQL test', () => {
validFor: /^[a-zA-Z0-9_:]+$/,
},
},
{
title: 'cursor in middle of metric name - to should extend to end (issue #15839)',
expr: 'sum(coredns_cache_hits_total)',
pos: 18, // cursor is after 'coredns_cache_' (before 'hits')
expectedResult: {
options: ([] as Completion[]).concat(functionIdentifierTerms, aggregateOpTerms, snippets),
from: 4,
to: 28, // should extend to end of 'coredns_cache_hits_total'
validFor: /^[a-zA-Z0-9_:]+$/,
},
},
{
title: 'cursor in middle of metric name inside rate() - to should extend to end (issue #15839)',
expr: 'rate(coredns_cache_hits_total[2m])',
pos: 19, // cursor is after 'coredns_cache_' (before 'hits')
expectedResult: {
options: ([] as Completion[]).concat(functionIdentifierTerms, aggregateOpTerms, snippets),
from: 5,
to: 29, // should extend to end of 'coredns_cache_hits_total'
validFor: /^[a-zA-Z0-9_:]+$/,
},
},
{
title: 'offline function/aggregation autocompletion in aggregation 3',
expr: 'sum(rate())',

View File

@ -166,6 +166,20 @@ function arrayToCompletionResult(data: Completion[], from: number, to: number, i
} as CompletionResult;
}
// computeEndCompletePosition calculates the end position for autocompletion replacement.
// When the cursor is in the middle of an identifier (e.g., metric name), this ensures the entire
// identifier is replaced, not just the portion before the cursor. This fixes issue #15839.
// Note: this method is exported only for testing purpose.
export function computeEndCompletePosition(state: EditorState, node: SyntaxNode, pos: number): number {
// For Identifier nodes (metric names), extend the end position to include
// the entire identifier, even if the cursor is in the middle.
if (node.type.id === Identifier) {
return node.to;
}
// Default: use the cursor position as the end position
return pos;
}
// Matches complete PromQL durations, including compound units (e.g., 5m, 1d2h, 1h30m, etc.).
// Duration units are a fixed, safe set (no regex metacharacters), so no escaping is needed.
export const durationWithUnitRegexp = new RegExp(`^(\\d+(${durationTerms.map((term) => term.label).join('|')}))+$`);
@ -667,7 +681,13 @@ export class HybridComplete implements CompleteStrategy {
}
}
return asyncResult.then((result) => {
return arrayToCompletionResult(result, computeStartCompletePosition(state, tree, pos), pos, completeSnippet, span);
return arrayToCompletionResult(
result,
computeStartCompletePosition(state, tree, pos),
computeEndCompletePosition(state, tree, pos),
completeSnippet,
span
);
});
}