diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts index 1f3985af63..8250319681 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts @@ -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())', diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.ts index fc79b6fcd6..d89907699a 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.ts @@ -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 + ); }); }