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.
@@ -0,0 +1,846 @@
1
+ /**
2
+ * ColumnsEngine — framework-agnostic columns expression editor logic.
3
+ * Pure JS class, no Vue/React/DOM dependencies.
4
+ * Uses the columns parser (flyql/columns) instead of the core query parser.
5
+ * One instance per columns editor component.
6
+ */
7
+
8
+ import {
9
+ Parser,
10
+ parse as parseColumns,
11
+ diagnose,
12
+ CharType,
13
+ State,
14
+ TRANSFORMER_OPERATOR,
15
+ COLUMNS_DELIMITER,
16
+ } from 'flyql/columns'
17
+ import { defaultRegistry } from 'flyql/transformers'
18
+ import { EditorState } from './state.js'
19
+ import { getNestedColumnSuggestions, resolveColumnDef, getKeyDiscoverySuggestions } from './suggestions.js'
20
+ import { Column, ColumnSchema, Diagnostic, Range, CODE_UNKNOWN_COLUMN, CODE_UNKNOWN_TRANSFORMER } from 'flyql/core'
21
+ import { Type } from 'flyql'
22
+
23
+ /** Maps editor-input raw-type strings to canonical flyql.Type. */
24
+ const EDITOR_TYPE_TO_FLYQL = {
25
+ enum: Type.String,
26
+ string: Type.String,
27
+ number: Type.Int,
28
+ int: Type.Int,
29
+ integer: Type.Int,
30
+ float: Type.Float,
31
+ bool: Type.Bool,
32
+ boolean: Type.Bool,
33
+ array: Type.Array,
34
+ map: Type.Map,
35
+ struct: Type.Struct,
36
+ json: Type.JSON,
37
+ date: Type.Date,
38
+ }
39
+
40
+ const _FLYQL_TYPE_VALUES = new Set(Object.values(Type))
41
+
42
+ function _applyEditorTypeNormalization(col) {
43
+ if (col.type && !_FLYQL_TYPE_VALUES.has(col.type)) {
44
+ const mapped = EDITOR_TYPE_TO_FLYQL[col.type]
45
+ col.type = mapped !== undefined ? mapped : Type.Unknown
46
+ }
47
+ if (col.children) {
48
+ for (const child of Object.values(col.children)) {
49
+ if (child) _applyEditorTypeNormalization(child)
50
+ }
51
+ }
52
+ }
53
+
54
+ const COL_CHAR_TYPE_CLASS = {
55
+ [CharType.COLUMN]: 'flyql-col-column',
56
+ [CharType.OPERATOR]: 'flyql-col-operator',
57
+ [CharType.TRANSFORMER]: 'flyql-col-transformer',
58
+ [CharType.ARGUMENT]: 'flyql-col-argument',
59
+ [CharType.ALIAS]: 'flyql-col-alias',
60
+ [CharType.ERROR]: 'flyql-col-error',
61
+ }
62
+
63
+ const STATE_LABELS = {
64
+ column: 'column name',
65
+ transformer: 'transformers',
66
+ delimiter: 'next',
67
+ alias: 'next',
68
+ argument: 'arguments',
69
+ next: 'column name, separator or transformer',
70
+ none: '',
71
+ }
72
+
73
+ function escapeHtml(str) {
74
+ return str
75
+ .replace(/&/g, '&')
76
+ .replace(/</g, '&lt;')
77
+ .replace(/>/g, '&gt;')
78
+ .replace(/"/g, '&quot;')
79
+ .replace(/'/g, '&#x27;')
80
+ }
81
+
82
+ function wrapSpan(charType, text) {
83
+ const escaped = escapeHtml(text)
84
+ const cls = COL_CHAR_TYPE_CLASS[charType]
85
+ if (cls) {
86
+ return `<span class="${cls}">${escaped}</span>`
87
+ }
88
+ return escaped
89
+ }
90
+
91
+ export class ColumnsEngine {
92
+ constructor(schema, options = {}) {
93
+ this.columns = schema || new ColumnSchema({})
94
+ for (const col of Object.values(this.columns.columns)) {
95
+ if (col) _applyEditorTypeNormalization(col)
96
+ }
97
+ const capDefaults = { transformers: true }
98
+ this.capabilities = options.capabilities ? { ...capDefaults, ...options.capabilities } : { ...capDefaults }
99
+ this.onKeyDiscovery = options.onKeyDiscovery || null
100
+ this.onLoadingChange = options.onLoadingChange || null
101
+ this.registry = options.registry || defaultRegistry()
102
+ this.keyCache = {}
103
+ this.state = new EditorState()
104
+ this.context = null
105
+ this.suggestions = []
106
+ this.suggestionType = ''
107
+ this.message = ''
108
+ this.isLoading = false
109
+ this.diagnostics = []
110
+ this._seq = 0
111
+ }
112
+
113
+ setColumns(schema) {
114
+ this.columns = schema || new ColumnSchema({})
115
+ for (const col of Object.values(this.columns.columns)) {
116
+ if (col) _applyEditorTypeNormalization(col)
117
+ }
118
+ }
119
+
120
+ setRegistry(registry) {
121
+ this.registry = registry || defaultRegistry()
122
+ }
123
+
124
+ _transformerDetail(name) {
125
+ const t = this.registry.get(name)
126
+ if (!t) return ''
127
+ const schema = t.argSchema
128
+ if (!schema || schema.length === 0) return `${t.inputType} → ${t.outputType}`
129
+ const parts = schema.map((a) => (a.optional ? a.type + '?' : a.type))
130
+ return '(' + parts.join(', ') + ') ' + t.inputType + ' → ' + t.outputType
131
+ }
132
+
133
+ setQuery(text) {
134
+ this.state.setQuery(text)
135
+ }
136
+
137
+ setCursorPosition(pos) {
138
+ this.state.setCursorPosition(pos)
139
+ }
140
+
141
+ /**
142
+ * Build context from text before cursor — determines what the editor expects next.
143
+ */
144
+ buildContext(textBeforeCursor, fullText) {
145
+ if (!textBeforeCursor) {
146
+ return {
147
+ expecting: 'column',
148
+ column: '',
149
+ transformer: '',
150
+ state: State.EXPECT_COLUMN,
151
+ textBeforeCursor: '',
152
+ existingColumns: [],
153
+ }
154
+ }
155
+
156
+ const parser = new Parser(this.capabilities)
157
+ try {
158
+ parser.parse(textBeforeCursor, false, true)
159
+ } catch (e) {
160
+ return {
161
+ expecting: 'error',
162
+ column: '',
163
+ transformer: '',
164
+ state: State.ERROR,
165
+ error: e.message || 'Parse error',
166
+ textBeforeCursor,
167
+ existingColumns: parser.columns ? parser.columns.map((c) => c.name) : [],
168
+ }
169
+ }
170
+
171
+ if (parser.state === State.ERROR) {
172
+ return {
173
+ expecting: 'error',
174
+ column: '',
175
+ transformer: '',
176
+ state: State.ERROR,
177
+ error: parser.errorText || 'Parse error',
178
+ textBeforeCursor,
179
+ existingColumns: parser.columns ? parser.columns.map((c) => c.name) : [],
180
+ }
181
+ }
182
+
183
+ const existingColumns = parser.columns ? parser.columns.map((c) => c.name) : []
184
+
185
+ const ctx = {
186
+ state: parser.state,
187
+ column: parser.column || '',
188
+ transformer: parser.transformer || '',
189
+ expecting: 'none',
190
+ textBeforeCursor,
191
+ existingColumns,
192
+ }
193
+
194
+ if (parser.state === State.EXPECT_COLUMN || parser.state === State.COLUMN) {
195
+ ctx.expecting = 'column'
196
+ } else if (parser.state === State.EXPECT_TRANSFORMER || parser.state === State.TRANSFORMER) {
197
+ ctx.expecting = 'transformer'
198
+ } else if (
199
+ parser.state === State.EXPECT_ALIAS ||
200
+ parser.state === State.EXPECT_ALIAS_OPERATOR ||
201
+ parser.state === State.EXPECT_ALIAS_DELIMITER
202
+ ) {
203
+ ctx.expecting = 'alias'
204
+ } else if (
205
+ parser.state === State.TRANSFORMER_ARGUMENT ||
206
+ parser.state === State.EXPECT_TRANSFORMER_ARGUMENT ||
207
+ parser.state === State.TRANSFORMER_ARGUMENT_DOUBLE_QUOTED ||
208
+ parser.state === State.TRANSFORMER_ARGUMENT_SINGLE_QUOTED ||
209
+ parser.state === State.EXPECT_TRANSFORMER_ARGUMENT_DELIMITER
210
+ ) {
211
+ // Peek at char after cursor: if it's ) and parser is between args
212
+ // (waiting for comma or close), user is done — show next steps.
213
+ // But NOT when actively typing an argument or in empty parens.
214
+ const charAtCursor = fullText ? fullText[textBeforeCursor.length] : undefined
215
+ if (charAtCursor === ')' && parser.state === State.EXPECT_TRANSFORMER_ARGUMENT_DELIMITER) {
216
+ ctx.expecting = 'next'
217
+ } else {
218
+ ctx.expecting = 'argument'
219
+ }
220
+ } else if (parser.state === State.TRANSFORMER_COMPLETE) {
221
+ ctx.expecting = 'next'
222
+ }
223
+
224
+ return ctx
225
+ }
226
+
227
+ /**
228
+ * Update suggestions based on current cursor position.
229
+ */
230
+ async updateSuggestions() {
231
+ const seq = ++this._seq
232
+ const textBeforeCursor = this.state.getTextBeforeCursor()
233
+ const ctx = this.buildContext(textBeforeCursor, this.state.query)
234
+ this.context = ctx
235
+ this.message = ''
236
+ this.isLoading = false
237
+ this.suggestions = []
238
+ this.suggestionType = ''
239
+ this.state.selectedIndex = 0
240
+
241
+ if (ctx.expecting === 'column') {
242
+ const prefix = ctx.column.toLowerCase()
243
+ const existing = ctx.existingColumns
244
+
245
+ // Nested column path — delegate to shared helper
246
+ if (prefix.includes('.')) {
247
+ // Check if it's an exact leaf match — show next-step actions
248
+ const resolvedCol = resolveColumnDef(this.columns, ctx.column)
249
+ if (resolvedCol && !resolvedCol.children) {
250
+ const nextSteps = [
251
+ {
252
+ label: COLUMNS_DELIMITER,
253
+ insertText: COLUMNS_DELIMITER + ' ',
254
+ type: 'delimiter',
255
+ detail: 'next column',
256
+ },
257
+ ]
258
+ if (this.capabilities.transformers) {
259
+ nextSteps.push({
260
+ label: TRANSFORMER_OPERATOR,
261
+ insertText: TRANSFORMER_OPERATOR,
262
+ type: 'delimiter',
263
+ detail: 'add transformer',
264
+ })
265
+ }
266
+ const nested = getNestedColumnSuggestions(this.columns, ctx.column).filter(
267
+ (s) => !existing.includes(s.label) && s.label.toLowerCase() !== prefix,
268
+ )
269
+ this.suggestions = [...nextSteps, ...nested]
270
+ this.suggestionType = 'column'
271
+ return { ctx, seq }
272
+ }
273
+
274
+ const nested = getNestedColumnSuggestions(this.columns, ctx.column)
275
+ if (nested.length === 0) {
276
+ // Try remote key discovery for schemaless object columns
277
+ const discovered = await getKeyDiscoverySuggestions(
278
+ this.columns,
279
+ ctx.column,
280
+ this.onKeyDiscovery,
281
+ this.keyCache,
282
+ (loading) => {
283
+ if (this.isStale(seq)) return
284
+ this.isLoading = loading
285
+ if (this.onLoadingChange) this.onLoadingChange(loading)
286
+ },
287
+ )
288
+ if (this.isStale(seq)) return { ctx, seq }
289
+ this.suggestions = discovered.filter((s) => !existing.includes(s.label))
290
+ this.suggestionType = 'column'
291
+ if (this.suggestions.length === 0 && prefix) {
292
+ this.message = 'No matching columns'
293
+ }
294
+ return { ctx, seq }
295
+ }
296
+ this.suggestions = nested.filter((s) => !existing.includes(s.label))
297
+ this.suggestionType = 'column'
298
+ if (this.suggestions.length === 0 && prefix) {
299
+ this.message = 'No matching columns'
300
+ }
301
+ return { ctx, seq }
302
+ }
303
+
304
+ const columnSuggestions = []
305
+ let hasExactMatch = false
306
+ for (const [name, def] of Object.entries(this.columns.columns)) {
307
+ if (!def || def.suggest === false) continue
308
+ if (existing.includes(name)) continue
309
+ if (prefix && !name.toLowerCase().startsWith(prefix)) continue
310
+ if (prefix && name.toLowerCase() === prefix) hasExactMatch = true
311
+ const hasChildren = !!def.children
312
+ columnSuggestions.push({
313
+ label: name,
314
+ insertText: hasChildren ? name + '.' : name,
315
+ type: 'column',
316
+ detail: def.type || '',
317
+ })
318
+ }
319
+
320
+ if (hasExactMatch && prefix) {
321
+ // Exact match — check if it has children (object/map column)
322
+ const matchedDef = resolveColumnDef(this.columns, ctx.column)
323
+ if (matchedDef && matchedDef.children && Object.keys(matchedDef.children).length > 0) {
324
+ // Object column with children — show nested paths
325
+ const nested = getNestedColumnSuggestions(this.columns, ctx.column + '.')
326
+ this.suggestions = nested.filter((s) => !existing.includes(s.label))
327
+ this.suggestionType = 'column'
328
+ return { ctx, seq }
329
+ }
330
+
331
+ // Leaf column — show next-step actions first, then remaining columns
332
+ const nextSteps = [
333
+ {
334
+ label: COLUMNS_DELIMITER,
335
+ insertText: COLUMNS_DELIMITER + ' ',
336
+ type: 'delimiter',
337
+ detail: 'next column',
338
+ },
339
+ ]
340
+ if (this.capabilities.transformers) {
341
+ nextSteps.push({
342
+ label: TRANSFORMER_OPERATOR,
343
+ insertText: TRANSFORMER_OPERATOR,
344
+ type: 'transformer',
345
+ detail: 'transformer (pipe)',
346
+ })
347
+ }
348
+ const otherColumns = columnSuggestions.filter((s) => s.label.toLowerCase() !== prefix)
349
+ this.suggestions = [...otherColumns, ...nextSteps]
350
+ this.suggestionType = 'next'
351
+ } else {
352
+ this.suggestions = columnSuggestions
353
+ this.suggestionType = 'column'
354
+ if (columnSuggestions.length === 0 && prefix) {
355
+ this.message = 'No matching columns'
356
+ }
357
+ }
358
+ } else if (ctx.expecting === 'transformer') {
359
+ if (!this.capabilities.transformers) {
360
+ this.message = 'transformers are not enabled'
361
+ this.suggestionType = ''
362
+ return { ctx, seq }
363
+ }
364
+ const prefix = ctx.transformer.toLowerCase()
365
+ const names = this.registry.names()
366
+ const hasExactMatch = prefix && names.some((m) => m.toLowerCase() === prefix)
367
+
368
+ if (hasExactMatch) {
369
+ const matchedName = names.find((m) => m.toLowerCase() === prefix)
370
+ const t = matchedName ? this.registry.get(matchedName) : null
371
+ const hasArgs = t && t.argSchema && t.argSchema.length > 0
372
+ const nextSteps = [
373
+ {
374
+ label: COLUMNS_DELIMITER,
375
+ insertText: COLUMNS_DELIMITER + ' ',
376
+ type: 'delimiter',
377
+ detail: 'next column',
378
+ },
379
+ ]
380
+ if (hasArgs) {
381
+ nextSteps.push({
382
+ label: '()',
383
+ insertText: '()',
384
+ type: 'delimiter',
385
+ detail: this._transformerDetail(prefix),
386
+ cursorOffset: -1,
387
+ })
388
+ }
389
+ nextSteps.push({
390
+ label: TRANSFORMER_OPERATOR,
391
+ insertText: TRANSFORMER_OPERATOR,
392
+ type: 'transformer',
393
+ detail: 'transformer (pipe)',
394
+ })
395
+ const otherMods = []
396
+ for (const mod of names) {
397
+ if (mod.toLowerCase() === prefix) continue
398
+ if (!mod.toLowerCase().startsWith(prefix)) continue
399
+ otherMods.push({
400
+ label: mod,
401
+ insertText: mod,
402
+ type: 'transformer',
403
+ detail: this._transformerDetail(mod),
404
+ })
405
+ }
406
+ this.suggestions = [...otherMods, ...nextSteps]
407
+ this.suggestionType = 'next'
408
+ } else {
409
+ const suggestions = []
410
+ for (const mod of names) {
411
+ if (prefix && !mod.toLowerCase().startsWith(prefix)) continue
412
+ suggestions.push({
413
+ label: mod,
414
+ insertText: mod,
415
+ type: 'transformer',
416
+ detail: this._transformerDetail(mod),
417
+ })
418
+ }
419
+ this.suggestions = suggestions
420
+ this.suggestionType = 'transformer'
421
+ if (suggestions.length === 0 && prefix) {
422
+ this.message = 'No matching transformers'
423
+ }
424
+ }
425
+ } else if (ctx.expecting === 'alias') {
426
+ if (ctx.state === State.EXPECT_ALIAS) {
427
+ // Inside alias value (e.g. "column as RC") — only separator is valid
428
+ this.suggestions = [
429
+ {
430
+ label: COLUMNS_DELIMITER,
431
+ insertText: COLUMNS_DELIMITER + ' ',
432
+ type: 'delimiter',
433
+ detail: 'next column',
434
+ },
435
+ ]
436
+ } else {
437
+ // After column/transformer+space, before alias operator — pipe and comma are valid
438
+ const items = []
439
+ if (this.capabilities.transformers) {
440
+ items.push({
441
+ label: TRANSFORMER_OPERATOR,
442
+ insertText: TRANSFORMER_OPERATOR,
443
+ type: 'transformer',
444
+ detail: 'transformer (pipe)',
445
+ })
446
+ }
447
+ items.push({
448
+ label: COLUMNS_DELIMITER,
449
+ insertText: COLUMNS_DELIMITER + ' ',
450
+ type: 'delimiter',
451
+ detail: 'next column',
452
+ })
453
+ this.suggestions = items
454
+ }
455
+ this.suggestionType = 'delimiter'
456
+ } else if (ctx.expecting === 'next') {
457
+ // After transformer with args completes — suggest comma or pipe
458
+ const items = [
459
+ {
460
+ label: COLUMNS_DELIMITER,
461
+ insertText: COLUMNS_DELIMITER + ' ',
462
+ type: 'delimiter',
463
+ detail: 'next column',
464
+ },
465
+ ]
466
+ if (this.capabilities.transformers) {
467
+ items.push({
468
+ label: TRANSFORMER_OPERATOR,
469
+ insertText: TRANSFORMER_OPERATOR,
470
+ type: 'transformer',
471
+ detail: 'transformer (pipe)',
472
+ })
473
+ }
474
+ this.suggestions = items
475
+ this.suggestionType = 'delimiter'
476
+ } else if (ctx.expecting === 'error') {
477
+ this.message = ctx.error
478
+ this.suggestionType = ''
479
+ }
480
+
481
+ return { ctx, seq }
482
+ }
483
+
484
+ isStale(seq) {
485
+ return seq !== this._seq
486
+ }
487
+
488
+ /**
489
+ * Generate highlight tokens as HTML string.
490
+ */
491
+ getHighlightTokens(query, diagnostics = null, highlightDiagIndex = -1) {
492
+ const value = query !== undefined ? query : this.state.query
493
+ if (!value) return ''
494
+
495
+ const parser = new Parser(this.capabilities)
496
+ try {
497
+ parser.parse(value, false, true)
498
+ } catch {
499
+ return escapeHtml(value)
500
+ }
501
+
502
+ const typedChars = parser.typedChars
503
+ if (!typedChars || typedChars.length === 0) {
504
+ return escapeHtml(value)
505
+ }
506
+
507
+ // Build per-position diagnostic map
508
+ let diagMap = null
509
+ let highlightSet = null
510
+ if (diagnostics && diagnostics.length > 0) {
511
+ diagMap = {}
512
+ for (let di = 0; di < diagnostics.length; di++) {
513
+ const d = diagnostics[di]
514
+ for (let j = d.range.start; j < d.range.end && j < value.length; j++) {
515
+ if (!diagMap[j]) {
516
+ diagMap[j] = { diag: d, index: di }
517
+ }
518
+ }
519
+ }
520
+ if (highlightDiagIndex >= 0 && highlightDiagIndex < diagnostics.length) {
521
+ highlightSet = new Set()
522
+ const hd = diagnostics[highlightDiagIndex]
523
+ for (let j = hd.range.start; j < hd.range.end && j < value.length; j++) {
524
+ highlightSet.add(j)
525
+ }
526
+ }
527
+ }
528
+
529
+ // Build highlight using char positions — columns parser skips spaces
530
+ // in some states, so typedChars count != value length. Use pos to align.
531
+ let html = ''
532
+ let currentType = null
533
+ let currentText = ''
534
+ let currentDiag = null
535
+ let currentHighlight = false
536
+ let lastPos = -1
537
+
538
+ const flushSpan = () => {
539
+ if (!currentText) return
540
+ const inner = wrapSpan(currentType, currentText)
541
+ if (currentDiag || currentHighlight) {
542
+ const classes = ['flyql-diagnostic']
543
+ if (currentDiag) {
544
+ classes.push('flyql-diagnostic--' + (currentDiag.diag.severity === 'warning' ? 'warning' : 'error'))
545
+ }
546
+ if (currentHighlight) {
547
+ classes.push('flyql-diagnostic--highlight')
548
+ }
549
+ const title = currentDiag ? ` title="${escapeHtml(currentDiag.diag.message)}"` : ''
550
+ html += `<span class="${classes.join(' ')}"${title}>${inner}</span>`
551
+ } else {
552
+ html += inner
553
+ }
554
+ }
555
+
556
+ for (let i = 0; i < typedChars.length; i++) {
557
+ const char = typedChars[i][0]
558
+ const charType = typedChars[i][1]
559
+ const pos = char.pos
560
+
561
+ // Fill any gap (untracked chars like spaces) as plain text
562
+ if (pos > lastPos + 1) {
563
+ flushSpan()
564
+ currentText = ''
565
+ currentType = null
566
+ currentDiag = null
567
+ currentHighlight = false
568
+ // Render gap characters with diagnostic overlay
569
+ for (let gapPos = lastPos + 1; gapPos < pos; gapPos++) {
570
+ const gapDiag = diagMap ? diagMap[gapPos] || null : null
571
+ const gapHighlight = highlightSet ? highlightSet.has(gapPos) : false
572
+ const gapCh = value[gapPos]
573
+ if (gapDiag || gapHighlight) {
574
+ const classes = ['flyql-diagnostic']
575
+ if (gapDiag)
576
+ classes.push(
577
+ 'flyql-diagnostic--' + (gapDiag.diag.severity === 'warning' ? 'warning' : 'error'),
578
+ )
579
+ if (gapHighlight) classes.push('flyql-diagnostic--highlight')
580
+ const title = gapDiag ? ` title="${escapeHtml(gapDiag.diag.message)}"` : ''
581
+ html += `<span class="${classes.join(' ')}"${title}>${escapeHtml(gapCh)}</span>`
582
+ } else {
583
+ html += escapeHtml(gapCh)
584
+ }
585
+ }
586
+ }
587
+
588
+ const ch = value[pos] !== undefined ? value[pos] : char.value
589
+ const newDiag = diagMap ? diagMap[pos] || null : null
590
+ const newHighlight = highlightSet ? highlightSet.has(pos) : false
591
+ if (
592
+ charType === currentType &&
593
+ newDiag === currentDiag &&
594
+ newHighlight === currentHighlight &&
595
+ ch !== '\n'
596
+ ) {
597
+ currentText += ch
598
+ } else {
599
+ flushSpan()
600
+ currentType = charType
601
+ currentDiag = newDiag
602
+ currentHighlight = newHighlight
603
+ currentText = ch
604
+ }
605
+ lastPos = pos
606
+ }
607
+ flushSpan()
608
+
609
+ // Render any remaining untracked characters after last typed char
610
+ if (lastPos + 1 < value.length && parser.state !== State.ERROR) {
611
+ html += escapeHtml(value.substring(lastPos + 1))
612
+ }
613
+
614
+ if (parser.state === State.ERROR && lastPos + 1 < value.length) {
615
+ const remaining = value.substring(lastPos + 1)
616
+ html += `<span class="flyql-col-error">${escapeHtml(remaining)}</span>`
617
+ }
618
+
619
+ return html
620
+ }
621
+
622
+ /**
623
+ * Parse the full expression and return ParsedColumn array.
624
+ */
625
+ getParsedColumns() {
626
+ const value = this.state.query
627
+ if (!value) return []
628
+ try {
629
+ return parseColumns(value, this.capabilities)
630
+ } catch {
631
+ return []
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Validate the expression and return status.
637
+ */
638
+ getQueryStatus() {
639
+ const value = this.state.query
640
+ if (!value) return { valid: true, message: 'Empty' }
641
+ const parser = new Parser(this.capabilities)
642
+ try {
643
+ parser.parse(value, false, false)
644
+ } catch (e) {
645
+ return { valid: false, message: e.message || 'Parse error' }
646
+ }
647
+ if (parser.state === State.ERROR) {
648
+ return { valid: false, message: parser.errorText || 'Parse error' }
649
+ }
650
+ if (
651
+ parser.state === State.COLUMN ||
652
+ parser.state === State.EXPECT_COLUMN ||
653
+ parser.state === State.EXPECT_ALIAS_OPERATOR ||
654
+ parser.state === State.EXPECT_ALIAS ||
655
+ parser.state === State.TRANSFORMER ||
656
+ parser.state === State.TRANSFORMER_COMPLETE
657
+ ) {
658
+ return { valid: true, message: 'Valid columns expression' }
659
+ }
660
+ return { valid: false, message: 'Incomplete expression' }
661
+ }
662
+
663
+ _buildValidatorColumns() {
664
+ return this.columns
665
+ }
666
+
667
+ getDiagnostics() {
668
+ const value = this.state.query
669
+ if (!value) {
670
+ this.diagnostics = []
671
+ return this.diagnostics
672
+ }
673
+
674
+ const parser = new Parser(this.capabilities)
675
+ let typedChars
676
+ try {
677
+ parser.parse(value, false, false)
678
+ typedChars = parser.typedChars
679
+ } catch (e) {
680
+ typedChars = parser.typedChars
681
+ const start = typedChars && typedChars.length > 0 ? typedChars[typedChars.length - 1][0].pos + 1 : 0
682
+ // Suppress syntax errors at end of input
683
+ if (start >= value.length - 1) {
684
+ this.diagnostics = []
685
+ return this.diagnostics
686
+ }
687
+ this.diagnostics = [
688
+ new Diagnostic(new Range(start, value.length), e.message || 'Parse error', 'error', 'syntax'),
689
+ ]
690
+ return this.diagnostics
691
+ }
692
+
693
+ if (parser.state === State.ERROR) {
694
+ const start = typedChars && typedChars.length > 0 ? typedChars[typedChars.length - 1][0].pos + 1 : 0
695
+ if (start >= value.length - 1) {
696
+ this.diagnostics = []
697
+ return this.diagnostics
698
+ }
699
+ this.diagnostics = [
700
+ new Diagnostic(new Range(start, value.length), parser.errorText || 'Parse error', 'error', 'syntax'),
701
+ ]
702
+ return this.diagnostics
703
+ }
704
+
705
+ let parsedColumns
706
+ try {
707
+ parsedColumns = parseColumns(value, this.capabilities)
708
+ } catch {
709
+ this.diagnostics = []
710
+ return this.diagnostics
711
+ }
712
+
713
+ if (!parsedColumns || parsedColumns.length === 0) {
714
+ this.diagnostics = []
715
+ return this.diagnostics
716
+ }
717
+
718
+ const validatorColumns = this._buildValidatorColumns()
719
+ const reg = this.registry
720
+
721
+ try {
722
+ this.diagnostics = diagnose(parsedColumns, validatorColumns, reg)
723
+ } catch {
724
+ this.diagnostics = []
725
+ }
726
+
727
+ // Smart suppression at end of input
728
+ if (this.diagnostics.length > 0) {
729
+ const queryLen = value.trimEnd().length
730
+ const transformerNames = reg.names()
731
+ const columnNames = Object.keys(this.columns.columns).map((n) => n.toLowerCase())
732
+ this.diagnostics = this.diagnostics.filter((d) => {
733
+ if (d.range.end < queryLen) return true
734
+ if (d.code === CODE_UNKNOWN_TRANSFORMER) {
735
+ const match = d.message.match(/^unknown transformer: '(.+)'$/)
736
+ if (!match) return true
737
+ const partial = match[1]
738
+ return !transformerNames.some((n) => n.startsWith(partial) && n !== partial)
739
+ }
740
+ if (d.code === CODE_UNKNOWN_COLUMN) {
741
+ const match = d.message.match(/^column '(.+)' is not defined$/)
742
+ if (!match) return true
743
+ const partial = match[1].toLowerCase()
744
+ return !columnNames.some((n) => n.startsWith(partial) && n !== partial)
745
+ }
746
+ return true
747
+ })
748
+ }
749
+
750
+ return this.diagnostics
751
+ }
752
+
753
+ getParseError() {
754
+ if (this.context && this.context.expecting === 'error') {
755
+ return this.context.error
756
+ }
757
+ return null
758
+ }
759
+
760
+ /**
761
+ * Get the text range to replace when accepting a suggestion.
762
+ * If suggestion is an operator type, insert at cursor without replacing prefix.
763
+ */
764
+ getInsertRange(ctx, fullText, suggestion) {
765
+ const context = ctx || this.context
766
+ if (!context) return { start: 0, end: 0 }
767
+
768
+ const cursor = context.textBeforeCursor.length
769
+
770
+ // Calculate end position: include trailing word chars after cursor
771
+ let endPos = cursor
772
+ if (fullText) {
773
+ const afterCursor = fullText.substring(cursor)
774
+ const trailingMatch = afterCursor.match(/^[^\s,|()]+/)
775
+ if (trailingMatch) {
776
+ endPos = cursor + trailingMatch[0].length
777
+ }
778
+ }
779
+
780
+ // Delimiter and pipe suggestions insert at cursor, don't replace prefix
781
+ if (suggestion && (suggestion.type === 'delimiter' || suggestion.label === '|' || suggestion.label === '()')) {
782
+ return { start: cursor, end: cursor }
783
+ }
784
+
785
+ if (context.expecting === 'column') {
786
+ const prefix = context.column || ''
787
+ return { start: cursor - prefix.length, end: endPos }
788
+ }
789
+ if (context.expecting === 'transformer') {
790
+ const prefix = context.transformer || ''
791
+ return { start: cursor - prefix.length, end: endPos }
792
+ }
793
+ return { start: cursor, end: endPos }
794
+ }
795
+
796
+ navigateUp() {
797
+ if (this.suggestions.length === 0) return
798
+ this.state.selectedIndex =
799
+ this.state.selectedIndex <= 0 ? this.suggestions.length - 1 : this.state.selectedIndex - 1
800
+ }
801
+
802
+ navigateDown() {
803
+ if (this.suggestions.length === 0) return
804
+ this.state.selectedIndex =
805
+ this.state.selectedIndex >= this.suggestions.length - 1 ? 0 : this.state.selectedIndex + 1
806
+ }
807
+
808
+ selectSuggestion(index) {
809
+ return this.suggestions[index] || null
810
+ }
811
+
812
+ getStateLabel() {
813
+ return STATE_LABELS[this.suggestionType] || ''
814
+ }
815
+
816
+ getFooterInfo() {
817
+ if (!this.context) return null
818
+ const col = this.context.column || ''
819
+ if (!col) return null
820
+ const def = resolveColumnDef(this.columns, col)
821
+ if (def) {
822
+ return { column: col, type: def.type || '' }
823
+ }
824
+ return { column: col, type: '' }
825
+ }
826
+
827
+ clearKeyCache() {
828
+ this.keyCache = {}
829
+ }
830
+
831
+ getFilterPrefix() {
832
+ if (!this.context) return ''
833
+ if (this.context.expecting === 'column') return this.context.column || ''
834
+ if (this.context.expecting === 'transformer') return this.context.transformer || ''
835
+ return ''
836
+ }
837
+
838
+ highlightMatch(label) {
839
+ const prefix = this.getFilterPrefix()
840
+ if (!prefix) return escapeHtml(label)
841
+ if (!label.toLowerCase().startsWith(prefix.toLowerCase())) return escapeHtml(label)
842
+ const matched = escapeHtml(label.substring(0, prefix.length))
843
+ const rest = escapeHtml(label.substring(prefix.length))
844
+ return `<span class="flyql-panel__match">${matched}</span>${rest}`
845
+ }
846
+ }