@vii7/div-table-widget 1.0.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.
package/src/query.js ADDED
@@ -0,0 +1,2107 @@
1
+ /**
2
+ * Sets up basic language configuration for the query language
3
+ * @param {object} monaco The Monaco editor instance
4
+ */
5
+ function setupLanguageConfiguration(monaco, languageId) {
6
+ monaco.languages.setLanguageConfiguration(languageId, {
7
+
8
+ // Auto-closing pairs
9
+ autoClosingPairs: [
10
+ { open: '(', close: ')' },
11
+ { open: '[', close: ']' },
12
+ { open: '"', close: '"' },
13
+ { open: "'", close: "'" }
14
+ ],
15
+
16
+ // Surrounding pairs
17
+ surroundingPairs: [
18
+ { open: '(', close: ')' },
19
+ { open: '[', close: ']' },
20
+ { open: '"', close: '"' },
21
+ { open: "'", close: "'" }
22
+ ]
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Sets up the completion provider for the query language
28
+ * @param {object} monaco The Monaco editor instance
29
+ * @param {object} options Configuration options
30
+ * @param {object} options.fieldNames The field name definitions
31
+ */
32
+ function setupCompletionProvider(monaco, { fieldNames, languageId }) {
33
+ // Set up auto-insertion of brackets after "IN " is typed
34
+ function setupAutoInsertBrackets(editor) {
35
+ const disposable = editor.onDidChangeModelContent((e) => {
36
+ // Only handle single character insertions
37
+ if (e.changes.length !== 1) return;
38
+
39
+ const change = e.changes[0];
40
+ if (change.text.length !== 1) return;
41
+
42
+ // Only trigger if the user just typed a space character
43
+ if (change.text !== ' ') return;
44
+
45
+ const model = editor.getModel();
46
+ if (!model) return;
47
+
48
+ // Get the current position after the change
49
+ const position = {
50
+ lineNumber: change.range.startLineNumber,
51
+ column: change.range.startColumn + change.text.length
52
+ };
53
+
54
+ // Get the text before the cursor to check if we just typed "IN "
55
+ const lineText = model.getLineContent(position.lineNumber);
56
+ const textBeforeCursor = lineText.substring(0, position.column - 1);
57
+
58
+ // Check if we just completed "IN " (case-sensitive, with space)
59
+ if (textBeforeCursor.endsWith('IN ')) {
60
+ // Check if brackets don't already exist immediately after the space
61
+ const textAfterCursor = lineText.substring(position.column - 1);
62
+
63
+ // Only auto-insert if there are no brackets already present
64
+ if (!textAfterCursor.trimStart().startsWith('[')) {
65
+ // Also check that "IN" is a standalone word (not part of another word like "inStock")
66
+ const beforeIN = textBeforeCursor.substring(0, textBeforeCursor.length - 3);
67
+ const lastChar = beforeIN[beforeIN.length - 1];
68
+
69
+ // Only proceed if "IN" is preceded by whitespace or is at the start
70
+ if (!lastChar || /\s/.test(lastChar)) {
71
+ // Insert brackets and position cursor between them
72
+ editor.executeEdits('auto-insert-brackets', [
73
+ {
74
+ range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
75
+ text: '[]'
76
+ }
77
+ ]);
78
+
79
+ // Position cursor between the brackets
80
+ editor.setPosition({
81
+ lineNumber: position.lineNumber,
82
+ column: position.column + 1
83
+ });
84
+
85
+ // Trigger completion suggestions for the list content
86
+ setTimeout(() => {
87
+ editor.trigger('auto-insert', 'editor.action.triggerSuggest', {});
88
+ }, 10);
89
+ }
90
+ }
91
+ }
92
+ });
93
+
94
+ return disposable;
95
+ }
96
+ // Helper: Insert operator with proper spacing
97
+ function operatorInsertText(op, position, model) {
98
+ // Get the text before the cursor
99
+ const textBefore = model.getValueInRange({
100
+ startLineNumber: position.lineNumber,
101
+ startColumn: Math.max(1, position.column - 1),
102
+ endLineNumber: position.lineNumber,
103
+ endColumn: position.column
104
+ });
105
+
106
+ // If no text before or ends with whitespace, don't add leading space
107
+ if (!textBefore || /\s$/.test(textBefore)) {
108
+ return op;
109
+ }
110
+ // Otherwise add a leading space
111
+ return ` ${op}`;
112
+ }
113
+
114
+ // Create patterns for matching with better context awareness
115
+ const fieldPattern = new RegExp(`^(${Object.keys(fieldNames).join('|')})$`);
116
+ const operPattern = /^(=|!=|>=|<=|>|<)$/i;
117
+ const inPattern = /^IN$/; // Case-sensitive IN operator
118
+ const logicalPattern = /^(AND|OR)$/; // Case-sensitive logical operators
119
+ const fieldList = Object.keys(fieldNames);
120
+
121
+
122
+ // Documentation helper
123
+ function docMarkdown(text) {
124
+ return { value: text, isTrusted: true };
125
+ }
126
+
127
+ // Sort text helper to ensure consistent ordering
128
+ function getSortText(type, label) {
129
+ const order = {
130
+ field: '1',
131
+ operator: '2',
132
+ value: '3',
133
+ logical: '4',
134
+ list: '5'
135
+ };
136
+
137
+ // Handle undefined or null labels
138
+ if (!label) {
139
+ return `${order[type] || '9'}`;
140
+ }
141
+
142
+ // Convert label to string to handle numeric values
143
+ const labelStr = String(label);
144
+
145
+ // Special ordering for operators
146
+ if (type === 'operator') {
147
+ const operatorOrder = {
148
+ '=': '1',
149
+ '!=': '2',
150
+ '>': '3',
151
+ '<': '4',
152
+ 'IN': '5'
153
+ };
154
+ return `${order[type]}${operatorOrder[labelStr] || '9'}${labelStr.toLowerCase()}`;
155
+ }
156
+
157
+ return `${order[type]}${labelStr.toLowerCase()}`;
158
+ }
159
+
160
+ // Operator descriptions
161
+ const descriptions = {
162
+ '=': 'Equals operator',
163
+ '!=': 'Not equals operator',
164
+ '>': 'Greater than operator',
165
+ '<': 'Less than operator',
166
+ 'IN': 'Check if a value is in a list',
167
+ 'AND': 'Logical AND operator',
168
+ 'OR': 'Logical OR operator',
169
+ 'true': 'Boolean true value',
170
+ 'false': 'Boolean false value',
171
+ ...Object.fromEntries(Object.entries(fieldNames).map(([key, attr]) =>
172
+ [key, `${key} (${attr.type}${attr.values ? `: One of [${attr.values.join(', ')}]` : ''})`]
173
+ ))
174
+ };
175
+
176
+ // Helper to get value suggestions based on field type
177
+ function getValueSuggestions(field, fieldName = 'unknown') {
178
+ const suggestions = [];
179
+ if (!field) {
180
+ return suggestions;
181
+ }
182
+ if (field.type === 'boolean') {
183
+ suggestions.push(
184
+ {
185
+ label: 'true',
186
+ kind: monaco.languages.CompletionItemKind.Value,
187
+ insertText: 'true',
188
+ documentation: docMarkdown('Boolean true value'),
189
+ sortText: getSortText('value', 'true')
190
+ },
191
+ {
192
+ label: 'false',
193
+ kind: monaco.languages.CompletionItemKind.Value,
194
+ insertText: 'false',
195
+ documentation: docMarkdown('Boolean false value'),
196
+ sortText: getSortText('value', 'false')
197
+ }
198
+ );
199
+ } else if (field.type === 'string' && field.values) {
200
+ console.log(`🔍 Generating suggestions for field "${fieldName}" with values:`, field.values);
201
+ const newSuggestions = field.values.map(v => ({
202
+ label: v === 'NULL' ? 'NULL' : `"${v}"`,
203
+ kind: monaco.languages.CompletionItemKind.Value,
204
+ insertText: v === 'NULL' ? 'NULL' : `"${v}"`,
205
+ documentation: v === 'NULL' ?
206
+ docMarkdown('Special keyword for null/undefined/empty values') :
207
+ docMarkdown(`String value "${v}"`),
208
+ sortText: getSortText('value', v)
209
+ }));
210
+ console.log(`📋 Generated ${newSuggestions.length} suggestions for field "${fieldName}"`);
211
+ suggestions.push(...newSuggestions);
212
+ } else if (field.type === 'string' && !field.values) {
213
+ // For string fields without predefined values, suggest empty quotes with cursor positioning
214
+ suggestions.push({
215
+ label: '""',
216
+ kind: monaco.languages.CompletionItemKind.Value,
217
+ insertText: '"${1}"',
218
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
219
+ documentation: docMarkdown('Enter a string value'),
220
+ sortText: getSortText('value', '""'),
221
+ detail: 'Free text string'
222
+ });
223
+ } else if (field.type === 'number') {
224
+ // First add a hint suggestion that shows but doesn't insert anything
225
+ suggestions.push({
226
+ label: '(a number)',
227
+ kind: monaco.languages.CompletionItemKind.Text,
228
+ insertText: '', // Don't insert anything when selected
229
+ documentation: docMarkdown(
230
+ field.range
231
+ ? `Enter a number${field.range.min !== undefined ? ` ≥ ${field.range.min}` : ''}${field.range.max !== undefined ? ` ≤ ${field.range.max}` : ''}`
232
+ : 'Enter a number'
233
+ ),
234
+ sortText: getSortText('value', '0'),
235
+ preselect: false, // Don't preselect this item
236
+ filterText: '' // Make it appear but not match any typing
237
+ });
238
+
239
+ // Then add actual values if we have range information
240
+ if (field.range) {
241
+ const suggestions = new Set();
242
+ if (field.range.min !== undefined) {
243
+ suggestions.add(field.range.min);
244
+ }
245
+ if (field.range.max !== undefined) {
246
+ suggestions.add(field.range.max);
247
+ }
248
+ // If we have both min and max, suggest some values in between
249
+ if (field.range.min !== undefined && field.range.max !== undefined) {
250
+ const mid = Math.floor((field.range.min + field.range.max) / 2);
251
+ if (mid !== field.range.min && mid !== field.range.max) {
252
+ suggestions.add(mid);
253
+ }
254
+ // Add quarter points if they're different enough
255
+ const quarter = Math.floor((field.range.min + mid) / 2);
256
+ const threeQuarter = Math.floor((mid + field.range.max) / 2);
257
+ if (quarter !== field.range.min && quarter !== mid) {
258
+ suggestions.add(quarter);
259
+ }
260
+ if (threeQuarter !== mid && threeQuarter !== field.range.max) {
261
+ suggestions.add(threeQuarter);
262
+ }
263
+ }
264
+
265
+ // Add all the suggestions
266
+ [...suggestions].sort((a, b) => a - b).forEach(value => {
267
+ suggestions.push({
268
+ label: value.toString(),
269
+ kind: monaco.languages.CompletionItemKind.Value,
270
+ insertText: value.toString(),
271
+ documentation: docMarkdown(`Number value: ${value}`),
272
+ sortText: getSortText('value', value.toString())
273
+ });
274
+ });
275
+ }
276
+ }
277
+ return suggestions;
278
+ }
279
+
280
+ // Helper to get operator suggestions based on field type
281
+ function getOperatorSuggestions(field, position, model) {
282
+ const suggestions = [
283
+ {
284
+ label: '=',
285
+ kind: monaco.languages.CompletionItemKind.Operator,
286
+ insertText: operatorInsertText('= ', position, model),
287
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
288
+ documentation: docMarkdown(descriptions['=']),
289
+ sortText: getSortText('operator', '='),
290
+ command: { id: 'editor.action.triggerSuggest' }
291
+ },
292
+ {
293
+ label: '!=',
294
+ kind: monaco.languages.CompletionItemKind.Operator,
295
+ insertText: operatorInsertText('!= ', position, model),
296
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
297
+ documentation: docMarkdown(descriptions['!=']),
298
+ sortText: getSortText('operator', '!='),
299
+ command: { id: 'editor.action.triggerSuggest' }
300
+ }
301
+ ];
302
+
303
+ if (field.type === 'number') {
304
+ suggestions.push(
305
+ {
306
+ label: '>',
307
+ kind: monaco.languages.CompletionItemKind.Operator,
308
+ insertText: operatorInsertText('> ', position, model),
309
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
310
+ documentation: docMarkdown(descriptions['>']),
311
+ sortText: getSortText('operator', '>'),
312
+ command: { id: 'editor.action.triggerSuggest' }
313
+ },
314
+ {
315
+ label: '<',
316
+ kind: monaco.languages.CompletionItemKind.Operator,
317
+ insertText: operatorInsertText('< ', position, model),
318
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
319
+ documentation: docMarkdown(descriptions['<']),
320
+ sortText: getSortText('operator', '<'),
321
+ command: { id: 'editor.action.triggerSuggest' }
322
+ },
323
+ {
324
+ label: '>=',
325
+ kind: monaco.languages.CompletionItemKind.Operator,
326
+ insertText: operatorInsertText('>= ', position, model),
327
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
328
+ documentation: docMarkdown('Greater than or equal operator'),
329
+ sortText: getSortText('operator', '>='),
330
+ command: { id: 'editor.action.triggerSuggest' }
331
+ },
332
+ {
333
+ label: '<=',
334
+ kind: monaco.languages.CompletionItemKind.Operator,
335
+ insertText: operatorInsertText('<= ', position, model),
336
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
337
+ documentation: docMarkdown('Less than or equal operator'),
338
+ sortText: getSortText('operator', '<='),
339
+ command: { id: 'editor.action.triggerSuggest' }
340
+ }
341
+ );
342
+ }
343
+
344
+ if (field.values || ['string', 'number'].includes(field.type)) {
345
+ suggestions.push({
346
+ label: 'IN',
347
+ kind: monaco.languages.CompletionItemKind.Operator,
348
+ insertText: operatorInsertText('IN [${1}]', position, model),
349
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet | monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
350
+ documentation: docMarkdown(descriptions['IN']),
351
+ sortText: getSortText('operator', 'IN'),
352
+ command: { id: 'editor.action.triggerSuggest' }
353
+ });
354
+ }
355
+
356
+ return suggestions;
357
+ }
358
+
359
+ // Helper to check expression context
360
+ function getExpressionContext(tokens, position) {
361
+ const lastToken = tokens[tokens.length - 1] || '';
362
+ const prevToken = tokens[tokens.length - 2] || '';
363
+ const context = {
364
+ needsField: false,
365
+ needsOperator: false,
366
+ needsValue: false,
367
+ inList: false,
368
+ currentField: null,
369
+ afterLogical: false
370
+ };
371
+
372
+ // Check for parentheses context - if we're right after an opening parenthesis
373
+ // or inside empty parentheses, we should expect a field name
374
+ if (lastToken === '(' || (lastToken === '' && prevToken === '(')) {
375
+ context.needsField = true;
376
+ context.afterLogical = false;
377
+ return context;
378
+ }
379
+
380
+ // First check for logical operators as they reset the expression context
381
+ if (!lastToken || logicalPattern.test(lastToken)) {
382
+ context.needsField = true;
383
+ context.afterLogical = !!lastToken; // true if we're after AND/OR, false if empty query
384
+ } else if (fieldPattern.test(lastToken)) {
385
+ context.needsOperator = true;
386
+ context.currentField = lastToken;
387
+ } else if (operPattern.test(lastToken) || inPattern.test(lastToken)) {
388
+ context.needsValue = true;
389
+ // Find the associated field name by looking backwards
390
+ for (let i = tokens.length - 2; i >= 0; i--) {
391
+ if (fieldPattern.test(tokens[i])) {
392
+ context.currentField = tokens[i];
393
+ break;
394
+ }
395
+ // Stop if we hit a logical operator or another expression
396
+ if (logicalPattern.test(tokens[i]) || operPattern.test(tokens[i]) || inPattern.test(tokens[i])) {
397
+ break;
398
+ }
399
+ }
400
+ } else if (/\[$/.test(lastToken) || // after opening bracket
401
+ (/\[/.test(lastToken) && !/\]$/.test(lastToken)) || // between brackets
402
+ /,$/.test(lastToken) || // after comma
403
+ (lastToken === '' && tokens.length >= 2 && /\[/.test(tokens[tokens.length - 2]))) { // empty space between brackets
404
+ context.inList = true;
405
+ // Find the field name before IN
406
+ for (let i = tokens.length - 1; i >= 0; i--) {
407
+ if (tokens[i] === 'IN' && i > 0) {
408
+ context.currentField = tokens[i - 1];
409
+ break;
410
+ }
411
+ }
412
+ }
413
+
414
+ return context;
415
+ }
416
+
417
+ const triggerCharacters= [
418
+ // Add all alphabetical characters first
419
+ ...Array.from('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'),
420
+ // Then add other special characters
421
+ ',', ' ', '=', '!', '>', '<', '[', ']', '(', ')', '"', "'"
422
+ ];
423
+ const completionProvider = monaco.languages.registerCompletionItemProvider(languageId, {
424
+ triggerCharacters,
425
+ provideCompletionItems: (model, position) => {
426
+ // Get text up to cursor (don't trim to preserve space context)
427
+ const text = model.getValueInRange({
428
+ startLineNumber: 1,
429
+ startColumn: 1,
430
+ endLineNumber: position.lineNumber,
431
+ endColumn: position.column
432
+ });
433
+
434
+ // Check if cursor is after whitespace (indicates we completed a token)
435
+ const endsWithSpace = /\s$/.test(text);
436
+
437
+ // Enhanced context extraction - use trimmed text for tokenization
438
+ const tokens = text.trim().match(/([\w]+|\(|\)|\[|\]|"[^"]*"|\S)/g) || [];
439
+ const context = getExpressionContext(tokens, position);
440
+ let suggestions = [];
441
+
442
+ // If we're after whitespace and have tokens, we might need to adjust context
443
+ if (endsWithSpace && tokens.length > 0) {
444
+ const lastToken = tokens[tokens.length - 1];
445
+ // If last token is a field name and we're after space, we need operators
446
+ if (fieldList.includes(lastToken)) {
447
+ context.needsOperator = true;
448
+ context.currentField = lastToken;
449
+ context.needsField = false;
450
+ context.afterLogical = false;
451
+ }
452
+ }
453
+
454
+ // Detect if we're in search mode or structured query mode
455
+ const hasOperators = tokens.some(token =>
456
+ ['=', '!=', '>', '<', '>=', '<=', 'IN', 'AND', 'OR','(', ')'].includes(token)
457
+ );
458
+
459
+ // Count meaningful tokens (exclude empty strings)
460
+ const meaningfulTokens = tokens.filter(token => token.trim().length > 0);
461
+ const isFirstWord = meaningfulTokens.length <= 1 && !context.needsOperator;
462
+
463
+ // Get the current word being typed
464
+ const currentWord = context.afterLogical ? '' : (tokens[tokens.length - 1] || '');
465
+ const prevToken = context.afterLogical ? tokens[tokens.length - 1] : (tokens[tokens.length - 2] || '');
466
+
467
+ // Special handling for first word - show both structured and search suggestions
468
+ if (isFirstWord && !hasOperators && currentWord.length >= 1 && /^[a-zA-Z]+$/.test(currentWord)) {
469
+ // Show field name suggestions (for structured mode)
470
+ const matchingFields = fieldList.filter(f =>
471
+ f.toLowerCase().startsWith(currentWord.toLowerCase())
472
+ );
473
+
474
+ if (matchingFields.length > 0) {
475
+ suggestions.push(...matchingFields.map(f => ({
476
+ label: f,
477
+ kind: monaco.languages.CompletionItemKind.Field,
478
+ insertText: `${f} `,
479
+ documentation: docMarkdown(`Field: ${descriptions[f] || f}\n\nClick to start a structured query with this field.`),
480
+ detail: 'Field (start structured query)',
481
+ sortText: `0_field_${f}`, // Sort fields first
482
+ command: { id: 'editor.action.triggerSuggest' } // Auto-trigger next suggestions
483
+ })));
484
+ }
485
+
486
+ // Show search mode suggestion for any alphabetical input
487
+ suggestions.push({
488
+ label: `"${currentWord}" (search all fields)`,
489
+ kind: monaco.languages.CompletionItemKind.Text,
490
+ insertText: currentWord,
491
+ documentation: docMarkdown(`Search for "${currentWord}" in any field\n\nType additional words to search for multiple terms.`),
492
+ detail: 'Text search mode',
493
+ sortText: `1_search_${currentWord}` // Sort after fields
494
+ });
495
+
496
+ return { suggestions };
497
+ }
498
+
499
+ // Search mode suggestions (for subsequent words when no operators detected)
500
+ if (!hasOperators && meaningfulTokens.length > 1) {
501
+ // After first word in search mode, only suggest search continuation
502
+ if (/^[a-zA-Z0-9]*$/.test(currentWord)) {
503
+ suggestions.push({
504
+ label: `"${currentWord || 'term'}" (continue search)`,
505
+ kind: monaco.languages.CompletionItemKind.Text,
506
+ insertText: currentWord || '',
507
+ documentation: docMarkdown(`Add "${currentWord || 'term'}" as additional search term\n\nAll terms must be found in the record for it to match.`),
508
+ detail: 'Additional search term',
509
+ sortText: `0_search_continue`
510
+ });
511
+ }
512
+
513
+ return { suggestions };
514
+ }
515
+
516
+ // Structured query mode (existing logic)
517
+ if (context.needsOperator && context.currentField) {
518
+ // After a field name, show operators
519
+ suggestions = getOperatorSuggestions(fieldNames[context.currentField], position, model);
520
+ } else if (context.needsValue && context.currentField && fieldNames[context.currentField]) {
521
+ // After an operator, show values
522
+ suggestions = getValueSuggestions(fieldNames[context.currentField], context.currentField);
523
+ } else if (context.needsField || context.afterLogical || (tokens.length === 1 && /^[a-zA-Z]+$/.test(tokens[0]) && !fieldPattern.test(tokens[0]))) {
524
+ // Only show field suggestions if:
525
+ // 1. We're at the start of a query, or
526
+ // 2. After a logical operator (AND/OR), or
527
+ // 3. We're typing something that isn't a complete field name yet
528
+ if (!prevToken || logicalPattern.test(prevToken) || (currentWord && !fieldPattern.test(currentWord))) {
529
+ // Filter field list by the current word if it's an alphabetical string (including single characters)
530
+ const matchingFields = /^[a-zA-Z]+$/.test(currentWord) && currentWord.length >= 1
531
+ ? fieldList.filter(f => f.toLowerCase().startsWith(currentWord.toLowerCase()))
532
+ : fieldList;
533
+
534
+ suggestions = matchingFields.map(f => ({
535
+ label: f,
536
+ kind: monaco.languages.CompletionItemKind.Field,
537
+ insertText: `${f} `,
538
+ documentation: docMarkdown(descriptions[f] || ''),
539
+ sortText: getSortText('field', f),
540
+ command: { id: 'editor.action.triggerSuggest' }
541
+ }));
542
+ } else {
543
+ suggestions = [];
544
+ }
545
+ } else if (context.inList && context.currentField) {
546
+ // Handle IN list suggestions...
547
+ const field = fieldNames[context.currentField];
548
+ if (!field) return { suggestions: [] };
549
+
550
+ // Extract existing values
551
+ const listValues = new Set();
552
+ const listStart = tokens.findIndex(t => t === '[');
553
+ if (listStart !== -1) {
554
+ tokens.slice(listStart + 1)
555
+ .filter(t => t !== ',' && t !== '[')
556
+ .forEach(t => listValues.add(t.replace(/^"(.*)"$/, '$1')));
557
+ }
558
+
559
+ // Filter out used values and add remaining ones
560
+ if (field.type === 'string' && field.values) {
561
+ const remainingValues = field.values.filter(v => !listValues.has(v));
562
+ suggestions = remainingValues.map(v => ({
563
+ label: v === 'NULL' ? 'NULL' : `"${v}"`,
564
+ kind: monaco.languages.CompletionItemKind.Value,
565
+ insertText: v === 'NULL' ? 'NULL' : `"${v}"`,
566
+ documentation: v === 'NULL' ?
567
+ docMarkdown('Special keyword for null/undefined/empty values') :
568
+ docMarkdown(`String value "${v}"`),
569
+ sortText: getSortText('value', v)
570
+ }));
571
+ } else if (field.type === 'string' && !field.values) {
572
+ // For string fields without predefined values in IN lists, suggest empty quotes
573
+ suggestions.push({
574
+ label: '""',
575
+ kind: monaco.languages.CompletionItemKind.Value,
576
+ insertText: '"${1}"',
577
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
578
+ documentation: docMarkdown('Enter a string value for the list'),
579
+ sortText: getSortText('value', '""'),
580
+ detail: 'Free text string'
581
+ });
582
+ } else if (field.type === 'number') {
583
+ // First add the hint suggestion
584
+ suggestions.push({
585
+ label: '(a number)',
586
+ kind: monaco.languages.CompletionItemKind.Text,
587
+ insertText: '', // Don't insert anything when selected
588
+ documentation: docMarkdown(
589
+ field.range
590
+ ? `Enter a number${field.range.min !== undefined ? ` ≥ ${field.range.min}` : ''}${field.range.max !== undefined ? ` ≤ ${field.range.max}` : ''}`
591
+ : 'Enter a number'
592
+ ),
593
+ sortText: getSortText('value', '0'),
594
+ preselect: false,
595
+ filterText: ''
596
+ });
597
+
598
+ // Then add some reasonable values if we have range info
599
+ if (field.range) {
600
+ const values = new Set();
601
+ if (field.range.min !== undefined) values.add(field.range.min);
602
+ if (field.range.max !== undefined) values.add(field.range.max);
603
+ // Add some values in between if we have both min and max
604
+ if (field.range.min !== undefined && field.range.max !== undefined) {
605
+ const mid = Math.floor((field.range.min + field.range.max) / 2);
606
+ values.add(mid);
607
+ }
608
+ suggestions.push(...Array.from(values).map(v => ({
609
+ label: v.toString(),
610
+ kind: monaco.languages.CompletionItemKind.Value,
611
+ insertText: v.toString(),
612
+ documentation: docMarkdown(`Number value ${v}`),
613
+ sortText: getSortText('value', v.toString())
614
+ })));
615
+ }
616
+ }
617
+
618
+ // Add comma if we have values and aren't right after a comma
619
+ if (suggestions.length > 0 && tokens[tokens.length - 1] !== ',') {
620
+ suggestions.unshift({
621
+ label: ',',
622
+ kind: monaco.languages.CompletionItemKind.Operator,
623
+ insertText: operatorInsertText(', ', position, model),
624
+ documentation: docMarkdown('Add another value'),
625
+ sortText: getSortText('list', ','),
626
+ command: { id: 'editor.action.triggerSuggest' }
627
+ });
628
+ }
629
+ } else if (/[\])]$/.test(tokens[tokens.length - 1]) || /^".*"|\d+|true|false$/i.test(tokens[tokens.length - 1])) {
630
+ // After a complete value or closing bracket/parenthesis, suggest logical operators
631
+ suggestions = ['AND', 'OR'].map(op => ({
632
+ label: op,
633
+ kind: monaco.languages.CompletionItemKind.Keyword,
634
+ insertText: operatorInsertText(`${op} `, position, model),
635
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
636
+ documentation: docMarkdown(descriptions[op]),
637
+ sortText: getSortText('logical', op),
638
+ command: { id: 'editor.action.triggerSuggest' }
639
+ }));
640
+ }
641
+
642
+ return { suggestions };
643
+ }
644
+ });
645
+
646
+ return {
647
+ provider: completionProvider,
648
+ setupAutoInsertBrackets
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Sets up the token provider for syntax highlighting
654
+ * @param {object} monaco The Monaco editor instance
655
+ * @param {object} options Configuration options
656
+ * @param {object} options.fieldNames The field name definitions
657
+ */
658
+ function setupTokenProvider(monaco, { fieldNames, languageId }) {
659
+ // Create pattern for field names - add word boundary only at end to allow partial matches
660
+ const fieldPattern = `\\b(${Object.keys(fieldNames).join('|')})\\b`;
661
+
662
+ monaco.languages.setMonarchTokensProvider(languageId, {
663
+ // Define the states
664
+ defaultToken: '',
665
+ tokenPostfix: '.querylang',
666
+
667
+ // Track values in arrays for duplicate detection
668
+ brackets: [
669
+ { open: '[', close: ']', token: 'delimiter.square' },
670
+ { open: '(', close: ')', token: 'delimiter.parenthesis' }
671
+ ],
672
+
673
+ keywords: ['AND', 'OR', 'IN'],
674
+ operators: ['=', '!=', '>=', '<=', '>', '<'],
675
+
676
+ tokenizer: {
677
+ root: [
678
+ // Keywords and operators (most specific word-based matches first)
679
+ [/\b(AND|OR)\b/, 'keyword'],
680
+ [/\b(IN)\b/, { token: 'operator', next: '@inArray' }],
681
+ [/\b(true|false)\b/, 'boolean'],
682
+ [/\b(NULL)\b/, 'keyword.null'],
683
+
684
+ // Operators and delimiters
685
+ [/(=|!=|>=|<=|>|<)/, 'operator'],
686
+ [/\(|\)/, 'delimiter.parenthesis'],
687
+ [/\[/, { token: 'delimiter.square', next: '@inArray' }],
688
+ [/\]/, 'delimiter.square'],
689
+
690
+ // Field names (after keywords to avoid conflicts)
691
+ [new RegExp(fieldPattern), 'identifier'],
692
+
693
+ // Literals (after operators to avoid partial matches)
694
+ [/"(?:[^"\\]|\\.)*"/, 'string'],
695
+ [/-?\d+(?:\.\d+)?/, 'number'],
696
+
697
+ // Free text/search terms (words that don't match above patterns)
698
+ [/[a-zA-Z0-9_]+/, 'string.search'],
699
+
700
+ // Whitespace
701
+ [/\s+/, 'white']
702
+ ],
703
+
704
+ inArray: [
705
+ [/\s+/, 'white'],
706
+ [/,/, 'delimiter.comma'],
707
+ [/\]/, { token: 'delimiter.square', next: '@pop' }],
708
+ [/"(?:[^"\\]|\\.)*"/, 'string.array'],
709
+ [/-?\d+(?:\.\d+)?/, 'number.array']
710
+ ]
711
+ }
712
+ });
713
+ }
714
+
715
+ /**
716
+ * Sets up validation for the query language
717
+ * @param {object} monaco The Monaco editor instance
718
+ * @param {object} options Configuration options
719
+ * @param {object} options.fieldNames The field name definitions
720
+ */
721
+ function setupValidation(monaco, { fieldNames, languageId }) {
722
+ // Prevent duplicate validation setup for the same language ID
723
+ if (monaco._validationSetup && monaco._validationSetup[languageId]) {
724
+ return monaco._validationSetup[languageId];
725
+ }
726
+
727
+ if (!monaco._validationSetup) {
728
+ monaco._validationSetup = {};
729
+ }
730
+
731
+ // Cache for tokenization and validation results
732
+ const tokenCache = new Map();
733
+ const validationCache = new Map();
734
+
735
+ // Enhanced tokenizer
736
+ function tokenize(str) {
737
+ // Check cache first
738
+ const cached = tokenCache.get(str);
739
+ if (cached) {
740
+ return cached;
741
+ }
742
+
743
+ const tokens = [];
744
+ let position = 0;
745
+
746
+ while (position < str.length) {
747
+ // Skip whitespace
748
+ if (/\s/.test(str[position])) {
749
+ position++;
750
+ continue;
751
+ }
752
+
753
+ let match = null;
754
+ let value = '';
755
+ let type = '';
756
+ let tokenStart = position;
757
+
758
+ // Check for specific patterns in order of priority
759
+
760
+ // 1. Operators (multi-character first)
761
+ if (str.substring(position).match(/^(!=|>=|<=)/)) {
762
+ const op = str.substring(position).match(/^(!=|>=|<=)/)[0];
763
+ value = op;
764
+ type = 'operator';
765
+ position += op.length;
766
+ }
767
+ // 2. Single character operators
768
+ else if (/[=<>]/.test(str[position])) {
769
+ value = str[position];
770
+ type = 'operator';
771
+ position++;
772
+ }
773
+ // 3. Punctuation
774
+ else if (/[(),\[\]]/.test(str[position])) {
775
+ value = str[position];
776
+ type = 'punctuation';
777
+ position++;
778
+ }
779
+ // 4. Comma
780
+ else if (str[position] === ',') {
781
+ value = ',';
782
+ type = 'punctuation';
783
+ position++;
784
+ }
785
+ // 5. Quoted strings (including unclosed ones)
786
+ else if (str[position] === '"') {
787
+ let endQuoteFound = false;
788
+ let stringEnd = position + 1;
789
+
790
+ // Look for closing quote, handling escaped quotes
791
+ while (stringEnd < str.length) {
792
+ if (str[stringEnd] === '"' && str[stringEnd - 1] !== '\\') {
793
+ endQuoteFound = true;
794
+ stringEnd++;
795
+ break;
796
+ }
797
+ stringEnd++;
798
+ }
799
+
800
+ value = str.substring(position, stringEnd);
801
+ type = endQuoteFound ? 'string' : 'unclosed-string';
802
+ position = stringEnd;
803
+ }
804
+ // 6. Numbers
805
+ else if (/\d/.test(str[position]) || (str[position] === '-' && /\d/.test(str[position + 1]))) {
806
+ const numberMatch = str.substring(position).match(/^-?\d*\.?\d+/);
807
+ if (numberMatch) {
808
+ value = numberMatch[0];
809
+ type = 'number';
810
+ position += value.length;
811
+ } else {
812
+ // Fallback - treat as identifier
813
+ const identifierMatch = str.substring(position).match(/^\w+/);
814
+ value = identifierMatch ? identifierMatch[0] : str[position];
815
+ type = 'identifier';
816
+ position += value.length;
817
+ }
818
+ }
819
+ // 7. Keywords and identifiers
820
+ else if (/[a-zA-Z_]/.test(str[position])) {
821
+ const wordMatch = str.substring(position).match(/^[a-zA-Z_]\w*/);
822
+ if (wordMatch) {
823
+ value = wordMatch[0];
824
+
825
+ // Check for keywords (case-sensitive for logical operators)
826
+ if (['AND', 'OR'].includes(value)) { // Case-sensitive check
827
+ type = 'keyword';
828
+ } else if (value === 'IN') { // Case-sensitive
829
+ type = 'keyword';
830
+ } else if (['true', 'false'].includes(value.toLowerCase())) {
831
+ type = 'boolean';
832
+ } else if (value.toLowerCase() === 'null') {
833
+ type = 'null';
834
+ } else {
835
+ type = 'identifier';
836
+ }
837
+
838
+ position += value.length;
839
+ } else {
840
+ // Single character fallback
841
+ value = str[position];
842
+ type = 'identifier';
843
+ position++;
844
+ }
845
+ }
846
+ // 8. Fallback for any other character
847
+ else {
848
+ value = str[position];
849
+ type = 'identifier';
850
+ position++;
851
+ }
852
+
853
+ if (value) {
854
+ tokens.push({
855
+ value,
856
+ type,
857
+ start: tokenStart,
858
+ end: position
859
+ });
860
+ }
861
+ }
862
+
863
+ // Cache the result if it's not too large (prevent memory issues)
864
+ if (str.length < 10000) {
865
+ tokenCache.set(str, tokens);
866
+ }
867
+
868
+ return tokens;
869
+ }
870
+
871
+ // Helper to get token type with enhanced pattern recognition
872
+ function getTokenType(value) {
873
+ if (/^-?\d*\.?\d+$/.test(value)) return 'number';
874
+ if (/^".*"$/.test(value)) return 'string';
875
+ if (/^"/.test(value) && !value.endsWith('"')) return 'unclosed-string';
876
+ if (/^(true|false)$/i.test(value)) return 'boolean';
877
+ if (/^(null)$/i.test(value)) return 'null';
878
+ if (/^(AND|OR)$/.test(value)) return 'keyword'; // Case-sensitive check for logical operators
879
+ if (value === 'IN') return 'keyword'; // Case-sensitive check for IN operator
880
+ if (/^[=!<>]=?$/.test(value)) return 'operator';
881
+ if (/^[\[\](),]$/.test(value)) return 'punctuation';
882
+ return 'identifier';
883
+ }
884
+
885
+ // Helper function to find the field name before an IN operator
886
+ function findFieldBeforeIN(tokens, inStartIndex) {
887
+ // Walk backwards from the IN token to find the field name
888
+ for (let i = inStartIndex - 1; i >= 0; i--) {
889
+ const token = tokens[i];
890
+ // Check if it's a valid field name (identifier that matches our field names)
891
+ if (token.type === 'identifier' && fieldNames[token.value]) {
892
+ return token.value;
893
+ }
894
+ // Stop if we hit another operator or the start
895
+ if (token.type === 'operator' || i === 0) {
896
+ break;
897
+ }
898
+ }
899
+ return null;
900
+ }
901
+
902
+ // Validate string values
903
+ function validateStringValue(value, token, markers) {
904
+ // Check for unclosed quotes
905
+ if (token.type === 'unclosed-string') {
906
+ markers.push({
907
+ severity: monaco.MarkerSeverity.Error,
908
+ message: 'Unclosed string literal. Did you forget a closing quote?',
909
+ startLineNumber: 1,
910
+ startColumn: token.start + 1,
911
+ endLineNumber: 1,
912
+ endColumn: token.end + 1
913
+ });
914
+ return false;
915
+ }
916
+
917
+ // Check for properly escaped quotes
918
+ const unescapedQuotes = value.slice(1, -1).match(/(?<!\\)"/g);
919
+ if (unescapedQuotes) {
920
+ markers.push({
921
+ severity: monaco.MarkerSeverity.Error,
922
+ message: 'Unescaped quote in string literal. Use \\" for quotes inside strings.',
923
+ startLineNumber: 1,
924
+ startColumn: token.start + 1,
925
+ endLineNumber: 1,
926
+ endColumn: token.end + 1
927
+ });
928
+ return false;
929
+ }
930
+
931
+ // Check for invalid escape sequences
932
+ const invalidEscapes = value.slice(1, -1).match(/\\(?!["\\/bfnrt])/g);
933
+ if (invalidEscapes) {
934
+ markers.push({
935
+ severity: monaco.MarkerSeverity.Error,
936
+ message: 'Invalid escape sequence. Valid escapes are: \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t',
937
+ startLineNumber: 1,
938
+ startColumn: token.start + 1,
939
+ endLineNumber: 1,
940
+ endColumn: token.end + 1
941
+ });
942
+ return false;
943
+ }
944
+
945
+ return true;
946
+ }
947
+
948
+ // Validate number value
949
+ function validateNumberValue(value, field, token, markers) {
950
+ // Check if it's a valid number
951
+ if (!/^-?\d*\.?\d+$/.test(value)) {
952
+ markers.push({
953
+ severity: monaco.MarkerSeverity.Error,
954
+ message: `Invalid number format: ${value}`,
955
+ startLineNumber: 1,
956
+ startColumn: token.start + 1,
957
+ endLineNumber: 1,
958
+ endColumn: token.end + 1
959
+ });
960
+ return false;
961
+ }
962
+
963
+ // If field has range validation
964
+ if (field.range) {
965
+ const num = parseFloat(value);
966
+ if (field.range.min !== undefined && num < field.range.min) {
967
+ markers.push({
968
+ severity: monaco.MarkerSeverity.Error,
969
+ message: `Value must be greater than or equal to ${field.range.min}`,
970
+ startLineNumber: 1,
971
+ startColumn: token.start + 1,
972
+ endLineNumber: 1,
973
+ endColumn: token.end + 1
974
+ });
975
+ return false;
976
+ }
977
+ if (field.range.max !== undefined && num > field.range.max) {
978
+ markers.push({
979
+ severity: monaco.MarkerSeverity.Error,
980
+ message: `Value must be less than or equal to ${field.range.max}`,
981
+ startLineNumber: 1,
982
+ startColumn: token.start + 1,
983
+ endLineNumber: 1,
984
+ endColumn: token.end + 1
985
+ });
986
+ return false;
987
+ }
988
+ }
989
+
990
+ return true;
991
+ }
992
+
993
+ // Helper to validate IN list structure and values
994
+ function validateInList(tokens, startIndex, markers) {
995
+ let inList = false;
996
+ let valueCount = 0;
997
+ let hasTrailingComma = false;
998
+ let bracketBalance = 0;
999
+ let arrayStart = -1;
1000
+
1001
+ // Store values and their positions
1002
+ let values = [];
1003
+
1004
+ // Find the field name associated with this IN list
1005
+ const currentListField = findFieldBeforeIN(tokens, startIndex);
1006
+ const fieldDef = currentListField ? fieldNames[currentListField] : null;
1007
+ let hasErrors = false;
1008
+
1009
+ // Function to check for duplicates in the collected values
1010
+ function checkForDuplicates() {
1011
+ for (let i = 0; i < values.length; i++) {
1012
+ for (let j = i + 1; j < values.length; j++) {
1013
+ const a = values[i];
1014
+ const b = values[j];
1015
+
1016
+ let isDuplicate = false;
1017
+ if (a.type === 'number' && b.type === 'number') {
1018
+ // Compare numbers with fixed precision
1019
+ isDuplicate = Number(a.value).toFixed(10) === Number(b.value).toFixed(10);
1020
+ } else {
1021
+ // Direct comparison for strings and booleans
1022
+ isDuplicate = a.value === b.value;
1023
+ }
1024
+
1025
+ if (isDuplicate) {
1026
+ // Mark the first occurrence
1027
+ markers.push({
1028
+ severity: monaco.MarkerSeverity.Error,
1029
+ message: 'This value is duplicated later in the list',
1030
+ startLineNumber: 1,
1031
+ startColumn: a.token.start + 1,
1032
+ endLineNumber: 1,
1033
+ endColumn: a.token.end + 1
1034
+ });
1035
+
1036
+ // Mark the duplicate
1037
+ markers.push({
1038
+ severity: monaco.MarkerSeverity.Error,
1039
+ message: `Duplicate value ${b.value} in IN list`,
1040
+ startLineNumber: 1,
1041
+ startColumn: b.token.start + 1,
1042
+ endLineNumber: 1,
1043
+ endColumn: b.token.end + 1
1044
+ });
1045
+
1046
+ hasErrors = true;
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ for (let i = startIndex; i < tokens.length; i++) {
1053
+ const token = tokens[i];
1054
+ const value = token.value;
1055
+
1056
+ if (value === '[') {
1057
+ if (inList) {
1058
+ markers.push({
1059
+ severity: monaco.MarkerSeverity.Error,
1060
+ message: 'Unexpected opening bracket inside IN list',
1061
+ startLineNumber: 1,
1062
+ startColumn: token.start + 1,
1063
+ endLineNumber: 1,
1064
+ endColumn: token.end + 1
1065
+ });
1066
+ hasErrors = true;
1067
+ }
1068
+ inList = true;
1069
+ bracketBalance++;
1070
+ arrayStart = token.start;
1071
+ continue;
1072
+ }
1073
+
1074
+ if (!inList) continue;
1075
+
1076
+ if (value === ']') {
1077
+ bracketBalance--;
1078
+ // Check for duplicate values before exiting
1079
+ checkForDuplicates();
1080
+ break;
1081
+ }
1082
+
1083
+ if (value === ',') {
1084
+ hasTrailingComma = true;
1085
+ continue;
1086
+ }
1087
+
1088
+ hasTrailingComma = false;
1089
+ if (['string', 'number', 'boolean'].includes(token.type)) {
1090
+ valueCount++;
1091
+
1092
+ // Check for allowed values if field has specific values defined
1093
+ if (fieldDef && fieldDef.values && fieldDef.type === 'string' && token.type === 'string') {
1094
+ // Remove quotes from string value to compare with allowed values
1095
+ const stringValue = token.value.slice(1, -1);
1096
+ if (!fieldDef.values.includes(stringValue)) {
1097
+ let message;
1098
+ if (fieldDef.values.length <= 3) {
1099
+ // Show all values if there are 10 or fewer
1100
+ message = `Value "${stringValue}" is not one of the allowed values: [${fieldDef.values.join(', ')}]`;
1101
+ } else {
1102
+ // Show first few values and indicate there are more
1103
+ const preview = fieldDef.values.slice(0, 3).join(', ');
1104
+ message = `Value "${stringValue}" is not one of the allowed values. Expected one of: ${preview}... (${fieldDef.values.length} total values)`;
1105
+ }
1106
+ markers.push({
1107
+ severity: monaco.MarkerSeverity.Warning,
1108
+ message: message,
1109
+ startLineNumber: 1,
1110
+ startColumn: token.start + 1,
1111
+ endLineNumber: 1,
1112
+ endColumn: token.end + 1
1113
+ });
1114
+ }
1115
+ }
1116
+
1117
+ values.push({
1118
+ value: token.value,
1119
+ token: token,
1120
+ type: token.type
1121
+ });
1122
+ }
1123
+ }
1124
+
1125
+ return !hasErrors;
1126
+ }
1127
+
1128
+ // Track the last validation state
1129
+ let lastValidationState = {
1130
+ content: '',
1131
+ tokens: [],
1132
+ markers: [],
1133
+ hasErrors: false
1134
+ };
1135
+
1136
+ // Helper to calculate validation state hash
1137
+ function getValidationHash(str) {
1138
+ let hash = 0;
1139
+ for (let i = 0; i < str.length; i++) {
1140
+ const char = str.charCodeAt(i);
1141
+ hash = ((hash << 5) - hash) + char;
1142
+ hash = hash & hash;
1143
+ }
1144
+ return hash;
1145
+ }
1146
+
1147
+ // Helper to check if position is in the middle of complete content
1148
+ function isPositionInMiddle(model, position) {
1149
+ const lineCount = model.getLineCount();
1150
+ const lastLineLength = model.getLineLength(lineCount);
1151
+
1152
+ return position.lineNumber < lineCount ||
1153
+ (position.lineNumber === lineCount && position.column < lastLineLength);
1154
+ }
1155
+
1156
+ // Helper to get tokens up to position
1157
+ function getTokensUpToPosition(tokens, position, model) {
1158
+ if (!position) return tokens;
1159
+
1160
+ const offset = model.getOffsetAt(position);
1161
+ return tokens.filter(token => token.end <= offset);
1162
+ }
1163
+
1164
+ // Main validation function with incremental updates
1165
+ function validateQuery(model, position) {
1166
+ const value = model.getValue();
1167
+
1168
+ // Quick check if content hasn't changed
1169
+ if (value === lastValidationState.content) {
1170
+ monaco.editor.setModelMarkers(model, languageId, lastValidationState.markers);
1171
+ return;
1172
+ }
1173
+
1174
+ // Check cache for identical content
1175
+ const validationHash = getValidationHash(value);
1176
+ const cached = validationCache.get(validationHash);
1177
+ if (cached && !position) { // Only use cache if we don't need position-aware validation
1178
+ monaco.editor.setModelMarkers(model, languageId, cached);
1179
+ lastValidationState = {
1180
+ content: value,
1181
+ tokens: tokenCache.get(value) || [],
1182
+ markers: cached,
1183
+ hasErrors: cached.length > 0
1184
+ };
1185
+ return;
1186
+ }
1187
+
1188
+ const markers = [];
1189
+ const tokens = tokenize(value);
1190
+
1191
+ // Detect if this is search mode or structured query mode
1192
+ const hasOperators = tokens.some(token =>
1193
+ ['=', '!=', '>', '<', '>=', '<=', 'IN', 'AND', 'OR', '(', ')'].includes(token.value)
1194
+ );
1195
+
1196
+ // If no operators found, treat as search mode (no validation needed)
1197
+ if (!hasOperators && tokens.length > 0) {
1198
+ // Search mode - just check for unclosed strings
1199
+ tokens.forEach(token => {
1200
+ if (token.type === 'unclosed-string') {
1201
+ addError(token, 'Unclosed string literal');
1202
+ }
1203
+ });
1204
+
1205
+ // Cache and store validation result
1206
+ validationCache.set(validationHash, markers);
1207
+ lastValidationState = {
1208
+ content: value,
1209
+ tokens,
1210
+ markers,
1211
+ hasErrors: markers.length > 0
1212
+ };
1213
+ monaco.editor.setModelMarkers(model, languageId, markers);
1214
+ return;
1215
+ }
1216
+
1217
+ // Helper to add error marker
1218
+ function addError(token, message) {
1219
+ markers.push({
1220
+ severity: monaco.MarkerSeverity.Error,
1221
+ message,
1222
+ startLineNumber: 1,
1223
+ startColumn: token.start + 1,
1224
+ endLineNumber: 1,
1225
+ endColumn: token.end + 1
1226
+ });
1227
+ }
1228
+
1229
+ // Helper to add warning marker
1230
+ function addWarning(token, message) {
1231
+ markers.push({
1232
+ severity: monaco.MarkerSeverity.Warning,
1233
+ message,
1234
+ startLineNumber: 1,
1235
+ startColumn: token.start + 1,
1236
+ endLineNumber: 1,
1237
+ endColumn: token.end + 1
1238
+ });
1239
+ }
1240
+
1241
+ // State tracking
1242
+ let expressionState = {
1243
+ hasField: false,
1244
+ hasOperator: false,
1245
+ hasValue: false,
1246
+ currentField: null,
1247
+ lastValueToken: null,
1248
+ inParentheses: false,
1249
+ parenthesesBalance: 0,
1250
+ reset() {
1251
+ this.hasField = false;
1252
+ this.hasOperator = false;
1253
+ this.hasValue = false;
1254
+ this.currentField = null;
1255
+ this.lastValueToken = null;
1256
+ }
1257
+ };
1258
+
1259
+ // Optimize token validation by caching field lookups
1260
+ const fieldCache = new Map();
1261
+ function isValidField(token) {
1262
+ if (fieldCache.has(token)) {
1263
+ return fieldCache.get(token);
1264
+ }
1265
+ // Only allow defined field names - remove the fallback regex
1266
+ const isValid = fieldNames[token] !== undefined;
1267
+ fieldCache.set(token, isValid);
1268
+ return isValid;
1269
+ }
1270
+
1271
+ // Track parentheses for complex expressions
1272
+ let parenthesesStack = [];
1273
+
1274
+ // Validate each token with enhanced state tracking
1275
+ tokens.forEach((token, index) => {
1276
+ const current = token.value.toUpperCase();
1277
+ const prev = index > 0 ? tokens[index - 1].value : '';
1278
+ const next = index < tokens.length - 1 ? tokens[index + 1].value : '';
1279
+
1280
+ // Track parentheses state
1281
+ if (current === '(') {
1282
+ expressionState.inParentheses = true;
1283
+ expressionState.parenthesesBalance++;
1284
+ parenthesesStack.push(expressionState.parenthesesBalance);
1285
+ } else if (current === ')') {
1286
+ expressionState.parenthesesBalance--;
1287
+ parenthesesStack.pop();
1288
+ if (expressionState.parenthesesBalance < 0) {
1289
+ addError(token, 'Unmatched closing parenthesis');
1290
+ return;
1291
+ }
1292
+ expressionState.inParentheses = expressionState.parenthesesBalance > 0;
1293
+ }
1294
+
1295
+ // Reset expression state after logical operators (only uppercase ones are valid)
1296
+ if (['AND', 'OR'].includes(token.value)) {
1297
+ // Check if we have a complete expression before the logical operator
1298
+ const hasCompleteExpression = expressionState.hasValue ||
1299
+ (prev === ']' && tokens.slice(0, index).some(t => t.value.toUpperCase() === 'IN'));
1300
+ if (!hasCompleteExpression && !expressionState.inParentheses) {
1301
+ addError(token, 'Incomplete expression before logical operator');
1302
+ }
1303
+ expressionState.reset();
1304
+ return;
1305
+ }
1306
+
1307
+ // Check if we're expecting a logical operator after a complete expression
1308
+ if (token.type === 'identifier' && expressionState.hasValue) {
1309
+ // We just completed an expression (field = value), so we expect a logical operator
1310
+ if (['and', 'or'].includes(token.value.toLowerCase())) {
1311
+ // This is a logical operator but in wrong case
1312
+ addError(token, `Logical operator must be uppercase. Use '${token.value.toUpperCase()}' instead of '${token.value}'.`);
1313
+ return;
1314
+ } else if (!['AND', 'OR'].includes(token.value.toUpperCase())) {
1315
+ // This is not a logical operator at all, but we expected one
1316
+ addError(token, `Expected logical operator (AND/OR) after complete expression, but found '${token.value}'.`);
1317
+ return;
1318
+ }
1319
+ }
1320
+
1321
+ // Enhanced field name validation
1322
+ if (token.type === 'identifier' && !['AND', 'OR', 'IN', 'TRUE', 'FALSE', 'NULL'].includes(token.value)) {
1323
+ // Check for lowercase logical operators first
1324
+ if (['and', 'or'].includes(token.value.toLowerCase()) && token.value !== token.value.toUpperCase()) {
1325
+ addError(token, `Logical operator must be uppercase. Use '${token.value.toUpperCase()}' instead of '${token.value}'.`);
1326
+ return;
1327
+ }
1328
+
1329
+ // Check if this is a valid field name
1330
+ if (!isValidField(token.value)) {
1331
+ // Check if we're in a position where a field name is expected
1332
+ const expectingField = !expressionState.hasField ||
1333
+ (index > 0 && ['AND', 'OR'].includes(tokens[index - 1].value.toUpperCase()));
1334
+
1335
+ if (expectingField) {
1336
+ const availableFields = Object.keys(fieldNames);
1337
+ let suggestion = '';
1338
+ if (availableFields.length > 0) {
1339
+ // Find the closest matching field name
1340
+ const closest = availableFields.find(f =>
1341
+ f.toLowerCase().includes(token.value.toLowerCase()) ||
1342
+ token.value.toLowerCase().includes(f.toLowerCase())
1343
+ );
1344
+ if (closest) {
1345
+ suggestion = ` Did you mean '${closest}'?`;
1346
+ } else {
1347
+ const fieldList = availableFields.length <= 5
1348
+ ? availableFields.join(', ')
1349
+ : availableFields.slice(0, 5).join(', ') + '...';
1350
+ suggestion = ` Available fields: ${fieldList}`;
1351
+ }
1352
+ }
1353
+ addError(token, `Unknown field name '${token.value}'.${suggestion}`);
1354
+ }
1355
+ } else {
1356
+ // Valid field name
1357
+ if (expressionState.hasField && !expressionState.hasValue && !['AND', 'OR'].includes(prev)) {
1358
+ addError(token, 'Unexpected field name. Did you forget an operator or AND/OR?');
1359
+ }
1360
+ expressionState.hasField = true;
1361
+ expressionState.currentField = token.value;
1362
+ }
1363
+ }
1364
+
1365
+ // Enhanced operator validation
1366
+ if (['=', '!=', '>', '<', '>=', '<='].includes(current)) {
1367
+ if (!expressionState.hasField) {
1368
+ addError(token, 'Operator without a preceding field name');
1369
+ }
1370
+ expressionState.hasOperator = true;
1371
+ expressionState.hasValue = false; // Reset value state when we see an operator
1372
+
1373
+ // Validate operator compatibility with field type
1374
+ if (expressionState.currentField) {
1375
+ const field = fieldNames[expressionState.currentField];
1376
+ if (field && ['>', '<', '>=', '<='].includes(current) && field.type !== 'number') {
1377
+ addError(token, `Operator ${current} can only be used with number fields`);
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // Check for unclosed strings immediately (regardless of expression state)
1383
+ if (token.type === 'unclosed-string') {
1384
+ addError(token, 'Unclosed string literal. Did you forget a closing quote?');
1385
+ }
1386
+
1387
+ // Special handling for IN operator (case-sensitive, uppercase only)
1388
+ if (token.value === 'IN') {
1389
+ if (!expressionState.hasField) {
1390
+ addError(token, 'IN operator without a preceding field name');
1391
+ }
1392
+ expressionState.hasOperator = true;
1393
+ expressionState.hasValue = false;
1394
+ validateInList(tokens, index + 1, markers);
1395
+ }
1396
+
1397
+ // Value validation with type checking
1398
+ if ((token.type === 'string' || token.type === 'number' || token.type === 'boolean' ||
1399
+ token.type === 'null' || token.type === 'unclosed-string') && expressionState.hasOperator) {
1400
+ if (expressionState.currentField) {
1401
+ const field = fieldNames[expressionState.currentField];
1402
+ if (field) {
1403
+ // NULL is allowed for any field type (represents absence of value)
1404
+ if (token.type === 'null') {
1405
+ // NULL is valid for any field, skip type validation
1406
+ } else if (field.type === 'string' && token.type !== 'string' && token.type !== 'unclosed-string') {
1407
+ addError(token, `Value must be a string for field '${expressionState.currentField}'`);
1408
+ } else if (field.type === 'number' && token.type !== 'number') {
1409
+ addError(token, `Value must be a number for field '${expressionState.currentField}'`);
1410
+ } else if (field.type === 'boolean' && token.type !== 'boolean') {
1411
+ addError(token, `Value must be a boolean for field '${expressionState.currentField}'`);
1412
+ } else {
1413
+ // Check for allowed values if field has specific values defined
1414
+ if (field.values && field.type === 'string' && token.type === 'string') {
1415
+ // Remove quotes from string value to compare with allowed values
1416
+ const stringValue = token.value.slice(1, -1);
1417
+ if (!field.values.includes(stringValue)) {
1418
+ let message;
1419
+ if (field.values.length <= 2) {
1420
+ // Show all values if there are 10 or fewer
1421
+ message = `Value "${stringValue}" is not one of the allowed values: [${field.values.join(', ')}]`;
1422
+ } else {
1423
+ // Show first few values and indicate there are more
1424
+ const preview = field.values.slice(0, 5).join(', ');
1425
+ message = `Value "${stringValue}" is not one of the allowed values. Expected one of: ${preview}... (${field.values.length} total values)`;
1426
+ }
1427
+ addWarning(token, message);
1428
+ }
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+ expressionState.hasValue = true;
1434
+ expressionState.lastValueToken = token;
1435
+
1436
+ // Check for consecutive tokens without proper logical operators
1437
+ if (index < tokens.length - 1) {
1438
+ const nextToken = tokens[index + 1];
1439
+ if (nextToken.type === 'identifier' && !['AND', 'OR'].includes(nextToken.value.toUpperCase())) {
1440
+ // We have a value followed immediately by an identifier that's not a logical operator
1441
+ addError(nextToken, `Unexpected token '${nextToken.value}' after value. Did you forget a logical operator (AND/OR)?`);
1442
+ }
1443
+ }
1444
+ }
1445
+ });
1446
+
1447
+ // Final validation checks
1448
+ if (expressionState.parenthesesBalance > 0) {
1449
+ markers.push({
1450
+ severity: monaco.MarkerSeverity.Error,
1451
+ message: 'Unclosed parentheses in expression',
1452
+ startLineNumber: 1,
1453
+ startColumn: 1,
1454
+ endLineNumber: 1,
1455
+ endColumn: value.length + 1
1456
+ });
1457
+ }
1458
+
1459
+ // Accept valid expressions ending with string/number/boolean/null
1460
+ const lastToken = tokens[tokens.length - 1];
1461
+ const validEndTypes = ['string', 'number', 'boolean', 'null'];
1462
+ if (
1463
+ tokens.length > 0 &&
1464
+ !expressionState.hasValue &&
1465
+ !expressionState.inParentheses &&
1466
+ validEndTypes.includes(lastToken.type) &&
1467
+ expressionState.hasField &&
1468
+ expressionState.hasOperator
1469
+ ) {
1470
+ expressionState.hasValue = true;
1471
+ }
1472
+
1473
+ if (tokens.length > 0 && !expressionState.hasValue && !expressionState.inParentheses) {
1474
+ // Only mark as incomplete if we're at the actual end of content
1475
+ // or if the last token is an operator/identifier and there's nothing valid after it
1476
+ if (lastToken.type === 'identifier' || lastToken.type === 'operator') {
1477
+ if (!position || !isPositionInMiddle(model, position)) {
1478
+ addError(lastToken, 'Incomplete expression at end of query');
1479
+ } else {
1480
+ // Check if there's valid content after the cursor
1481
+ const fullTokens = tokenize(value);
1482
+ const tokensAfterCursor = fullTokens.filter(t => t.start >= model.getOffsetAt(position));
1483
+ if (!tokensAfterCursor.some(t => t.type === 'string' || t.type === 'number' || t.type === 'boolean')) {
1484
+ addError(lastToken, 'Incomplete expression at end of query');
1485
+ }
1486
+ }
1487
+ }
1488
+ }
1489
+
1490
+ // Cache validation results
1491
+ if (value.length < 10000) {
1492
+ validationCache.set(validationHash, markers);
1493
+ }
1494
+
1495
+ // Update last validation state
1496
+ lastValidationState = {
1497
+ content: value,
1498
+ tokens,
1499
+ markers,
1500
+ hasErrors: markers.length > 0
1501
+ };
1502
+
1503
+ // Set markers using the specific language ID
1504
+ monaco.editor.setModelMarkers(model, languageId, markers);
1505
+ }
1506
+
1507
+ // Helper function to set up validation for a model
1508
+ const setupModelValidation = (model) => {
1509
+ // Initial validation
1510
+ validateQuery(model);
1511
+
1512
+ // Set up change listener with debouncing
1513
+ const changeDisposable = model.onDidChangeContent((e) => {
1514
+ // Clear previous timeout
1515
+ if (validateTimeout) {
1516
+ clearTimeout(validateTimeout);
1517
+ }
1518
+
1519
+ // Get the cursor position from the last change
1520
+ const position = e.changes[e.changes.length - 1].rangeOffset ? {
1521
+ lineNumber: model.getPositionAt(e.changes[e.changes.length - 1].rangeOffset).lineNumber,
1522
+ column: model.getPositionAt(e.changes[e.changes.length - 1].rangeOffset).column
1523
+ } : null;
1524
+
1525
+ // Set new timeout for validation
1526
+ validateTimeout = setTimeout(() => {
1527
+ validateQuery(model, position);
1528
+ }, 300); // 300ms debounce
1529
+ });
1530
+
1531
+ // Clean up when model is disposed
1532
+ model.onWillDispose(() => {
1533
+ if (validateTimeout) {
1534
+ clearTimeout(validateTimeout);
1535
+ }
1536
+ changeDisposable.dispose();
1537
+ });
1538
+
1539
+ return changeDisposable;
1540
+ };
1541
+
1542
+ // Set up validation for existing models with this language
1543
+ const existingModelDisposables = [];
1544
+ monaco.editor.getModels().forEach(model => {
1545
+ if (model.getLanguageId() === languageId) {
1546
+ const disposable = setupModelValidation(model);
1547
+ existingModelDisposables.push(disposable);
1548
+ }
1549
+ });
1550
+
1551
+ // Set up model change listener for future models
1552
+ let validateTimeout = null;
1553
+ let disposable = monaco.editor.onDidCreateModel(model => {
1554
+ if (model.getLanguageId() === languageId) {
1555
+ setupModelValidation(model);
1556
+ }
1557
+ });
1558
+
1559
+ // Return dispose function
1560
+ const disposeFunction = {
1561
+ dispose: () => {
1562
+ if (validateTimeout) {
1563
+ clearTimeout(validateTimeout);
1564
+ }
1565
+ disposable.dispose();
1566
+ // Dispose existing model listeners
1567
+ existingModelDisposables.forEach(d => d.dispose());
1568
+ // Clean up the registration tracker
1569
+ if (monaco._validationSetup && monaco._validationSetup[languageId]) {
1570
+ delete monaco._validationSetup[languageId];
1571
+ }
1572
+ }
1573
+ };
1574
+
1575
+ // Store the disposal function to prevent duplicate setup
1576
+ monaco._validationSetup[languageId] = disposeFunction;
1577
+
1578
+ return disposeFunction;
1579
+ }
1580
+
1581
+ /**
1582
+ * Sets up the editor theme for the query language
1583
+ * @param {object} monaco The Monaco editor instance
1584
+ */
1585
+ function setupEditorTheme(monaco) {
1586
+ monaco.editor.defineTheme("queryTheme", {
1587
+ base: "vs",
1588
+ inherit: true,
1589
+ rules: [
1590
+ { token: 'identifier', foreground: '795E26', background: 'FFF3D0', fontStyle: 'italic' },
1591
+ { token: 'operator', foreground: 'af00db' },
1592
+ { token: 'boolean', foreground: '5f5757', fontStyle: 'bold' },
1593
+ { token: 'number', foreground: '5f5757', fontStyle: 'bold' },
1594
+ { token: 'string', foreground: '5f5757', fontStyle: 'bold' },
1595
+ { token: 'string.search', foreground: '5f5757', fontStyle: 'bold' },
1596
+ { token: 'keyword', foreground: '007acc', fontStyle: 'bold' },
1597
+ { token: 'keyword.null', foreground: '5f5757', fontStyle: 'bold' }
1598
+ ],
1599
+ colors: {
1600
+ 'editor.foreground': '#5f5757',
1601
+ 'editor.background': '#ffffff'
1602
+ }
1603
+ });
1604
+ }
1605
+
1606
+ /**
1607
+ * Sets up query language support for a Monaco editor instance
1608
+ * @param {object} monaco The Monaco editor instance
1609
+ * @param {object} options Configuration options
1610
+ * @param {object} options.fieldNames The field name definitions with types and valid values
1611
+ * @returns {object} The configured editor features
1612
+ */
1613
+ // Track registered languages and their field schemas with better isolation
1614
+ const registeredLanguages = new Map();
1615
+ let languageCounter = 0;
1616
+
1617
+ // Generate a unique ID for each editor instance
1618
+ function generateUniqueLanguageId(fieldNames) {
1619
+ // Create a hash of the field schema for consistency
1620
+ const sortedFields = Object.entries(fieldNames)
1621
+ .sort(([a], [b]) => a.localeCompare(b))
1622
+ .map(([name, def]) => `${name}:${def.type}:${def.values ? def.values.join('|') : ''}`)
1623
+ .join(',');
1624
+
1625
+ // Create a hash to make it more compact
1626
+ let hash = 0;
1627
+ for (let i = 0; i < sortedFields.length; i++) {
1628
+ const char = sortedFields.charCodeAt(i);
1629
+ hash = ((hash << 5) - hash) + char;
1630
+ hash = hash & hash;
1631
+ }
1632
+
1633
+ // Use absolute hash value and increment counter for uniqueness
1634
+ const uniqueId = `querylang-${Math.abs(hash)}-${++languageCounter}`;
1635
+ return uniqueId;
1636
+ }
1637
+
1638
+ function setupQueryLanguage(monaco, { fieldNames = {} } = {}) {
1639
+ // Always generate a unique language ID for this editor instance
1640
+ const languageId = generateUniqueLanguageId(fieldNames);
1641
+
1642
+ // Register new language instance with unique ID
1643
+ monaco.languages.register({ id: languageId });
1644
+
1645
+ // Set up all language features with the unique language ID
1646
+ const completionSetup = setupCompletionProvider(monaco, { fieldNames, languageId });
1647
+ const disposables = [
1648
+ setupLanguageConfiguration(monaco, languageId),
1649
+ setupTokenProvider(monaco, { fieldNames, languageId }),
1650
+ completionSetup.provider,
1651
+ setupValidation(monaco, { fieldNames, languageId })
1652
+ ];
1653
+
1654
+ // Set up theme only once (shared across all instances, but that's okay)
1655
+ if (!monaco._queryThemeSetup) {
1656
+ setupEditorTheme(monaco);
1657
+ monaco._queryThemeSetup = true;
1658
+ }
1659
+
1660
+ // Store the registration info
1661
+ registeredLanguages.set(languageId, {
1662
+ fieldNames,
1663
+ setupAutoInsertBrackets: completionSetup.setupAutoInsertBrackets,
1664
+ disposables
1665
+ });
1666
+
1667
+ return {
1668
+ languageId,
1669
+ setupAutoInsertBrackets: completionSetup.setupAutoInsertBrackets,
1670
+ dispose: () => {
1671
+ // Clean up this specific language registration
1672
+ disposables.forEach(d => d && d.dispose && d.dispose());
1673
+ registeredLanguages.delete(languageId);
1674
+ }
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * Creates a query editor instance with standardized configuration
1680
+ * @param {object} monaco The Monaco editor instance
1681
+ * @param {HTMLElement} container The container element for the editor
1682
+ * @param {object} options Configuration options
1683
+ * @param {object} options.fieldNames Field definitions for this editor instance
1684
+ * @param {string} [options.initialValue=''] Initial editor content
1685
+ * @param {string} [options.placeholder=''] Placeholder text when editor is empty
1686
+ * @param {boolean} [options.showClearButton=true] Whether to show the clear button
1687
+ * @returns {object} The created editor instance and its model
1688
+ */
1689
+ function createQueryEditor(monaco, container, { fieldNames = {}, initialValue = '', placeholder = '', showClearButton = true } = {}) {
1690
+ // Set up isolated language features for this specific editor instance
1691
+ const languageSetup = setupQueryLanguage(monaco, { fieldNames });
1692
+ const { languageId, setupAutoInsertBrackets } = languageSetup;
1693
+
1694
+ // Create editor model with unique language ID
1695
+ const model = monaco.editor.createModel(initialValue, languageId);
1696
+
1697
+ // Create wrapper div for proper sizing with clear button container
1698
+ const wrapper = document.createElement('div');
1699
+ wrapper.className = 'monaco-editor-wrapper';
1700
+ wrapper.style.cssText = `
1701
+ position: relative;
1702
+ width: 100%;
1703
+ height: 100%;
1704
+ display: flex;
1705
+ align-items: center;
1706
+ `;
1707
+ container.appendChild(wrapper);
1708
+
1709
+ // Create editor container
1710
+ const editorContainer = document.createElement('div');
1711
+ editorContainer.style.cssText = `
1712
+ flex: 1;
1713
+ height: 100%;
1714
+ padding-right: ${showClearButton ? '30px' : '0px'};
1715
+ `;
1716
+ wrapper.appendChild(editorContainer);
1717
+
1718
+ let clearButton = null;
1719
+ let updateClearButtonVisibility = null;
1720
+
1721
+ // Create clear button if enabled
1722
+ if (showClearButton) {
1723
+ clearButton = document.createElement('button');
1724
+ clearButton.className = 'query-clear-button';
1725
+ clearButton.innerHTML = '✕';
1726
+ clearButton.title = 'Clear query';
1727
+ clearButton.style.cssText = `
1728
+ position: absolute;
1729
+ right: 5px;
1730
+ top: -12px;
1731
+ width: 20px;
1732
+ height: 20px;
1733
+ border: 1px solid #d1d5db;
1734
+ background: #f9fafb;
1735
+ color: #6b7280;
1736
+ border-radius: 4px;
1737
+ cursor: pointer;
1738
+ font-size: 12px;
1739
+ font-weight: bold;
1740
+ line-height: 1;
1741
+ display: none;
1742
+ z-index: 1000;
1743
+ transition: all 0.15s ease;
1744
+ outline: none;
1745
+ padding: 0;
1746
+ font-family: monospace;
1747
+ `;
1748
+
1749
+ // Add hover and focus effects
1750
+ clearButton.addEventListener('mouseenter', () => {
1751
+ clearButton.style.background = '#fef2f2';
1752
+ clearButton.style.color = '#dc2626';
1753
+ clearButton.style.borderColor = '#fca5a5';
1754
+ });
1755
+
1756
+ clearButton.addEventListener('mouseleave', () => {
1757
+ clearButton.style.background = '#f9fafb';
1758
+ clearButton.style.color = '#6b7280';
1759
+ clearButton.style.borderColor = '#d1d5db';
1760
+ });
1761
+
1762
+ // Add clear functionality
1763
+ clearButton.addEventListener('click', (e) => {
1764
+ e.preventDefault();
1765
+ e.stopPropagation();
1766
+ model.setValue('');
1767
+ editor.focus();
1768
+ if (updateClearButtonVisibility) {
1769
+ updateClearButtonVisibility();
1770
+ }
1771
+ });
1772
+
1773
+ wrapper.appendChild(clearButton);
1774
+
1775
+ // Function to toggle clear button visibility based on content
1776
+ updateClearButtonVisibility = function() {
1777
+ const hasContent = model.getValue().trim().length > 0;
1778
+ clearButton.style.display = hasContent ? 'block' : 'none';
1779
+ };
1780
+ }
1781
+
1782
+ // Create editor with standard configuration and proper widget positioning
1783
+ const editor = monaco.editor.create(editorContainer, {
1784
+ model,
1785
+ theme: 'queryTheme',
1786
+ lineNumbers: 'off',
1787
+ minimap: { enabled: false },
1788
+ scrollbar: {
1789
+ vertical: 'hidden',
1790
+ horizontal: 'auto',
1791
+ horizontalScrollbarSize: 3,
1792
+ alwaysConsumeMouseWheel: false
1793
+ },
1794
+ overviewRulerLanes: 0,
1795
+ lineDecorationsWidth: 0,
1796
+ lineNumbersMinChars: 0,
1797
+ folding: false,
1798
+ scrollBeyondLastLine: false,
1799
+ wordWrap: 'off',
1800
+ renderLineHighlight: 'none',
1801
+ overviewRulerBorder: false,
1802
+ fixedOverflowWidgets: true, // This is crucial for proper popup positioning
1803
+ renderValidationDecorations: 'editable',
1804
+ automaticLayout: true,
1805
+ placeholder,
1806
+ smoothScrolling: true,
1807
+ // Enhanced suggestion settings for auto-triggering
1808
+ suggestOnTriggerCharacters: true,
1809
+ quickSuggestions: {
1810
+ other: true,
1811
+ comments: false,
1812
+ strings: false
1813
+ },
1814
+ quickSuggestionsDelay: 100,
1815
+ suggestFontSize: 13,
1816
+ suggestLineHeight: 20,
1817
+ suggest: {
1818
+ insertMode: 'insert',
1819
+ showStatusBar: false,
1820
+ // Ensure suggestions are positioned relative to this editor
1821
+ localityBonus: true
1822
+ }
1823
+ });
1824
+
1825
+ // Set up auto-insert brackets functionality for this specific editor
1826
+ const autoInsertDisposable = setupAutoInsertBrackets(editor);
1827
+
1828
+ let contentChangeDisposable = null;
1829
+
1830
+ // Listen for content changes to show/hide clear button
1831
+ if (showClearButton && updateClearButtonVisibility) {
1832
+ contentChangeDisposable = model.onDidChangeContent(() => {
1833
+ updateClearButtonVisibility();
1834
+ });
1835
+
1836
+ // Initial visibility check
1837
+ updateClearButtonVisibility();
1838
+ }
1839
+
1840
+ // Prevent Enter key from adding newlines and handle Tab navigation
1841
+ editor.onKeyDown((e) => {
1842
+ if (e.code === 'Enter' || e.code === 'NumpadEnter') {
1843
+ // Check if the suggestion widget is visible using the correct Monaco API
1844
+ const suggestController = editor.getContribution('editor.contrib.suggestController');
1845
+ const isSuggestWidgetVisible = suggestController && suggestController.model && suggestController.model.state !== 0;
1846
+
1847
+ // If suggestions are visible, allow Enter to accept them
1848
+ if (isSuggestWidgetVisible) {
1849
+ return; // Let Monaco handle the suggestion acceptance
1850
+ }
1851
+
1852
+ // Otherwise, prevent Enter from adding newlines
1853
+ e.preventDefault();
1854
+ e.stopPropagation();
1855
+ }
1856
+
1857
+ // Handle Tab key for navigation instead of inserting tab character
1858
+ if (e.code === 'Tab') {
1859
+ e.preventDefault();
1860
+ e.stopPropagation();
1861
+
1862
+ // Find all focusable elements with tabindex, but exclude Monaco internal elements
1863
+ const focusableElements = document.querySelectorAll('[tabindex]:not([tabindex="-1"])');
1864
+ const focusableArray = Array.from(focusableElements).filter(element => {
1865
+ // Filter out Monaco Editor internal elements
1866
+ const isMonacoInternal = element.classList.contains('monaco-hover') ||
1867
+ element.classList.contains('monaco-mouse-cursor-text') ||
1868
+ element.classList.contains('monaco-editor') ||
1869
+ element.closest('.monaco-hover') ||
1870
+ element.closest('.monaco-mouse-cursor-text') ||
1871
+ element.closest('.monaco-editor-hover') ||
1872
+ element.closest('.monaco-scrollable-element') ||
1873
+ (element.className && typeof element.className === 'string' &&
1874
+ element.className.includes('monaco-')) ||
1875
+ element.getAttribute('role') === 'tooltip';
1876
+
1877
+ return !isMonacoInternal;
1878
+ });
1879
+
1880
+ const currentIndex = focusableArray.indexOf(container);
1881
+
1882
+ if (currentIndex !== -1) {
1883
+ let nextIndex;
1884
+ if (e.shiftKey) {
1885
+ // Shift+Tab: Move to previous element
1886
+ nextIndex = currentIndex === 0 ? focusableArray.length - 1 : currentIndex - 1;
1887
+ } else {
1888
+ // Tab: Move to next element
1889
+ nextIndex = currentIndex === focusableArray.length - 1 ? 0 : currentIndex + 1;
1890
+ }
1891
+
1892
+ // Remove focus from the Monaco editor by focusing the next element directly
1893
+ focusableArray[nextIndex].focus();
1894
+ }
1895
+ }
1896
+ });
1897
+
1898
+ // Also prevent paste operations that contain newlines
1899
+ editor.onDidPaste((e) => {
1900
+ const currentValue = model.getValue();
1901
+ // Remove any carriage return or line feed characters
1902
+ const cleanValue = currentValue.replace(/[\r\n]/g, ' ');
1903
+ if (cleanValue !== currentValue) {
1904
+ model.setValue(cleanValue);
1905
+ }
1906
+ });
1907
+
1908
+ // Prevent newlines from any other source
1909
+ model.onDidChangeContent((e) => {
1910
+ const currentValue = model.getValue();
1911
+ if (/[\r\n]/.test(currentValue)) {
1912
+ const cleanValue = currentValue.replace(/[\r\n]/g, ' ');
1913
+ model.pushEditOperations([], [{
1914
+ range: model.getFullModelRange(),
1915
+ text: cleanValue
1916
+ }], () => null);
1917
+ }
1918
+ });
1919
+
1920
+ // Enhanced cleanup method that also disposes language features
1921
+ const originalDispose = editor.dispose.bind(editor);
1922
+ editor.dispose = () => {
1923
+ autoInsertDisposable.dispose();
1924
+ if (contentChangeDisposable) {
1925
+ contentChangeDisposable.dispose();
1926
+ }
1927
+ // Clean up the isolated language features
1928
+ languageSetup.dispose();
1929
+ originalDispose();
1930
+ };
1931
+
1932
+ // Add method to toggle clear button visibility programmatically
1933
+ if (showClearButton) {
1934
+ editor.setClearButtonMode = function(mode) {
1935
+ if (!clearButton) return;
1936
+
1937
+ if (mode === 'always') {
1938
+ clearButton.style.display = 'block';
1939
+ } else if (mode === 'never') {
1940
+ clearButton.style.display = 'none';
1941
+ } else if (mode === 'auto' && updateClearButtonVisibility) {
1942
+ updateClearButtonVisibility();
1943
+ }
1944
+ };
1945
+ }
1946
+
1947
+ // Add modern input field focus/blur behavior
1948
+ editor.onDidFocusEditorWidget(() => {
1949
+ container.classList.add('focused');
1950
+ });
1951
+
1952
+ editor.onDidBlurEditorWidget(() => {
1953
+ container.classList.remove('focused');
1954
+ });
1955
+
1956
+ // Add method to update field names dynamically
1957
+ editor.updateFieldNames = function(newFieldNames) {
1958
+ return updateQueryEditorFieldNames(monaco, languageId, newFieldNames);
1959
+ };
1960
+
1961
+ // Add method to get current field names
1962
+ editor.getFieldNames = function() {
1963
+ return getQueryEditorFieldNames(languageId);
1964
+ };
1965
+
1966
+ // Store language ID for reference
1967
+ editor.languageId = languageId;
1968
+
1969
+ return { editor, model };
1970
+ }
1971
+
1972
+ /**
1973
+ * Updates field names for an existing query editor without recreating it
1974
+ * @param {object} monaco The Monaco editor instance
1975
+ * @param {string} languageId The language ID used by the editor
1976
+ * @param {object} newFieldNames New field definitions
1977
+ * @returns {boolean} True if update was successful
1978
+ */
1979
+ function updateQueryEditorFieldNames(monaco, languageId, newFieldNames) {
1980
+ try {
1981
+ // Store the reference to the language setup for this language ID
1982
+ const existingSetup = registeredLanguages.get(languageId);
1983
+ if (!existingSetup) {
1984
+ console.warn('No existing language setup found for', languageId);
1985
+ return false;
1986
+ }
1987
+
1988
+ // Dispose existing providers (both completion AND validation)
1989
+ if (existingSetup.disposables) {
1990
+ // Original structure with disposables array
1991
+ const completionProviderIndex = 2; // Based on setupLanguageSupport disposables order
1992
+ const validationProviderIndex = 3;
1993
+
1994
+ if (existingSetup.disposables[completionProviderIndex]) {
1995
+ existingSetup.disposables[completionProviderIndex].dispose();
1996
+ }
1997
+
1998
+ if (existingSetup.disposables[validationProviderIndex]) {
1999
+ existingSetup.disposables[validationProviderIndex].dispose();
2000
+ }
2001
+ } else {
2002
+ // Updated structure with individual providers
2003
+ if (existingSetup.completionProvider) {
2004
+ existingSetup.completionProvider.dispose();
2005
+ }
2006
+
2007
+ if (existingSetup.validationProvider) {
2008
+ existingSetup.validationProvider.dispose();
2009
+ }
2010
+ }
2011
+
2012
+ // Clear validation cache to ensure new provider is created with updated field names
2013
+ if (monaco._validationSetup && monaco._validationSetup[languageId]) {
2014
+ delete monaco._validationSetup[languageId];
2015
+ }
2016
+
2017
+ // Create NEW completion provider with updated field names
2018
+ const newCompletionProvider = setupCompletionProvider(monaco, {
2019
+ fieldNames: newFieldNames,
2020
+ languageId
2021
+ });
2022
+
2023
+ // Create NEW validation provider with updated field names
2024
+ const newValidationProvider = setupValidation(monaco, {
2025
+ fieldNames: newFieldNames,
2026
+ languageId
2027
+ });
2028
+
2029
+ // Update the stored language setup with new field names and providers
2030
+ existingSetup.fieldNames = newFieldNames;
2031
+
2032
+ if (existingSetup.disposables) {
2033
+ // Update disposables array with new providers
2034
+ existingSetup.disposables[2] = newCompletionProvider.provider;
2035
+ existingSetup.disposables[3] = newValidationProvider;
2036
+ } else {
2037
+ // Update individual providers
2038
+ existingSetup.completionProvider = newCompletionProvider.provider;
2039
+ existingSetup.validationProvider = newValidationProvider;
2040
+ }
2041
+
2042
+ // Force re-validation for all models using this language
2043
+ const models = monaco.editor.getModels();
2044
+ models.forEach(model => {
2045
+ if (model.getLanguageId() === languageId) {
2046
+ // Clear existing markers first
2047
+ monaco.editor.setModelMarkers(model, languageId, []);
2048
+
2049
+ // Trigger validation update by making a minimal change and reverting
2050
+ const currentValue = model.getValue();
2051
+ const currentPosition = model.getPositionAt(currentValue.length);
2052
+
2053
+ // Force revalidation by making a minimal change and reverting
2054
+ model.pushEditOperations([], [{
2055
+ range: new monaco.Range(currentPosition.lineNumber, currentPosition.column, currentPosition.lineNumber, currentPosition.column),
2056
+ text: ' '
2057
+ }], () => null);
2058
+
2059
+ model.pushEditOperations([], [{
2060
+ range: new monaco.Range(currentPosition.lineNumber, currentPosition.column, currentPosition.lineNumber, currentPosition.column + 1),
2061
+ text: ''
2062
+ }], () => null);
2063
+
2064
+ // Explicitly trigger validation again after a short delay to ensure providers are ready
2065
+ setTimeout(() => {
2066
+ const value = model.getValue();
2067
+ // Create a change event to trigger validation
2068
+ model.pushEditOperations([], [{
2069
+ range: new monaco.Range(1, 1, 1, 1),
2070
+ text: ''
2071
+ }], () => null);
2072
+ }, 50);
2073
+ }
2074
+ });
2075
+
2076
+ return true;
2077
+ } catch (error) {
2078
+ console.error('Failed to update field names:', error);
2079
+ return false;
2080
+ }
2081
+ }
2082
+
2083
+ /**
2084
+ * Gets current field names for a query editor
2085
+ * @param {string} languageId The language ID used by the editor
2086
+ * @returns {object|null} Current field names or null if not found
2087
+ */
2088
+ function getQueryEditorFieldNames(languageId) {
2089
+ const existingSetup = registeredLanguages.get(languageId);
2090
+ return existingSetup ? existingSetup.fieldNames : null;
2091
+ }
2092
+
2093
+ // Export for Node.js/Jest testing
2094
+ if (typeof module !== 'undefined' && module.exports) {
2095
+ module.exports = {
2096
+ setupQueryLanguage,
2097
+ createQueryEditor,
2098
+ updateQueryEditorFieldNames,
2099
+ getQueryEditorFieldNames,
2100
+ setupLanguageConfiguration,
2101
+ setupCompletionProvider,
2102
+ setupTokenProvider,
2103
+ setupValidation,
2104
+ setupEditorTheme,
2105
+ generateUniqueLanguageId
2106
+ };
2107
+ }