flyql-vue 0.0.37

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/engine.js ADDED
@@ -0,0 +1,891 @@
1
+ /**
2
+ * EditorEngine — framework-agnostic editor logic.
3
+ * Pure JS class, no Vue/React/DOM dependencies.
4
+ * One instance per editor component.
5
+ */
6
+
7
+ import {
8
+ Parser,
9
+ CharType,
10
+ State,
11
+ VALID_KEY_VALUE_OPERATORS,
12
+ isNumeric,
13
+ Column,
14
+ ColumnSchema,
15
+ Diagnostic,
16
+ diagnose,
17
+ CODE_UNKNOWN_COLUMN,
18
+ CODE_UNKNOWN_TRANSFORMER,
19
+ Range,
20
+ } from 'flyql/core'
21
+ import { Type } from 'flyql'
22
+ import { defaultRegistry } from 'flyql/transformers'
23
+ import { EditorState } from './state.js'
24
+ import {
25
+ updateSuggestions,
26
+ prepareSuggestionValues,
27
+ resolveColumnDef,
28
+ getColumnSuggestionsForValue,
29
+ getInsertRange,
30
+ STATE_LABELS,
31
+ } from './suggestions.js'
32
+
33
+ /**
34
+ * Maps editor-input raw-type strings (as appearing in user schema definitions)
35
+ * to canonical flyql.Type values. Unknown strings fall through to Type.Unknown
36
+ * so the validator's chain check is skipped cleanly (never leak raw strings).
37
+ */
38
+ const EDITOR_TYPE_TO_FLYQL = {
39
+ enum: Type.String,
40
+ string: Type.String,
41
+ number: Type.Int,
42
+ int: Type.Int,
43
+ integer: Type.Int,
44
+ float: Type.Float,
45
+ bool: Type.Bool,
46
+ boolean: Type.Bool,
47
+ array: Type.Array,
48
+ map: Type.Map,
49
+ struct: Type.Struct,
50
+ json: Type.JSON,
51
+ date: Type.Date,
52
+ }
53
+
54
+ const _FLYQL_TYPE_VALUES = new Set(Object.values(Type))
55
+
56
+ function _applyEditorTypeNormalization(col) {
57
+ if (col.type && !_FLYQL_TYPE_VALUES.has(col.type)) {
58
+ const mapped = EDITOR_TYPE_TO_FLYQL[col.type]
59
+ col.type = mapped !== undefined ? mapped : Type.Unknown
60
+ }
61
+ if (col.children) {
62
+ for (const child of Object.values(col.children)) {
63
+ if (child) _applyEditorTypeNormalization(child)
64
+ }
65
+ }
66
+ }
67
+
68
+ const CHAR_TYPE_CLASS = {
69
+ [CharType.KEY]: 'flyql-key',
70
+ [CharType.OPERATOR]: 'flyql-operator',
71
+ [CharType.VALUE]: 'flyql-value',
72
+ [CharType.NUMBER]: 'flyql-number',
73
+ [CharType.STRING]: 'flyql-string',
74
+ [CharType.BOOLEAN]: 'flyql-boolean',
75
+ [CharType.NULL]: 'flyql-null',
76
+ [CharType.SPACE]: 'flyql-space',
77
+ [CharType.PIPE]: 'flyql-transformer',
78
+ [CharType.TRANSFORMER]: 'flyql-transformer',
79
+ [CharType.ARGUMENT]: 'flyql-argument',
80
+ [CharType.ARGUMENT_STRING]: 'flyql-argument-string',
81
+ [CharType.ARGUMENT_NUMBER]: 'flyql-argument-number',
82
+ [CharType.WILDCARD]: 'flyql-wildcard',
83
+ [CharType.COLUMN]: 'flyql-column',
84
+ [CharType.PARAMETER]: 'flyql-parameter',
85
+ }
86
+
87
+ function escapeHtml(str) {
88
+ return str
89
+ .replace(/&/g, '&')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;')
92
+ .replace(/"/g, '&quot;')
93
+ .replace(/'/g, '&#x27;')
94
+ }
95
+
96
+ /**
97
+ * Normalize newlines to spaces for parser consumption.
98
+ * The editor supports multiline visually, but the parser only recognizes spaces as delimiters.
99
+ */
100
+ function normalizeForParser(text) {
101
+ return text.replace(/\r\n/g, ' ').replace(/[\r\n]/g, ' ')
102
+ }
103
+
104
+ function wrapSpan(charType, text) {
105
+ const escaped = escapeHtml(text)
106
+ const cls = CHAR_TYPE_CLASS[charType]
107
+ if (cls) {
108
+ return `<span class="${cls}">${escaped}</span>`
109
+ }
110
+ return escaped
111
+ }
112
+
113
+ export class EditorEngine {
114
+ constructor(schema, options = {}) {
115
+ this.columns = schema || new ColumnSchema({})
116
+ for (const col of Object.values(this.columns.columns)) {
117
+ if (col) _applyEditorTypeNormalization(col)
118
+ }
119
+ this.onAutocomplete = options.onAutocomplete || null
120
+ this.onKeyDiscovery = options.onKeyDiscovery || null
121
+ this.onLoadingChange = options.onLoadingChange || null
122
+ this.registry = options.registry || defaultRegistry()
123
+ this.parameters = options.parameters || []
124
+ this.debounceMs = options.debounceMs ?? 300
125
+ this.state = new EditorState()
126
+ this.context = null
127
+ this.suggestions = []
128
+ this.suggestionType = ''
129
+ this.incomplete = false
130
+ this.message = ''
131
+
132
+ this.activeTab = 'values'
133
+ this.valueSuggestions = []
134
+ this.columnSuggestions = []
135
+ this.valueMessage = ''
136
+
137
+ this.isLoading = false
138
+ this.keyCache = {}
139
+ this._suggestionSeq = 0
140
+ this._debounceTimer = null
141
+ this._valueState = null
142
+ this._lastValueKey = null
143
+ this._validatorColumns = null
144
+ this.diagnostics = []
145
+ }
146
+
147
+ setTab(tab) {
148
+ if (tab === this.activeTab) return
149
+ this.activeTab = tab
150
+ this.suggestions = tab === 'values' ? this.valueSuggestions : this.columnSuggestions
151
+ this.message = tab === 'values' ? this.valueMessage : ''
152
+ this.state.selectedIndex = 0
153
+ }
154
+
155
+ cycleTab() {
156
+ this.setTab(this.activeTab === 'values' ? 'columns' : 'values')
157
+ }
158
+
159
+ _resetTabState() {
160
+ this.activeTab = 'values'
161
+ this.valueSuggestions = []
162
+ this.columnSuggestions = []
163
+ this.valueMessage = ''
164
+ }
165
+
166
+ /**
167
+ * Set columns and invalidate the validator column cache.
168
+ */
169
+ setColumns(schema) {
170
+ this.columns = schema || new ColumnSchema({})
171
+ for (const col of Object.values(this.columns.columns)) {
172
+ if (col) _applyEditorTypeNormalization(col)
173
+ }
174
+ this._validatorColumns = null
175
+ }
176
+
177
+ /**
178
+ * Set the list of available parameter names (without `$` prefix).
179
+ */
180
+ setParameters(params) {
181
+ this.parameters = params || []
182
+ }
183
+
184
+ setRegistry(registry) {
185
+ this.registry = registry || defaultRegistry()
186
+ }
187
+
188
+ _buildValidatorColumns() {
189
+ // ColumnSchema is already typed — return it directly for the validator
190
+ this._validatorColumns = this.columns
191
+ return this._validatorColumns
192
+ }
193
+
194
+ /**
195
+ * Run the validator on the current query and return diagnostics.
196
+ * Includes parser syntax errors and semantic validation errors.
197
+ */
198
+ getDiagnostics() {
199
+ const value = this.state.query
200
+ if (!value) {
201
+ this.diagnostics = []
202
+ return this.diagnostics
203
+ }
204
+ const normalized = normalizeForParser(value)
205
+ const parser = new Parser()
206
+ try {
207
+ parser.parse(normalized, false, false)
208
+ } catch (e) {
209
+ const range = e.range || new Range(parser.typedChars ? parser.typedChars.length : 0, normalized.length)
210
+ // Suppress syntax errors at the end of query — user is still typing
211
+ if (range.end >= normalized.length) {
212
+ this.diagnostics = []
213
+ return this.diagnostics
214
+ }
215
+ this.diagnostics = [new Diagnostic(range, e.message || 'Parse error', 'error', 'syntax')]
216
+ return this.diagnostics
217
+ }
218
+ if (parser.state === State.ERROR) {
219
+ const start = parser.typedChars ? parser.typedChars.length : 0
220
+ // Suppress if error is at the end of query
221
+ if (start >= normalized.length - 1) {
222
+ this.diagnostics = []
223
+ return this.diagnostics
224
+ }
225
+ this.diagnostics = [
226
+ new Diagnostic(
227
+ new Range(start, normalized.length),
228
+ parser.errorText || 'Parse error',
229
+ 'error',
230
+ 'syntax',
231
+ ),
232
+ ]
233
+ return this.diagnostics
234
+ }
235
+ if (
236
+ parser.state !== State.EXPECT_BOOL_OP &&
237
+ parser.state !== State.VALUE &&
238
+ parser.state !== State.KEY &&
239
+ parser.state !== State.KEY_OR_BOOL_OP
240
+ ) {
241
+ this.diagnostics = []
242
+ return this.diagnostics
243
+ }
244
+ if (!parser.root) {
245
+ this.diagnostics = []
246
+ return this.diagnostics
247
+ }
248
+ const columns = this._validatorColumns || this._buildValidatorColumns()
249
+ const reg = this.registry
250
+ try {
251
+ this.diagnostics = diagnose(parser.root, columns, reg)
252
+ } catch {
253
+ this.diagnostics = []
254
+ }
255
+ // Suppress unknown_transformer/unknown_column at query end when name is a prefix of a known one
256
+ if (this.diagnostics.length > 0) {
257
+ const queryLen = normalized.length
258
+ const transformerNames = reg.names()
259
+ const columnNames = Object.keys(this.columns.columns).map((n) => n.toLowerCase())
260
+ this.diagnostics = this.diagnostics.filter((d) => {
261
+ if (d.range.end < queryLen) return true
262
+ if (d.code === CODE_UNKNOWN_TRANSFORMER) {
263
+ const match = d.message.match(/^unknown transformer: '(.+)'$/)
264
+ if (!match) return true
265
+ const partial = match[1]
266
+ return !transformerNames.some((n) => n.startsWith(partial) && n !== partial)
267
+ }
268
+ if (d.code === CODE_UNKNOWN_COLUMN) {
269
+ const match = d.message.match(/^column '(.+)' is not defined$/)
270
+ if (!match) return true
271
+ const partial = match[1].toLowerCase()
272
+ return !columnNames.some((n) => n.startsWith(partial) && n !== partial)
273
+ }
274
+ return true
275
+ })
276
+ }
277
+ return this.diagnostics
278
+ }
279
+
280
+ /**
281
+ * Set the query text and update cursor position.
282
+ */
283
+ setQuery(text) {
284
+ this.state.setQuery(text)
285
+ }
286
+
287
+ /**
288
+ * Set cursor position within the query.
289
+ */
290
+ setCursorPosition(pos) {
291
+ this.state.setCursorPosition(pos)
292
+ }
293
+
294
+ /**
295
+ * Build context from text before cursor — determines what the editor expects next.
296
+ */
297
+ buildContext(textBeforeCursor) {
298
+ if (!textBeforeCursor) {
299
+ return {
300
+ expecting: 'column',
301
+ key: '',
302
+ value: '',
303
+ quoteChar: '',
304
+ keyValueOperator: '',
305
+ state: 'INITIAL',
306
+ textBeforeCursor: '',
307
+ nestingDepth: 0,
308
+ }
309
+ }
310
+
311
+ const normalized = normalizeForParser(textBeforeCursor)
312
+ const parser = new Parser()
313
+ try {
314
+ parser.parse(normalized, false, true)
315
+ } catch (e) {
316
+ return {
317
+ expecting: '',
318
+ key: '',
319
+ value: '',
320
+ quoteChar: '',
321
+ keyValueOperator: '',
322
+ state: 'ERROR',
323
+ error: e.message || 'Parse error',
324
+ textBeforeCursor,
325
+ nestingDepth: parser.nodesStack ? parser.nodesStack.length : 0,
326
+ }
327
+ }
328
+
329
+ if (parser.state === State.ERROR) {
330
+ return {
331
+ expecting: '',
332
+ key: '',
333
+ value: '',
334
+ quoteChar: '',
335
+ keyValueOperator: '',
336
+ state: 'ERROR',
337
+ error: parser.errorText || 'Parse error',
338
+ textBeforeCursor,
339
+ nestingDepth: parser.nodesStack ? parser.nodesStack.length : 0,
340
+ }
341
+ }
342
+
343
+ const ctx = {
344
+ state: parser.state,
345
+ key: parser.key || '',
346
+ value: parser.value || '',
347
+ keyValueOperator: parser.keyValueOperator || '',
348
+ quoteChar: '',
349
+ expecting: '',
350
+ textBeforeCursor,
351
+ nestingDepth: parser.nodesStack ? parser.nodesStack.length : 0,
352
+ }
353
+
354
+ // Detect transformer context: key contains pipe character
355
+ const keyStr = ctx.key
356
+ const pipeIndex = keyStr.indexOf('|')
357
+ if (pipeIndex >= 0) {
358
+ const lastPipeIndex = keyStr.lastIndexOf('|')
359
+ ctx.transformerBaseKey = keyStr.substring(0, pipeIndex)
360
+ let rawPrefix = keyStr.substring(lastPipeIndex + 1)
361
+ ctx.transformerChain = pipeIndex < lastPipeIndex ? keyStr.substring(pipeIndex + 1, lastPipeIndex) : ''
362
+ // Strip argument portion from prefix: "upper(1,2" → "upper"
363
+ const parenIdx = rawPrefix.indexOf('(')
364
+ if (parenIdx >= 0 && !rawPrefix.endsWith(')')) {
365
+ ctx.transformerPrefix = rawPrefix.substring(0, parenIdx)
366
+ ctx.transformerInArgs = true
367
+ } else if (parenIdx >= 0) {
368
+ ctx.transformerPrefix = rawPrefix.substring(0, parenIdx)
369
+ ctx.transformerInArgs = false
370
+ } else {
371
+ ctx.transformerPrefix = rawPrefix
372
+ ctx.transformerInArgs = false
373
+ }
374
+ // Normalize key to base column for all downstream lookups
375
+ ctx.key = ctx.transformerBaseKey
376
+ if (parser.state === State.KEY) {
377
+ ctx.expecting = 'transformer'
378
+ return ctx
379
+ }
380
+ }
381
+
382
+ if (
383
+ parser.state === State.KEY ||
384
+ parser.state === State.INITIAL ||
385
+ parser.state === State.BOOL_OP_DELIMITER ||
386
+ parser.state === State.SINGLE_QUOTED_KEY ||
387
+ parser.state === State.DOUBLE_QUOTED_KEY
388
+ ) {
389
+ ctx.expecting = 'column'
390
+ } else if (parser.state === State.KEY_OR_BOOL_OP) {
391
+ ctx.expecting = 'operatorOrBool'
392
+ } else if (parser.state === State.EXPECT_OPERATOR) {
393
+ ctx.expecting = 'operatorOrBool'
394
+ } else if (parser.state === State.EXPECT_LIST_START) {
395
+ ctx.expecting = 'list'
396
+ } else if (
397
+ parser.state === State.EXPECT_LIST_VALUE ||
398
+ parser.state === State.IN_LIST_VALUE ||
399
+ parser.state === State.IN_LIST_SINGLE_QUOTED_VALUE ||
400
+ parser.state === State.IN_LIST_DOUBLE_QUOTED_VALUE ||
401
+ parser.state === State.EXPECT_LIST_COMMA_OR_END
402
+ ) {
403
+ ctx.expecting = 'none'
404
+ } else if (parser.state === State.KEY_VALUE_OPERATOR) {
405
+ const op = parser.keyValueOperator
406
+ const isValid = VALID_KEY_VALUE_OPERATORS.includes(op)
407
+ const hasLonger = VALID_KEY_VALUE_OPERATORS.some((o) => o.startsWith(op) && o !== op)
408
+ if (hasLonger) {
409
+ ctx.expecting = 'operatorPrefix'
410
+ } else if (isValid) {
411
+ ctx.expecting = 'value'
412
+ }
413
+ } else if (
414
+ parser.state === State.VALUE ||
415
+ parser.state === State.EXPECT_VALUE ||
416
+ parser.state === State.DOUBLE_QUOTED_VALUE ||
417
+ parser.state === State.SINGLE_QUOTED_VALUE
418
+ ) {
419
+ ctx.expecting = 'value'
420
+ if (parser.state === State.DOUBLE_QUOTED_VALUE) ctx.quoteChar = '"'
421
+ else if (parser.state === State.SINGLE_QUOTED_VALUE) ctx.quoteChar = "'"
422
+ } else if (parser.state === State.EXPECT_BOOL_OP) {
423
+ ctx.expecting = 'boolOp'
424
+ } else if (parser.state === State.EXPECT_NOT_TARGET) {
425
+ ctx.expecting = 'column'
426
+ } else if (parser.state === State.EXPECT_IN_KEYWORD) {
427
+ ctx.expecting = 'none'
428
+ } else if (parser.state === State.EXPECT_HAS_KEYWORD) {
429
+ ctx.expecting = 'none'
430
+ } else if (parser.state === State.EXPECT_LIKE_KEYWORD) {
431
+ ctx.expecting = 'none'
432
+ }
433
+
434
+ return ctx
435
+ }
436
+
437
+ /**
438
+ * Update suggestions based on current cursor position.
439
+ * Debounces async calls (value/key loading) to avoid excessive requests.
440
+ * Returns a promise (may be async for value loading).
441
+ */
442
+ async updateSuggestions() {
443
+ const seq = ++this._suggestionSeq
444
+ if (this._debounceTimer) {
445
+ clearTimeout(this._debounceTimer)
446
+ this._debounceTimer = null
447
+ }
448
+ const textBeforeCursor = this.state.getTextBeforeCursor()
449
+ const ctx = this.buildContext(textBeforeCursor)
450
+ this.context = ctx
451
+ this.message = ''
452
+ this.suggestionType = ctx ? ctx.expecting || '' : 'column'
453
+
454
+ // Reset value state when leaving value context or changing key
455
+ if (!ctx || ctx.expecting !== 'value' || (this._valueState && this._valueState.key !== ctx.key)) {
456
+ this._valueState = null
457
+ }
458
+
459
+ // Reset tab state when leaving value context or changing key
460
+ if (!ctx || ctx.expecting !== 'value') {
461
+ this._resetTabState()
462
+ } else if (this._lastValueKey && this._lastValueKey !== ctx.key) {
463
+ this._resetTabState()
464
+ }
465
+ if (ctx && ctx.expecting === 'value') {
466
+ this._lastValueKey = ctx.key
467
+ this.columnSuggestions = getColumnSuggestionsForValue(this.columns, ctx.value || '', ctx.key || '')
468
+ }
469
+
470
+ // Parameter autocomplete short-circuit: when value starts with `$`, show
471
+ // parameter suggestions regardless of column async state.
472
+ if (ctx && ctx.expecting === 'value' && ctx.value && ctx.value.startsWith('$')) {
473
+ const prefix = ctx.value.slice(1).toLowerCase()
474
+ const paramSuggestions = (this.parameters || [])
475
+ .filter((n) => !prefix || n.toLowerCase().startsWith(prefix))
476
+ .map((n) => ({
477
+ label: '$' + n,
478
+ insertText: '$' + n,
479
+ type: 'value',
480
+ detail: 'parameter',
481
+ }))
482
+ this.valueSuggestions = paramSuggestions
483
+ this.valueMessage = paramSuggestions.length === 0 ? 'No matching parameters' : ''
484
+ this.suggestionType = 'value'
485
+ this.incomplete = false
486
+ this.isLoading = false
487
+ if (this.onLoadingChange) this.onLoadingChange(false)
488
+ this.suggestions = this.activeTab === 'values' ? this.valueSuggestions : this.columnSuggestions
489
+ this.message = this.activeTab === 'values' ? this.valueMessage : ''
490
+ this.state.selectedIndex = 0
491
+ return ctx
492
+ }
493
+
494
+ // Only enter async _valueState flow when the column actually needs server fetch:
495
+ // - column has autocomplete enabled AND no static values, OR
496
+ // - column is unknown (unresolved dotted key — let onAutocomplete try)
497
+ const _needsAsyncValue =
498
+ ctx &&
499
+ ctx.expecting === 'value' &&
500
+ this.onAutocomplete &&
501
+ (() => {
502
+ const col = resolveColumnDef(this.columns, ctx.key)
503
+ if (!col) return true
504
+ if (!col.autocomplete) return false
505
+ return !col.values || col.values.length === 0
506
+ })()
507
+
508
+ // Complete list: client-side filter, no server call
509
+ if (_needsAsyncValue && this._valueState && !this._valueState.incomplete) {
510
+ this.valueSuggestions = prepareSuggestionValues(this._valueState.items, ctx.quoteChar, ctx.value)
511
+ this.suggestions = this.activeTab === 'values' ? this.valueSuggestions : this.columnSuggestions
512
+ this.incomplete = false
513
+ this.isLoading = false
514
+ this.state.selectedIndex = 0
515
+ return ctx
516
+ }
517
+
518
+ // Incomplete list refinement: keep current suggestions, debounce re-fetch
519
+ if (_needsAsyncValue && this._valueState && this._valueState.incomplete) {
520
+ // Don't clear suggestions — keep showing current values
521
+ this.isLoading = true
522
+ if (this.onLoadingChange) this.onLoadingChange(true)
523
+ if (this.debounceMs > 0) {
524
+ await new Promise((resolve) => {
525
+ this._debounceTimer = setTimeout(resolve, this.debounceMs)
526
+ })
527
+ if (seq !== this._suggestionSeq) return ctx
528
+ }
529
+
530
+ const result = await updateSuggestions(
531
+ ctx,
532
+ this.columns,
533
+ this.onAutocomplete,
534
+ this.onKeyDiscovery,
535
+ this.keyCache,
536
+ (loading) => {
537
+ if (seq !== this._suggestionSeq) return
538
+ this.isLoading = loading
539
+ if (this.onLoadingChange) this.onLoadingChange(loading)
540
+ },
541
+ this.registry,
542
+ this.parameters,
543
+ )
544
+
545
+ if (seq !== this._suggestionSeq) return ctx
546
+
547
+ this._valueState = {
548
+ key: ctx.key,
549
+ value: ctx.value,
550
+ items: result.rawItems || this._valueState.items,
551
+ incomplete: result.incomplete,
552
+ }
553
+ this.valueSuggestions = result.suggestions
554
+ this.valueMessage = result.suggestions.length === 0 ? 'No matching values' : result.message
555
+ this.suggestionType = result.suggestionType
556
+ this.incomplete = result.incomplete || false
557
+ if (this.activeTab === 'values') {
558
+ this.suggestions = this.valueSuggestions
559
+ this.message = this.valueMessage
560
+ }
561
+
562
+ this.state.selectedIndex = 0
563
+ return ctx
564
+ }
565
+
566
+ // Initial value load: no debounce
567
+ if (_needsAsyncValue && !this._valueState) {
568
+ // Mark pending so subsequent keystrokes hit the debounced refinement branch
569
+ this._valueState = { key: ctx.key, value: ctx.value, items: [], incomplete: true }
570
+ this.suggestions = []
571
+ this.incomplete = false
572
+ this.isLoading = true
573
+ this.state.selectedIndex = 0
574
+
575
+ const result = await updateSuggestions(
576
+ ctx,
577
+ this.columns,
578
+ this.onAutocomplete,
579
+ this.onKeyDiscovery,
580
+ this.keyCache,
581
+ (loading) => {
582
+ if (seq !== this._suggestionSeq) return
583
+ this.isLoading = loading
584
+ if (this.onLoadingChange) this.onLoadingChange(loading)
585
+ },
586
+ this.registry,
587
+ this.parameters,
588
+ )
589
+
590
+ if (seq !== this._suggestionSeq) return ctx
591
+
592
+ this._valueState = {
593
+ key: ctx.key,
594
+ value: ctx.value,
595
+ items: result.rawItems || [],
596
+ incomplete: result.incomplete,
597
+ }
598
+ this.valueSuggestions = result.suggestions
599
+ this.valueMessage = result.suggestions.length === 0 ? 'No matching values' : result.message
600
+ this.suggestionType = result.suggestionType
601
+ this.incomplete = result.incomplete || false
602
+ if (this.activeTab === 'values') {
603
+ this.suggestions = this.valueSuggestions
604
+ this.message = this.valueMessage
605
+ }
606
+
607
+ this.state.selectedIndex = 0
608
+ return ctx
609
+ }
610
+
611
+ // Non-value suggestions (columns, operators, boolOps) or value with static values
612
+ this.suggestions = []
613
+ this.incomplete = false
614
+ this.isLoading = false
615
+ this.state.selectedIndex = 0
616
+
617
+ const result = await updateSuggestions(
618
+ ctx,
619
+ this.columns,
620
+ this.onAutocomplete,
621
+ this.onKeyDiscovery,
622
+ this.keyCache,
623
+ (loading) => {
624
+ if (seq !== this._suggestionSeq) return
625
+ this.isLoading = loading
626
+ if (this.onLoadingChange) this.onLoadingChange(loading)
627
+ },
628
+ this.registry,
629
+ )
630
+
631
+ if (seq !== this._suggestionSeq) return ctx
632
+
633
+ if (ctx && ctx.expecting === 'value') {
634
+ this.valueSuggestions = result.suggestions
635
+ this.valueMessage = result.suggestions.length === 0 && !result.message ? 'No suggestions' : result.message
636
+ this.suggestions = this.activeTab === 'values' ? this.valueSuggestions : this.columnSuggestions
637
+ this.message = this.activeTab === 'values' ? this.valueMessage : ''
638
+ } else {
639
+ this.suggestions = result.suggestions
640
+ this.message = result.message
641
+ }
642
+ this.suggestionType = result.suggestionType
643
+ this.incomplete = result.incomplete || false
644
+ this.messageIsError = result.messageIsError || false
645
+ this.state.selectedIndex = 0
646
+
647
+ return ctx
648
+ }
649
+
650
+ /**
651
+ * Generate highlight tokens as HTML string.
652
+ * Accepts an optional query parameter to avoid mutating engine state.
653
+ * When diagnostics are provided, wraps affected characters with diagnostic spans.
654
+ */
655
+ getHighlightTokens(query, diagnostics = null, highlightDiagIndex = -1) {
656
+ const value = query !== undefined ? query : this.state.query
657
+ if (!value) return ''
658
+
659
+ const normalized = normalizeForParser(value)
660
+ const parser = new Parser()
661
+ try {
662
+ parser.parse(normalized, false, true)
663
+ } catch {
664
+ return escapeHtml(value)
665
+ }
666
+
667
+ const typedChars = parser.typedChars
668
+ if (!typedChars || typedChars.length === 0) {
669
+ return escapeHtml(value)
670
+ }
671
+
672
+ // Build per-character diagnostic map
673
+ let diagMap = null
674
+ let highlightSet = null
675
+ if (diagnostics && diagnostics.length > 0) {
676
+ diagMap = new Array(value.length).fill(null)
677
+ for (let di = 0; di < diagnostics.length; di++) {
678
+ const d = diagnostics[di]
679
+ for (let j = d.range.start; j < d.range.end && j < value.length; j++) {
680
+ if (!diagMap[j]) {
681
+ diagMap[j] = { diag: d, index: di }
682
+ }
683
+ }
684
+ }
685
+ // Build separate highlight set for hovered diagnostic
686
+ if (highlightDiagIndex >= 0 && highlightDiagIndex < diagnostics.length) {
687
+ highlightSet = new Set()
688
+ const hd = diagnostics[highlightDiagIndex]
689
+ for (let j = hd.range.start; j < hd.range.end && j < value.length; j++) {
690
+ highlightSet.add(j)
691
+ }
692
+ }
693
+ }
694
+
695
+ let html = ''
696
+ let currentType = null
697
+ let currentText = ''
698
+ let currentDiag = null
699
+ let currentHighlight = false
700
+
701
+ const flushSpan = () => {
702
+ if (!currentText) return
703
+ let spanType = currentType
704
+ if (spanType === CharType.VALUE) {
705
+ if (currentText === 'true' || currentText === 'false') {
706
+ spanType = CharType.BOOLEAN
707
+ } else if (currentText === 'null') {
708
+ spanType = CharType.NULL
709
+ } else if (isNumeric(currentText)) {
710
+ spanType = CharType.NUMBER
711
+ } else if (currentText.length > 0 && currentText[0] !== "'" && currentText[0] !== '"') {
712
+ spanType = CharType.COLUMN
713
+ }
714
+ }
715
+ const inner = wrapSpan(spanType, currentText)
716
+ if (currentDiag || currentHighlight) {
717
+ const classes = ['flyql-diagnostic']
718
+ if (currentDiag) {
719
+ classes.push('flyql-diagnostic--' + (currentDiag.diag.severity === 'warning' ? 'warning' : 'error'))
720
+ }
721
+ if (currentHighlight) {
722
+ classes.push('flyql-diagnostic--highlight')
723
+ }
724
+ const title = currentDiag ? ` title="${escapeHtml(currentDiag.diag.message)}"` : ''
725
+ html += `<span class="${classes.join(' ')}"${title}>${inner}</span>`
726
+ } else {
727
+ html += inner
728
+ }
729
+ }
730
+
731
+ for (let i = 0; i < typedChars.length; i++) {
732
+ const charType = typedChars[i][1]
733
+ const ch = value[i] !== undefined ? value[i] : typedChars[i][0].value
734
+ const newDiag = diagMap ? diagMap[i] : null
735
+ const newHighlight = highlightSet ? highlightSet.has(i) : false
736
+ if (
737
+ charType === currentType &&
738
+ newDiag === currentDiag &&
739
+ newHighlight === currentHighlight &&
740
+ ch !== '\n'
741
+ ) {
742
+ currentText += ch
743
+ } else {
744
+ flushSpan()
745
+ currentType = charType
746
+ currentDiag = newDiag
747
+ currentHighlight = newHighlight
748
+ currentText = ch
749
+ }
750
+ }
751
+ flushSpan()
752
+
753
+ if (parser.state === State.ERROR && typedChars.length < value.length) {
754
+ const remaining = value.substring(typedChars.length)
755
+ html += `<span class="flyql-error">${escapeHtml(remaining)}</span>`
756
+ }
757
+
758
+ return html
759
+ }
760
+
761
+ /**
762
+ * Get current suggestions list.
763
+ */
764
+ getSuggestions() {
765
+ return this.suggestions
766
+ }
767
+
768
+ /**
769
+ * Get the current editor state snapshot.
770
+ */
771
+ getState() {
772
+ return {
773
+ query: this.state.query,
774
+ cursorPosition: this.state.cursorPosition,
775
+ focused: this.state.focused,
776
+ activated: this.state.activated,
777
+ composing: this.state.composing,
778
+ selectedIndex: this.state.selectedIndex,
779
+ context: this.context,
780
+ suggestions: this.suggestions,
781
+ suggestionType: this.suggestionType,
782
+ message: this.message,
783
+ incomplete: this.incomplete,
784
+ isLoading: this.isLoading,
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Get current parse error, if any.
790
+ */
791
+ getParseError() {
792
+ if (this.context && this.context.state === 'ERROR') {
793
+ return this.context.error
794
+ }
795
+ return null
796
+ }
797
+
798
+ /**
799
+ * Validate the full query and return status.
800
+ */
801
+ getQueryStatus() {
802
+ const value = this.state.query
803
+ if (!value) return { valid: true, message: 'Empty query' }
804
+ const normalized = normalizeForParser(value)
805
+ const parser = new Parser()
806
+ try {
807
+ parser.parse(normalized, false, false)
808
+ } catch (e) {
809
+ return { valid: false, message: e.message || 'Parse error' }
810
+ }
811
+ if (parser.state === State.ERROR) {
812
+ return { valid: false, message: parser.errorText || 'Parse error' }
813
+ }
814
+ if (
815
+ parser.state === State.EXPECT_BOOL_OP ||
816
+ parser.state === State.VALUE ||
817
+ parser.state === State.KEY ||
818
+ parser.state === State.KEY_OR_BOOL_OP
819
+ ) {
820
+ return { valid: true, message: 'Valid query' }
821
+ }
822
+ return { valid: false, message: 'Incomplete query' }
823
+ }
824
+
825
+ /**
826
+ * Get the text range to replace when accepting a suggestion.
827
+ */
828
+ getInsertRange(ctx, fullText) {
829
+ return getInsertRange(ctx || this.context, fullText || this.state.query, this.suggestionType)
830
+ }
831
+
832
+ /**
833
+ * Navigate suggestion selection up.
834
+ */
835
+ navigateUp() {
836
+ if (this.suggestions.length === 0) return
837
+ this.state.selectedIndex =
838
+ this.state.selectedIndex <= 0 ? this.suggestions.length - 1 : this.state.selectedIndex - 1
839
+ }
840
+
841
+ /**
842
+ * Navigate suggestion selection down.
843
+ */
844
+ navigateDown() {
845
+ if (this.suggestions.length === 0) return
846
+ this.state.selectedIndex =
847
+ this.state.selectedIndex >= this.suggestions.length - 1 ? 0 : this.state.selectedIndex + 1
848
+ }
849
+
850
+ /**
851
+ * Get the selected suggestion item.
852
+ */
853
+ selectSuggestion(index) {
854
+ const suggestion = this.suggestions[index]
855
+ if (!suggestion) return null
856
+ return suggestion
857
+ }
858
+
859
+ /**
860
+ * Get the state label for the current suggestion type.
861
+ */
862
+ getStateLabel() {
863
+ return STATE_LABELS[this.suggestionType] || ''
864
+ }
865
+
866
+ /**
867
+ * Get filter prefix for highlighting matched text in suggestions.
868
+ */
869
+ getFilterPrefix() {
870
+ return this.state.getFilterPrefix(this.context)
871
+ }
872
+
873
+ /**
874
+ * Clear the key discovery cache (e.g., when deactivating).
875
+ */
876
+ clearKeyCache() {
877
+ this.keyCache = {}
878
+ }
879
+
880
+ /**
881
+ * Highlight the matching portion of a suggestion label.
882
+ */
883
+ highlightMatch(label) {
884
+ const prefix = this.getFilterPrefix()
885
+ if (!prefix) return escapeHtml(label)
886
+ if (!label.toLowerCase().startsWith(prefix.toLowerCase())) return escapeHtml(label)
887
+ const matched = escapeHtml(label.substring(0, prefix.length))
888
+ const rest = escapeHtml(label.substring(prefix.length))
889
+ return `<span class="flyql-panel__match">${matched}</span>${rest}`
890
+ }
891
+ }