@fc-components/monaco-editor 0.1.26 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,8 @@ export declare type Situation = {
7
7
  type: 'EMPTY';
8
8
  } | {
9
9
  type: 'IN_DURATION';
10
+ } | {
11
+ type: 'IN_WITH_BODY';
10
12
  } | {
11
13
  type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME';
12
14
  metricName?: string;
@@ -22,5 +24,6 @@ export declare type Situation = {
22
24
  labelName: string;
23
25
  betweenQuotes: boolean;
24
26
  otherLabels: Label[];
27
+ valueStartPos: number;
25
28
  };
26
29
  export declare function getSituation(text: string, pos: number): Situation | null;
@@ -17,7 +17,6 @@ interface PromQLEditorProps {
17
17
  onEnter?: (value: string) => void;
18
18
  onBlur?: (value: string) => void;
19
19
  editorDidMount?: (editor: monacoTypes.editor.IStandaloneCodeEditor) => void;
20
- debugEscKey?: boolean;
21
20
  }
22
21
  export default function PromQLEditor(props: PromQLEditorProps & DataProviderParams): React.JSX.Element;
23
22
  export {};
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.26",
6
+ "version": "0.2.1",
7
7
  "license": "MIT",
8
8
  "main": "dist/index.js",
9
9
  "module": "dist/monaco-editor.esm.js",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@emotion/css": "11.13.4",
48
- "@fc-components/lezer-metricsql": "0.0.1",
48
+ "@fc-components/lezer-metricsql": "^0.0.2",
49
49
  "@leeoniya/ufuzzy": "^1.0.18",
50
50
  "@lezer/common": "1.2.3",
51
51
  "@lezer/highlight": "1.2.1",
@@ -0,0 +1,72 @@
1
+ import { getCompletions } from '../completion/completions';
2
+ import type { Situation } from '../completion/situation';
3
+ import type { DataProvider } from '../completion/DataProvider';
4
+
5
+ // Minimal mock DataProvider that returns empty data
6
+ function createMockDataProvider(overrides: Partial<DataProvider> = {}): DataProvider {
7
+ return {
8
+ getAllMetricNames: () => [],
9
+ metricNamesToMetrics: (names: string[]) => names.map((name) => ({ name, help: '', type: '' })),
10
+ fetchLabels: async () => [],
11
+ fetchSeries: async () => [],
12
+ fetchLabelValues: async () => [],
13
+ getVariablesNames: () => [],
14
+ durationVariablesCompletion: false,
15
+ ...overrides,
16
+ } as unknown as DataProvider;
17
+ }
18
+
19
+ describe('getCompletions', () => {
20
+ const mockDataProvider = createMockDataProvider();
21
+
22
+ describe('with keyword completion', () => {
23
+ it('includes "with" in EMPTY completions', async () => {
24
+ const situation: Situation = { type: 'EMPTY' };
25
+ const completions = await getCompletions(situation, mockDataProvider);
26
+ const withCompletion = completions.find((c) => c.label === 'with');
27
+ expect(withCompletion).toBeDefined();
28
+ expect(withCompletion!.insertText).toBe('with (');
29
+ expect(withCompletion!.detail).toContain('cte_name');
30
+ });
31
+
32
+ it('includes "with" in AT_ROOT completions', async () => {
33
+ const situation: Situation = { type: 'AT_ROOT' };
34
+ const completions = await getCompletions(situation, mockDataProvider);
35
+ const withCompletion = completions.find((c) => c.label === 'with');
36
+ expect(withCompletion).toBeDefined();
37
+ });
38
+
39
+ it('includes "with" in IN_WITH_BODY completions', async () => {
40
+ const situation: Situation = { type: 'IN_WITH_BODY' };
41
+ const completions = await getCompletions(situation, mockDataProvider);
42
+ const withCompletion = completions.find((c) => c.label === 'with');
43
+ expect(withCompletion).toBeDefined();
44
+ });
45
+
46
+ it('includes "with" in IN_FUNCTION completions', async () => {
47
+ const situation: Situation = { type: 'IN_FUNCTION' };
48
+ const completions = await getCompletions(situation, mockDataProvider);
49
+ const withCompletion = completions.find((c) => c.label === 'with');
50
+ expect(withCompletion).toBeDefined();
51
+ });
52
+ });
53
+
54
+ describe('Function completions', () => {
55
+ it('includes standard PromQL functions', async () => {
56
+ const situation: Situation = { type: 'AT_ROOT' };
57
+ const completions = await getCompletions(situation, mockDataProvider);
58
+ const sumCompletion = completions.find((c) => c.label === 'sum');
59
+ expect(sumCompletion).toBeDefined();
60
+ expect(sumCompletion!.type).toBe('FUNCTION');
61
+ });
62
+ });
63
+
64
+ describe('Duration completions', () => {
65
+ it('returns duration values for IN_DURATION', async () => {
66
+ const situation: Situation = { type: 'IN_DURATION' };
67
+ const completions = await getCompletions(situation, mockDataProvider);
68
+ expect(completions.length).toBeGreaterThan(0);
69
+ expect(completions.every((c) => c.type === 'DURATION')).toBe(true);
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,85 @@
1
+ import { getSituation } from '../completion/situation';
2
+
3
+ describe('getSituation', () => {
4
+ describe('EMPTY', () => {
5
+ it('returns EMPTY for empty text', () => {
6
+ expect(getSituation('', 0)).toEqual({ type: 'EMPTY' });
7
+ });
8
+ });
9
+
10
+ describe('IN_WITH_BODY', () => {
11
+ it('returns IN_WITH_BODY when cursor is after with block', () => {
12
+ const text = 'with (cpu = sum(rate(foo[5m])))\ncpu';
13
+ // find the second occurrence of 'cpu' (after the with block)
14
+ const firstCpu = text.indexOf('cpu');
15
+ const pos = text.indexOf('cpu', firstCpu + 1);
16
+ const result = getSituation(text, pos);
17
+ expect(result).toEqual({ type: 'IN_WITH_BODY' });
18
+ });
19
+
20
+ it('returns IN_WITH_BODY for simple with expression', () => {
21
+ const text = 'with (a = foo) a';
22
+ const pos = text.length; // cursor at end
23
+ const result = getSituation(text, pos);
24
+ expect(result).toEqual({ type: 'IN_WITH_BODY' });
25
+ });
26
+
27
+ it('returns IN_WITH_BODY for multi-CTE with expressions', () => {
28
+ const text = 'with (x = rate(foo[5m]), y = sum(bar))\nx + y';
29
+ const pos = text.length;
30
+ const result = getSituation(text, pos);
31
+ expect(result).toEqual({ type: 'IN_WITH_BODY' });
32
+ });
33
+
34
+ it('does NOT return IN_WITH_BODY when cursor is before closing paren', () => {
35
+ const text = 'with (a = foo|) a';
36
+ const pipePos = text.indexOf('|');
37
+ const cleanText = text.slice(0, pipePos) + text.slice(pipePos + 1);
38
+ const result = getSituation(cleanText, pipePos);
39
+ expect(result?.type).not.toBe('IN_WITH_BODY');
40
+ });
41
+
42
+ it('does NOT return IN_WITH_BODY for regular query starting with identifier containing "with"', () => {
43
+ const text = 'without_cpu';
44
+ const pos = text.length;
45
+ const result = getSituation(text, pos);
46
+ expect(result?.type).not.toBe('IN_WITH_BODY');
47
+ });
48
+ });
49
+
50
+ describe('AT_ROOT', () => {
51
+ it('returns AT_ROOT for simple metric name', () => {
52
+ const text = 'node_cpu_seconds_total';
53
+ const pos = text.length;
54
+ const result = getSituation(text, pos);
55
+ expect(result).toEqual({ type: 'AT_ROOT' });
56
+ });
57
+
58
+ it('returns AT_ROOT for function call', () => {
59
+ const text = 'rate(node_cpu_seconds_total[5m])';
60
+ const pos = text.length;
61
+ const result = getSituation(text, pos);
62
+ expect(result).toEqual({ type: 'AT_ROOT' });
63
+ });
64
+ });
65
+
66
+ describe('IN_FUNCTION', () => {
67
+ it('returns IN_FUNCTION inside function body', () => {
68
+ const text = 'sum()';
69
+ const pos = text.indexOf(')');
70
+ const result = getSituation(text, pos);
71
+ expect(result?.type).toBe('IN_FUNCTION');
72
+ });
73
+ });
74
+
75
+ describe('IN_DURATION', () => {
76
+ it('returns a situation for incomplete duration', () => {
77
+ // Cursor just after `[` but before `]` should trigger some situation
78
+ const text = 'foo[5';
79
+ const pos = text.length;
80
+ const result = getSituation(text, pos);
81
+ // Result depends on exact parse tree; not null for valid/incomplete input
82
+ expect(result).toBeDefined();
83
+ });
84
+ });
85
+ });
@@ -58,9 +58,17 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({
58
58
  async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
59
59
  const metricNames = getAllMetricNamesCompletions(dataProvider);
60
60
 
61
- return [...FUNCTION_COMPLETIONS, ...metricNames];
61
+ return [CTE_KEYWORD_COMPLETION, ...FUNCTION_COMPLETIONS, ...metricNames];
62
62
  }
63
63
 
64
+ const CTE_KEYWORD_COMPLETION: Completion = {
65
+ type: 'FUNCTION',
66
+ label: 'with',
67
+ insertText: 'with (',
68
+ detail: 'with (cte_name = expr, ...) expr',
69
+ documentation: 'Define Common Table Expressions (CTEs) for use in the query. MetricsQL extension.',
70
+ };
71
+
64
72
  const DURATION_COMPLETIONS: Completion[] = ['1m', '5m', '10m', '30m', '1h', '1d'].map((text) => ({
65
73
  type: 'DURATION',
66
74
  label: text,
@@ -177,13 +185,14 @@ export function getCompletions(situation: Situation, dataProvider: DataProvider)
177
185
  return Promise.resolve(DURATION_COMPLETIONS);
178
186
  case 'IN_FUNCTION':
179
187
  return getAllFunctionsAndMetricNamesCompletions(dataProvider);
188
+ case 'IN_WITH_BODY':
180
189
  case 'AT_ROOT': {
181
190
  return getAllFunctionsAndMetricNamesCompletions(dataProvider);
182
191
  }
183
192
  case 'EMPTY': {
184
193
  const metricNames = getAllMetricNamesCompletions(dataProvider);
185
194
  const historyCompletions = getAllHistoryCompletions(dataProvider);
186
- return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]);
195
+ return Promise.resolve([...historyCompletions, CTE_KEYWORD_COMPLETION, ...FUNCTION_COMPLETIONS, ...metricNames]);
187
196
  }
188
197
  case 'IN_LABEL_SELECTOR_NO_LABEL_NAME':
189
198
  return getLabelNamesForSelectorCompletions(situation.metricName, situation.hasOperator, situation.otherLabels, dataProvider);
@@ -70,6 +70,20 @@ export function getCompletionProvider(monaco: Monaco, dataProvider: DataProvider
70
70
  // to stop it, we use a number-as-string sortkey,
71
71
  // so that monaco keeps the order we use
72
72
  const maxIndexDigits = items.length.toString().length;
73
+
74
+ // Determine the completion range based on situation type
75
+ let completionRange = range;
76
+ if (situation && situation.type === 'IN_LABEL_SELECTOR_WITH_LABEL_NAME' && situation.betweenQuotes) {
77
+ // For label values within quotes, replace from the start of the value to the current position
78
+ const valueStartPosition = model.getPositionAt(situation.valueStartPos);
79
+ completionRange = monaco.Range.lift({
80
+ startLineNumber: valueStartPosition.lineNumber,
81
+ endLineNumber: position.lineNumber,
82
+ startColumn: valueStartPosition.column,
83
+ endColumn: position.column,
84
+ });
85
+ }
86
+
73
87
  const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({
74
88
  kind: getMonacoCompletionItemKind(item.type, monaco),
75
89
  label: item.label,
@@ -77,7 +91,7 @@ export function getCompletionProvider(monaco: Monaco, dataProvider: DataProvider
77
91
  detail: item.detail,
78
92
  documentation: item.documentation,
79
93
  sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
80
- range,
94
+ range: completionRange,
81
95
  command: item.triggerOnInsert
82
96
  ? {
83
97
  id: 'editor.action.triggerSuggest',
@@ -20,6 +20,8 @@ import {
20
20
  StringLiteral,
21
21
  UnquotedLabelMatcher,
22
22
  VectorSelector,
23
+ WithExpr,
24
+ WithAssignment,
23
25
  } from '@fc-components/lezer-metricsql';
24
26
 
25
27
  import { NeverCaseError } from '../util';
@@ -45,7 +47,9 @@ type NodeTypeId =
45
47
  | typeof EqlSingle
46
48
  | typeof Neq
47
49
  | typeof EqlRegex
48
- | typeof NeqRegex;
50
+ | typeof NeqRegex
51
+ | typeof WithExpr
52
+ | typeof WithAssignment;
49
53
 
50
54
  type Path = Array<[Direction, NodeTypeId]>;
51
55
 
@@ -128,6 +132,9 @@ export type Situation =
128
132
  | {
129
133
  type: 'IN_DURATION';
130
134
  }
135
+ | {
136
+ type: 'IN_WITH_BODY';
137
+ }
131
138
  | {
132
139
  type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME';
133
140
  metricName?: string;
@@ -145,6 +152,7 @@ export type Situation =
145
152
  labelName: string;
146
153
  betweenQuotes: boolean;
147
154
  otherLabels: Label[];
155
+ valueStartPos: number;
148
156
  };
149
157
 
150
158
  type Resolver = {
@@ -215,6 +223,18 @@ const RESOLVERS: Resolver[] = [
215
223
  path: [GroupingLabels],
216
224
  fun: resolveLabelsForGrouping,
217
225
  },
226
+ {
227
+ path: [WithExpr],
228
+ fun: resolveWithExpr,
229
+ },
230
+ {
231
+ path: [WithExpr, PromQL],
232
+ fun: resolveWithExpr,
233
+ },
234
+ {
235
+ path: [WithAssignment, WithExpr],
236
+ fun: resolveWithExpr,
237
+ },
218
238
  ];
219
239
 
220
240
  const LABEL_OP_MAP = new Map<number, LabelOperator>([
@@ -335,6 +355,11 @@ function resolveLabelMatcher(node: SyntaxNode, text: string, _pos: number): Situ
335
355
  // - or an error node (like in `{job=^}`)
336
356
  const inStringNode = !node.type.isError;
337
357
 
358
+ // calculate where the value starts
359
+ // for string nodes, it's after the opening quote
360
+ // for error nodes, it's at the node start
361
+ const valueStartPos = inStringNode ? node.from + 1 : node.from;
362
+
338
363
  const parent = walk(node, [['parent', UnquotedLabelMatcher]]);
339
364
  if (parent === null) {
340
365
  return null;
@@ -370,6 +395,7 @@ function resolveLabelMatcher(node: SyntaxNode, text: string, _pos: number): Situ
370
395
  labelName,
371
396
  betweenQuotes: inStringNode,
372
397
  otherLabels,
398
+ valueStartPos,
373
399
  };
374
400
  }
375
401
 
@@ -381,6 +407,7 @@ function resolveLabelMatcher(node: SyntaxNode, text: string, _pos: number): Situ
381
407
  labelName,
382
408
  betweenQuotes: inStringNode,
383
409
  otherLabels,
410
+ valueStartPos,
384
411
  };
385
412
  }
386
413
 
@@ -422,6 +449,23 @@ function resolveInFunction(): Situation {
422
449
  };
423
450
  }
424
451
 
452
+ function resolveWithExpr(node: SyntaxNode, text: string, pos: number): Situation | null {
453
+ // Find the containing WithExpr node (node may be a WithAssignment child)
454
+ let withExprNode: SyntaxNode | null = node.type.id === WithExpr ? node : node.parent;
455
+ while (withExprNode && withExprNode.type.id !== WithExpr) {
456
+ withExprNode = withExprNode.parent;
457
+ }
458
+ if (!withExprNode) return null;
459
+
460
+ // Only return IN_WITH_BODY when cursor is in the body expression (after the closing ')')
461
+ const children = getNodeChildren(withExprNode);
462
+ const closeParen = children.find((c) => getNodeText(c, text) === ')');
463
+ if (closeParen && pos > closeParen.to) {
464
+ return { type: 'IN_WITH_BODY' };
465
+ }
466
+ return null;
467
+ }
468
+
425
469
  function resolveDurations(): Situation {
426
470
  return {
427
471
  type: 'IN_DURATION',
@@ -555,6 +599,27 @@ function getErrorNode(tree: Tree, pos: number): SyntaxNode | null {
555
599
  return null;
556
600
  }
557
601
 
602
+ function findMatchingParen(text: string, openPos: number): number {
603
+ let depth = 0;
604
+ for (let i = openPos; i < text.length; i++) {
605
+ if (text[i] === '(') depth++;
606
+ if (text[i] === ')') {
607
+ depth--;
608
+ if (depth === 0) return i;
609
+ }
610
+ }
611
+ return -1;
612
+ }
613
+
614
+ function isInWithBody(text: string, pos: number): boolean {
615
+ const withMatch = text.match(/^with\s*\(/i);
616
+ if (!withMatch) return false;
617
+ const openParenPos = withMatch[0].length - 1;
618
+ const closeParenPos = findMatchingParen(text, openParenPos);
619
+ if (closeParenPos === -1) return false;
620
+ return pos > closeParenPos;
621
+ }
622
+
558
623
  export function getSituation(text: string, pos: number): Situation | null {
559
624
  // there is a special-case when we are at the start of writing text,
560
625
  // so we handle that case first
@@ -565,6 +630,13 @@ export function getSituation(text: string, pos: number): Situation | null {
565
630
  };
566
631
  }
567
632
 
633
+ // text-based check for with body (fallback until grammar supports WithExpr)
634
+ if (isInWithBody(text, pos)) {
635
+ return {
636
+ type: 'IN_WITH_BODY',
637
+ };
638
+ }
639
+
568
640
  /**
569
641
  PromQL
570
642
  Expr
@@ -30,7 +30,6 @@ interface PromQLEditorProps {
30
30
  onEnter?: (value: string) => void;
31
31
  onBlur?: (value: string) => void;
32
32
  editorDidMount?: (editor: monacoTypes.editor.IStandaloneCodeEditor) => void;
33
- debugEscKey?: boolean;
34
33
  }
35
34
 
36
35
  const PROMQL_LANG_ID = promLanguageDefinition.id;
@@ -104,7 +103,6 @@ export default function PromQLEditor(props: PromQLEditorProps & DataProviderPara
104
103
  onEnter,
105
104
  onBlur,
106
105
  editorDidMount,
107
- debugEscKey = false,
108
106
  } = props;
109
107
  const autocompleteDisposeFun = useRef<(() => void) | null>(null);
110
108
  const containerRef = useRef<HTMLDivElement>(null);
@@ -213,22 +211,6 @@ export default function PromQLEditor(props: PromQLEditorProps & DataProviderPara
213
211
  '!suggestWidgetVisible && isEditorFocused' + id,
214
212
  );
215
213
 
216
- editor.addCommand(
217
- monaco.KeyCode.Escape,
218
- () => {
219
- if (debugEscKey) {
220
- const suggestWidgetVisible = (editor as any)?._contextKeyService?.getContextKeyValue?.('suggestWidgetVisible');
221
- // eslint-disable-next-line no-console
222
- console.log('[PromQLEditor][ESC]', {
223
- suggestWidgetVisible,
224
- valueLength: editor.getValue().length,
225
- });
226
- }
227
- editor.trigger('keyboard', 'hideSuggestWidget', {});
228
- },
229
- 'suggestWidgetVisible && isEditorFocused' + id,
230
- );
231
-
232
214
  // Initialize previous content tracking
233
215
  previousContentRef.current = editor.getValue();
234
216
  lastDeletionTriggerTimeRef.current = 0;
@@ -243,6 +243,8 @@ for (let _i = 0, aggregations_1 = aggregations; _i < aggregations_1.length; _i++
243
243
  // PromQL vector matching + the by and without clauses
244
244
  // (https://prometheus.io/docs/prometheus/latest/querying/operators/#vector-matching)
245
245
  const vectorMatching = ['on', 'ignoring', 'group_right', 'group_left', 'by', 'without'];
246
+
247
+ const cteKeywords = ['with'];
246
248
  // Produce a regex matching elements : (elt1|elt2|...)
247
249
  const vectorMatchingRegex =
248
250
  '(' +
@@ -257,7 +259,7 @@ const operators = ['+', '-', '*', '/', '%', '^', '==', '!=', '>', '<', '>=', '<=
257
259
  // (https://prometheus.io/docs/prometheus/latest/querying/basics/#offset-modifier)
258
260
  const offsetModifier = ['offset'];
259
261
  // Merging all the keywords in one list
260
- const keywords = aggregations.concat(functions).concat(aggregationsOverTime).concat(vectorMatching).concat(offsetModifier);
262
+ const keywords = aggregations.concat(functions).concat(aggregationsOverTime).concat(vectorMatching).concat(offsetModifier).concat(cteKeywords);
261
263
  // noinspection JSUnusedGlobalSymbols
262
264
  export const language = {
263
265
  ignoreCase: false,