@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/LICENSE +190 -0
- package/README.md +431 -0
- package/dist/divtable.min.js +1 -0
- package/dist/editor.worker.js +1 -0
- package/dist/json.worker.js +1 -0
- package/dist/ts.worker.js +6 -0
- package/package.json +66 -0
- package/src/div-table.css +1331 -0
- package/src/div-table.js +5186 -0
- package/src/query.js +2107 -0
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
|
+
}
|