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 526a5ce4f8..587e9c5304 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 } from './hybrid'; +import { analyzeCompletion, computeStartCompletePosition, ContextKind, durationWithUnitRegexp } from './hybrid'; import { createEditorState, mockedMetricsTerms, mockPrometheusServer } from '../test/utils-test'; import { Completion, CompletionContext } from '@codemirror/autocomplete'; import { @@ -559,6 +559,18 @@ describe('analyzeCompletion test', () => { pos: 28, expectedContext: [{ kind: ContextKind.Duration }], }, + { + title: 'do not autocomplete duration when unit already present in matrixSelector', + expr: 'rate(foo[5m])', + pos: 10, + expectedContext: [], + }, + { + title: 'do not autocomplete duration when multi char unit already present in matrixSelector', + expr: 'rate(foo[5ms])', + pos: 10, + expectedContext: [], + }, { title: 'autocomplete duration for a subQuery', expr: 'go[5d:5]', @@ -630,6 +642,42 @@ describe('analyzeCompletion test', () => { }); }); +describe('durationWithUnitRegexp test', () => { + it('should match complete durations with units', () => { + const testCases = [ + { input: '5m', expected: true }, + { input: '30s', expected: true }, + { input: '1h', expected: true }, + { input: '500ms', expected: true }, + { input: '2d', expected: true }, + { input: '1w', expected: true }, + { input: '1y', expected: true }, + { input: '1d2h', expected: true }, + { input: '2h30m', expected: true }, + { input: '1h2m3s', expected: true }, + { input: '250ms2s', expected: true }, + { input: '2h3m4s5ms', expected: true }, + { input: '5', expected: false }, + { input: '5m5', expected: false }, + { input: 'm', expected: false }, + { input: 'd', expected: false }, + { input: '', expected: false }, + { input: '1hms', expected: false }, + { input: '2x', expected: false }, + ]; + testCases.forEach(({ input, expected }) => { + expect(durationWithUnitRegexp.test(input)).toBe(expected); + }); + }); + + it('should not match durations without units or partial units', () => { + const testCases = ['5', '30', '100', '5m5', 'm', 'd']; + testCases.forEach((input) => { + expect(durationWithUnitRegexp.test(input)).toBe(false); + }); + }); +}); + describe('computeStartCompletePosition test', () => { const testCases = [ { @@ -1229,6 +1277,28 @@ describe('autocomplete promQL test', () => { validFor: undefined, }, }, + { + title: 'offline do not autocomplete duration when unit already present in matrixSelector', + expr: 'rate(foo[5m])', + pos: 10, + expectedResult: { + options: [], + from: 10, + to: 10, + validFor: /^[a-zA-Z0-9_:]+$/, + }, + }, + { + title: 'offline do not autocomplete duration when multi char unit already present in matrixSelector', + expr: 'rate(foo[5ms])', + pos: 10, + expectedResult: { + options: [], + from: 10, + to: 10, + validFor: /^[a-zA-Z0-9_:]+$/, + }, + }, { title: 'offline autocomplete duration for a subQuery', expr: 'go[5d:5]', diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.ts index 76efc34442..814147e532 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.ts @@ -166,6 +166,19 @@ function arrayToCompletionResult(data: Completion[], from: number, to: number, i } as CompletionResult; } +// 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('|')}))+$`); + +// Determines if a duration already has a complete time unit to prevent autocomplete insertion (issue #15452) +function hasCompleteDurationUnit(state: EditorState, node: SyntaxNode): boolean { + if (node.from >= node.to) { + return false; + } + const nodeContent = state.sliceDoc(node.from, node.to); + return durationWithUnitRegexp.test(nodeContent); +} + // computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel calculates the start position only when the node is a LabelMatchers or a GroupingLabels function computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel(node: SyntaxNode, pos: number): number { // Here we can have two different situations: @@ -477,12 +490,18 @@ export function analyzeCompletion(state: EditorState, node: SyntaxNode, pos: num // Duration, Duration, ⚠(NumberLiteral) // ) // So we should continue to autocomplete a duration - result.push({ kind: ContextKind.Duration }); + if (!hasCompleteDurationUnit(state, node)) { + result.push({ kind: ContextKind.Duration }); + } } else { result.push({ kind: ContextKind.Number }); } break; case NumberDurationLiteralInDurationContext: + if (!hasCompleteDurationUnit(state, node)) { + result.push({ kind: ContextKind.Duration }); + } + break; case OffsetExpr: result.push({ kind: ContextKind.Duration }); break;