@health-samurai/react-components 0.0.0-alpha.18 → 0.0.0-alpha.20

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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bundle.css +51 -33
  3. package/dist/src/components/code-editor/fhir-autocomplete.d.ts +70 -0
  4. package/dist/src/components/code-editor/fhir-autocomplete.d.ts.map +1 -0
  5. package/dist/src/components/code-editor/fhir-autocomplete.js +1849 -0
  6. package/dist/src/components/code-editor/fhir-autocomplete.js.map +1 -0
  7. package/dist/src/components/code-editor/fhir-autocomplete.test.js +1099 -0
  8. package/dist/src/components/code-editor/fhir-autocomplete.test.js.map +1 -0
  9. package/dist/src/components/code-editor/http/index.d.ts +9 -1
  10. package/dist/src/components/code-editor/http/index.d.ts.map +1 -1
  11. package/dist/src/components/code-editor/http/index.js +423 -3
  12. package/dist/src/components/code-editor/http/index.js.map +1 -1
  13. package/dist/src/components/code-editor/index.d.ts +13 -4
  14. package/dist/src/components/code-editor/index.d.ts.map +1 -1
  15. package/dist/src/components/code-editor/index.js +505 -96
  16. package/dist/src/components/code-editor/index.js.map +1 -1
  17. package/dist/src/components/code-editor/json-ast.d.ts +46 -0
  18. package/dist/src/components/code-editor/json-ast.d.ts.map +1 -0
  19. package/dist/src/components/code-editor/json-ast.js +465 -0
  20. package/dist/src/components/code-editor/json-ast.js.map +1 -0
  21. package/dist/src/components/code-editor/json-ast.test.js +206 -0
  22. package/dist/src/components/code-editor/json-ast.test.js.map +1 -0
  23. package/dist/src/components/code-editor/sql-completion.d.ts +22 -0
  24. package/dist/src/components/code-editor/sql-completion.d.ts.map +1 -0
  25. package/dist/src/components/code-editor/sql-completion.js +895 -0
  26. package/dist/src/components/code-editor/sql-completion.js.map +1 -0
  27. package/dist/src/components/date-picker-input.d.ts +10 -0
  28. package/dist/src/components/date-picker-input.d.ts.map +1 -0
  29. package/dist/src/components/date-picker-input.js +90 -0
  30. package/dist/src/components/date-picker-input.js.map +1 -0
  31. package/dist/src/components/date-picker-input.stories.js +76 -0
  32. package/dist/src/components/date-picker-input.stories.js.map +1 -0
  33. package/dist/src/index.d.ts +1 -0
  34. package/dist/src/index.d.ts.map +1 -1
  35. package/dist/src/index.js +1 -0
  36. package/dist/src/index.js.map +1 -1
  37. package/dist/src/shadcn/components/ui/alert-dialog.d.ts +1 -1
  38. package/dist/src/shadcn/components/ui/calendar.d.ts +1 -1
  39. package/dist/src/shadcn/components/ui/carousel.d.ts +1 -1
  40. package/dist/src/shadcn/components/ui/chart.d.ts +3 -3
  41. package/dist/src/shadcn/components/ui/chart.d.ts.map +1 -1
  42. package/dist/src/shadcn/components/ui/chart.js +1 -1
  43. package/dist/src/shadcn/components/ui/chart.js.map +1 -1
  44. package/dist/src/shadcn/components/ui/command.d.ts +1 -1
  45. package/dist/src/shadcn/components/ui/pagination.d.ts +1 -1
  46. package/dist/src/shadcn/components/ui/resizable.stories.js +2 -2
  47. package/dist/src/shadcn/components/ui/resizable.stories.js.map +1 -1
  48. package/dist/src/shadcn/components/ui/sidebar.d.ts +4 -4
  49. package/dist/src/shadcn/components/ui/tabs.d.ts +3 -1
  50. package/dist/src/shadcn/components/ui/tabs.d.ts.map +1 -1
  51. package/dist/src/shadcn/components/ui/tabs.js +129 -2
  52. package/dist/src/shadcn/components/ui/tabs.js.map +1 -1
  53. package/dist/src/shadcn/components/ui/tabs.stories.js +1 -1
  54. package/dist/src/shadcn/components/ui/tabs.stories.js.map +1 -1
  55. package/dist/src/shadcn/components/ui/toggle-group.d.ts +1 -1
  56. package/dist/src/typography.css +1 -1
  57. package/package.json +24 -19
  58. package/src/components/code-editor/fhir-autocomplete.test.ts +993 -0
  59. package/src/components/code-editor/fhir-autocomplete.ts +2321 -0
  60. package/src/components/code-editor/http/index.ts +339 -2
  61. package/src/components/code-editor/index.tsx +593 -102
  62. package/src/components/code-editor/json-ast.test.ts +230 -0
  63. package/src/components/code-editor/json-ast.ts +590 -0
  64. package/src/components/code-editor/sql-completion.ts +1105 -0
  65. package/src/components/date-picker-input.stories.tsx +79 -0
  66. package/src/components/date-picker-input.tsx +104 -0
  67. package/src/index.tsx +1 -0
  68. package/src/shadcn/components/ui/chart.tsx +6 -3
  69. package/src/shadcn/components/ui/resizable.stories.tsx +2 -2
  70. package/src/shadcn/components/ui/tabs.stories.tsx +1 -1
  71. package/src/shadcn/components/ui/tabs.tsx +160 -2
  72. package/src/typography.css +1 -1
  73. package/dist/src/components/code-editor/http/grammar/http.test.d.ts +0 -2
  74. package/dist/src/components/code-editor/http/grammar/http.test.d.ts.map +0 -1
@@ -0,0 +1,1105 @@
1
+ import {
2
+ autocompletion,
3
+ type Completion,
4
+ type CompletionContext,
5
+ type CompletionResult,
6
+ type CompletionSource,
7
+ startCompletion,
8
+ } from "@codemirror/autocomplete";
9
+ import { EditorState, type Extension } from "@codemirror/state";
10
+ import { EditorView } from "@codemirror/view";
11
+
12
+ // ── Public types ──
13
+
14
+ export type SqlQueryType =
15
+ | "tables"
16
+ | "columns"
17
+ | "functions"
18
+ | "jsonb_columns"
19
+ | "structure_definition";
20
+
21
+ interface StructureDefinitionElementType {
22
+ code: string;
23
+ }
24
+
25
+ interface StructureDefinitionElement {
26
+ path?: string;
27
+ type?: StructureDefinitionElementType[];
28
+ max?: string;
29
+ short?: string;
30
+ definition?: string;
31
+ }
32
+
33
+ interface StructureDefinition {
34
+ name?: string;
35
+ snapshot?: {
36
+ element: StructureDefinitionElement[];
37
+ };
38
+ }
39
+
40
+ export interface SqlConfig {
41
+ executeSql: (
42
+ query: string,
43
+ type: SqlQueryType,
44
+ ) => Promise<Record<string, unknown>[]>;
45
+ }
46
+
47
+ // ── Internal types ──
48
+
49
+ type SchemaMap = Record<string, string[]>;
50
+ type JsonbColumnMap = Record<string, string[]>;
51
+ type ColumnInfo = { name: string; dataType: string };
52
+ type ColumnMap = Record<string, ColumnInfo[]>;
53
+
54
+ type FhirFieldInfo = {
55
+ name: string;
56
+ datatype: string;
57
+ isArray: boolean;
58
+ description: string | undefined;
59
+ };
60
+
61
+ type FhirPathChildren = Record<string, FhirFieldInfo[]>;
62
+
63
+ type JsonbChain = {
64
+ tableOrAlias: string | null;
65
+ column: string;
66
+ path: string[];
67
+ isPathOp: boolean;
68
+ partialInput: string;
69
+ insideQuote: boolean;
70
+ lastRawSegment: string | null;
71
+ };
72
+
73
+ type AliasEntry = { schema: string; table: string };
74
+
75
+ export interface SqlMetadata {
76
+ schemas: SchemaMap;
77
+ jsonbColumns: JsonbColumnMap;
78
+ functions: string[];
79
+ columns: ColumnMap;
80
+ }
81
+
82
+ // ── SQL queries ──
83
+
84
+ const TABLES_QUERY = `SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pgagent') AND table_type = 'BASE TABLE' ORDER BY table_schema, table_name`;
85
+
86
+ const JSONB_COLUMNS_QUERY = `SELECT c.table_schema, c.table_name, c.column_name FROM information_schema.columns c JOIN information_schema.tables t ON c.table_schema = t.table_schema AND c.table_name = t.table_name WHERE t.table_type = 'BASE TABLE' AND c.table_schema NOT IN ('pg_catalog', 'information_schema', 'pgagent') AND c.udt_name = 'jsonb'`;
87
+
88
+ const FUNCTIONS_QUERY = `SELECT DISTINCT p.proname AS name FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid LEFT JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e' WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND d.objid IS NULL ORDER BY p.proname`;
89
+
90
+ const COLUMNS_QUERY = `SELECT c.table_schema, c.table_name, c.column_name, c.data_type FROM information_schema.columns c JOIN information_schema.tables t ON c.table_schema = t.table_schema AND c.table_name = t.table_name WHERE t.table_type = 'BASE TABLE' AND c.table_schema NOT IN ('pg_catalog', 'information_schema', 'pgagent') ORDER BY c.table_schema, c.table_name, c.ordinal_position`;
91
+
92
+ // ── FHIR StructureDefinition processing ──
93
+
94
+ function isExpandedVariant(path: string, unionBases: Set<string>): boolean {
95
+ const parts = path.split(".");
96
+ if (parts.length < 2) return false;
97
+ const parentPath = parts.slice(0, -1).join(".");
98
+ const name = parts[parts.length - 1]!;
99
+
100
+ for (const base of unionBases) {
101
+ const baseParts = base.split(".");
102
+ const baseName = baseParts[baseParts.length - 1]!;
103
+ const baseParent = baseParts.slice(0, -1).join(".");
104
+
105
+ if (
106
+ parentPath === baseParent &&
107
+ name.startsWith(baseName) &&
108
+ name.length > baseName.length &&
109
+ /^[A-Z]/.test(name.slice(baseName.length))
110
+ ) {
111
+ return true;
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+
117
+ function buildFromStructureDefinition(
118
+ sd: StructureDefinition,
119
+ ): FhirPathChildren {
120
+ const elements = sd.snapshot?.element ?? [];
121
+ const result: FhirPathChildren = {};
122
+
123
+ const unionBases = new Set<string>();
124
+ for (const el of elements) {
125
+ if (!el.path) continue;
126
+ const name = el.path.split(".").pop() ?? "";
127
+ if (name.endsWith("[x]")) {
128
+ unionBases.add(el.path.replace(/\[x\]$/, ""));
129
+ }
130
+ }
131
+
132
+ for (const el of elements) {
133
+ if (!el.path) continue;
134
+ const parts = el.path.split(".");
135
+ if (parts.length < 2) continue;
136
+
137
+ if (isExpandedVariant(el.path, unionBases)) continue;
138
+
139
+ const parentPath = parts.slice(0, -1).join(".");
140
+ const rawName = parts[parts.length - 1]!;
141
+
142
+ if (!result[parentPath]) result[parentPath] = [];
143
+
144
+ if (rawName.endsWith("[x]")) {
145
+ const name = rawName.slice(0, -3);
146
+ if (result[parentPath].some((f) => f.name === name)) continue;
147
+
148
+ result[parentPath].push({
149
+ name,
150
+ datatype: "union",
151
+ isArray: el.max === "*",
152
+ description: el.short ?? el.definition,
153
+ });
154
+
155
+ const unionPath = `${parentPath}.${name}`;
156
+ if (!result[unionPath]) result[unionPath] = [];
157
+ for (const t of el.type ?? []) {
158
+ if (!result[unionPath].some((f) => f.name === t.code)) {
159
+ result[unionPath].push({
160
+ name: t.code,
161
+ datatype: t.code,
162
+ isArray: false,
163
+ description: el.short ?? el.definition,
164
+ });
165
+ }
166
+ }
167
+ } else {
168
+ if (result[parentPath].some((f) => f.name === rawName)) continue;
169
+
170
+ result[parentPath].push({
171
+ name: rawName,
172
+ datatype: el.type?.[0]?.code ?? "",
173
+ isArray: el.max === "*",
174
+ description: el.short ?? el.definition,
175
+ });
176
+ }
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ function transformReferenceFields(result: FhirPathChildren): void {
183
+ for (const [path, children] of Object.entries(result)) {
184
+ const hasReferenceField = children.some((c) => c.name === "reference");
185
+ if (!hasReferenceField) continue;
186
+
187
+ result[path] = children.flatMap((child) => {
188
+ if (child.name === "reference") {
189
+ return [
190
+ {
191
+ name: "id",
192
+ datatype: "string",
193
+ isArray: false,
194
+ description: "Resource ID" as string | undefined,
195
+ },
196
+ {
197
+ name: "resourceType",
198
+ datatype: "string",
199
+ isArray: false,
200
+ description: "Resource type" as string | undefined,
201
+ },
202
+ ];
203
+ }
204
+ return [child];
205
+ });
206
+ }
207
+ }
208
+
209
+ // ── SQL parsing utilities ──
210
+
211
+ const SQL_TABLE_KEYWORDS =
212
+ /\b(?:from|join|inner\s+join|left\s+join|right\s+join|full\s+join|cross\s+join|into|update|table)\s+$/i;
213
+
214
+ function isInsideString(textBefore: string): boolean {
215
+ let count = 0;
216
+ for (let i = 0; i < textBefore.length; i++) {
217
+ if (textBefore[i] === "'") {
218
+ if (i + 1 < textBefore.length && textBefore[i + 1] === "'") {
219
+ i++;
220
+ } else {
221
+ count++;
222
+ }
223
+ }
224
+ }
225
+ return count % 2 !== 0;
226
+ }
227
+
228
+ function isInJsonbContext(textBefore: string): boolean {
229
+ return parseJsonbChain(textBefore) !== null;
230
+ }
231
+
232
+ function parseJsonbChain(textBefore: string): JsonbChain | null {
233
+ const pathOpMatch = textBefore.match(/((?:\w+\.)?\w+)\s*#>>?\s*'\{([^}]*)$/);
234
+ if (pathOpMatch) {
235
+ const ref = pathOpMatch[1]!;
236
+ const pathContent = pathOpMatch[2]!;
237
+ const segments = pathContent ? pathContent.split(",") : [];
238
+ const partialInput = segments.length > 0 ? (segments.pop() ?? "") : "";
239
+ const lastRawSegment =
240
+ segments.length > 0 ? (segments[segments.length - 1] ?? null) : null;
241
+ const path = segments.filter((s) => !/^\d+$/.test(s));
242
+
243
+ const dotParts = ref.split(".");
244
+ if (dotParts.length === 2) {
245
+ return {
246
+ tableOrAlias: dotParts[0]!,
247
+ column: dotParts[1]!,
248
+ path,
249
+ isPathOp: true,
250
+ partialInput,
251
+ insideQuote: false,
252
+ lastRawSegment,
253
+ };
254
+ }
255
+ return {
256
+ tableOrAlias: null,
257
+ column: dotParts[0]!,
258
+ path,
259
+ isPathOp: true,
260
+ partialInput,
261
+ insideQuote: false,
262
+ lastRawSegment,
263
+ };
264
+ }
265
+
266
+ const arrowPattern =
267
+ /(?:((?:\w+\.)?\w+)((?:\s*->>?\s*(?:'[^']*'|\d+))*)\s*->>?\s*)('?)([^']*)?$/;
268
+ const arrowMatch = textBefore.match(arrowPattern);
269
+ if (!arrowMatch) return null;
270
+
271
+ const ref = arrowMatch[1]!;
272
+ const chainPart = arrowMatch[2] || "";
273
+ const insideQuote = arrowMatch[3] === "'";
274
+ const partialInput = arrowMatch[4] ?? "";
275
+
276
+ const chainSegments: string[] = [];
277
+ let lastRawSeg: string | null = null;
278
+ const segmentRegex = /->>?\s*(?:'([^']*)'|(\d+))/g;
279
+ for (
280
+ let m = segmentRegex.exec(chainPart);
281
+ m !== null;
282
+ m = segmentRegex.exec(chainPart)
283
+ ) {
284
+ const seg = m[1] ?? m[2] ?? "";
285
+ lastRawSeg = seg;
286
+ if (!/^\d+$/.test(seg)) {
287
+ chainSegments.push(seg);
288
+ }
289
+ }
290
+
291
+ const dotParts = ref.split(".");
292
+ if (dotParts.length === 2) {
293
+ return {
294
+ tableOrAlias: dotParts[0]!,
295
+ column: dotParts[1]!,
296
+ path: chainSegments,
297
+ isPathOp: false,
298
+ partialInput,
299
+ insideQuote,
300
+ lastRawSegment: lastRawSeg,
301
+ };
302
+ }
303
+ return {
304
+ tableOrAlias: null,
305
+ column: dotParts[0]!,
306
+ path: chainSegments,
307
+ isPathOp: false,
308
+ partialInput,
309
+ insideQuote,
310
+ lastRawSegment: lastRawSeg,
311
+ };
312
+ }
313
+
314
+ function buildAliasMap(
315
+ sql: string,
316
+ schemas: SchemaMap,
317
+ ): Record<string, AliasEntry> {
318
+ const aliases: Record<string, AliasEntry> = {};
319
+ const regex = /\b(?:FROM|JOIN)\s+((?:\w+\.)?\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
320
+
321
+ for (let match = regex.exec(sql); match !== null; match = regex.exec(sql)) {
322
+ const fullTable = match[1]!;
323
+ const alias = match[2];
324
+
325
+ let schema: string;
326
+ let table: string;
327
+ if (fullTable.includes(".")) {
328
+ const parts = fullTable.split(".");
329
+ schema = parts[0]!;
330
+ table = parts[1]!;
331
+ } else {
332
+ table = fullTable;
333
+ let found: string | null = null;
334
+ for (const [s, tables] of Object.entries(schemas)) {
335
+ if (tables.includes(table)) {
336
+ found = s;
337
+ if (s === "public") break;
338
+ }
339
+ }
340
+ schema = found ?? "public";
341
+ }
342
+
343
+ if (alias) {
344
+ aliases[alias.toLowerCase()] = { schema, table };
345
+ }
346
+ aliases[table.toLowerCase()] = { schema, table };
347
+ }
348
+
349
+ return aliases;
350
+ }
351
+
352
+ function tableToResourceType(table: string): string {
353
+ return table
354
+ .split("_")
355
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
356
+ .join("");
357
+ }
358
+
359
+ function getCurrentStatement(doc: string, pos: number): string {
360
+ let start = 0;
361
+ let end = doc.length;
362
+
363
+ const before = doc.lastIndexOf(";", pos - 1);
364
+ if (before !== -1) start = before + 1;
365
+
366
+ const after = doc.indexOf(";", pos);
367
+ if (after !== -1) end = after;
368
+
369
+ return doc.slice(start, end);
370
+ }
371
+
372
+ // ── Completion result builders ──
373
+
374
+ function buildJsonbResult(
375
+ chain: JsonbChain,
376
+ pathChildren: FhirPathChildren,
377
+ resourceType: string,
378
+ context: CompletionContext,
379
+ ): CompletionResult | null {
380
+ const lookupPath =
381
+ chain.path.length > 0
382
+ ? `${resourceType}.${chain.path.join(".")}`
383
+ : resourceType;
384
+
385
+ const children = pathChildren[lookupPath];
386
+ if (!children || children.length === 0) return null;
387
+
388
+ const partial = chain.partialInput.toLowerCase();
389
+ const filtered = partial
390
+ ? children.filter((f) => f.name.toLowerCase().startsWith(partial))
391
+ : children;
392
+
393
+ if (filtered.length === 0) return null;
394
+
395
+ if (chain.isPathOp) {
396
+ return {
397
+ from: context.pos - chain.partialInput.length,
398
+ validFor: /^\w*$/,
399
+ options: filtered.map(
400
+ (f): Completion => ({
401
+ label: f.name,
402
+ type: "property",
403
+ detail: f.datatype + (f.isArray ? "[]" : ""),
404
+ ...(f.description != null ? { info: f.description } : {}),
405
+ }),
406
+ ),
407
+ };
408
+ }
409
+
410
+ if (chain.insideQuote) {
411
+ return {
412
+ from: context.pos - chain.partialInput.length,
413
+ validFor: /^\w*$/,
414
+ options: filtered.map(
415
+ (f): Completion => ({
416
+ label: f.name,
417
+ type: "property",
418
+ detail: f.datatype + (f.isArray ? "[]" : ""),
419
+ ...(f.description != null ? { info: f.description } : {}),
420
+ apply: (
421
+ view: EditorView,
422
+ _completion: Completion,
423
+ from: number,
424
+ to: number,
425
+ ) => {
426
+ const after = view.state.sliceDoc(to, to + 1);
427
+ const end = after === "'" ? to + 1 : to;
428
+ const insert = `${f.name}'`;
429
+ view.dispatch({
430
+ changes: { from, to: end, insert },
431
+ selection: { anchor: from + insert.length },
432
+ });
433
+ },
434
+ }),
435
+ ),
436
+ };
437
+ }
438
+
439
+ return {
440
+ from: context.pos - chain.partialInput.length,
441
+ validFor: /^'?\w*'?$/,
442
+ options: filtered.map(
443
+ (f): Completion => ({
444
+ label: `'${f.name}'`,
445
+ type: "property",
446
+ detail: f.datatype + (f.isArray ? "[]" : ""),
447
+ ...(f.description != null ? { info: f.description } : {}),
448
+ apply: `'${f.name}'`,
449
+ }),
450
+ ),
451
+ };
452
+ }
453
+
454
+ function isArrayPosition(
455
+ chain: JsonbChain,
456
+ pathChildren: FhirPathChildren,
457
+ resourceType: string,
458
+ ): boolean {
459
+ if (!chain.lastRawSegment || /^\d+$/.test(chain.lastRawSegment)) return false;
460
+ if (!chain.isPathOp && chain.insideQuote) return false;
461
+
462
+ const fieldName = chain.lastRawSegment;
463
+ const parentPath =
464
+ chain.path.length > 1
465
+ ? `${resourceType}.${chain.path.slice(0, -1).join(".")}`
466
+ : resourceType;
467
+ const parentChildren = pathChildren[parentPath];
468
+ if (!parentChildren) return false;
469
+
470
+ const element = parentChildren.find((f) => f.name === fieldName);
471
+ return !!element?.isArray;
472
+ }
473
+
474
+ function buildArrayIndexResult(
475
+ chain: JsonbChain,
476
+ context: CompletionContext,
477
+ ): CompletionResult {
478
+ return {
479
+ from: context.pos - chain.partialInput.length,
480
+ options: [
481
+ {
482
+ label: "0",
483
+ type: "enum",
484
+ detail: "array index",
485
+ },
486
+ ],
487
+ };
488
+ }
489
+
490
+ async function resolveNestedTypes(
491
+ pathChildren: FhirPathChildren,
492
+ resourceType: string,
493
+ path: string[],
494
+ fetchSchema: (type: string) => Promise<FhirPathChildren | null>,
495
+ ): Promise<void> {
496
+ for (let i = 0; i < path.length; i++) {
497
+ const currentPath = `${resourceType}.${path.slice(0, i + 1).join(".")}`;
498
+
499
+ if (pathChildren[currentPath]) continue;
500
+
501
+ const parentPath =
502
+ i === 0 ? resourceType : `${resourceType}.${path.slice(0, i).join(".")}`;
503
+ const parentChildren = pathChildren[parentPath];
504
+ if (!parentChildren) return;
505
+
506
+ const segmentName = path[i]!;
507
+ const element = parentChildren.find((f) => f.name === segmentName);
508
+ if (!element?.datatype) return;
509
+
510
+ if (element.datatype === "union") continue;
511
+
512
+ const firstChar = element.datatype[0]!;
513
+ if (firstChar !== firstChar.toUpperCase()) return;
514
+
515
+ const typeChildren = await fetchSchema(element.datatype);
516
+ if (!typeChildren) return;
517
+
518
+ const typeName = element.datatype;
519
+ for (const [key, children] of Object.entries(typeChildren)) {
520
+ const suffix = key === typeName ? "" : key.slice(typeName.length);
521
+ pathChildren[currentPath + suffix] = children;
522
+ }
523
+ }
524
+ }
525
+
526
+ // ── Completion extensions ──
527
+
528
+ function tableCompletionExtension(schemas: SchemaMap): Extension {
529
+ const source = (context: CompletionContext): CompletionResult | null => {
530
+ const line = context.state.doc.lineAt(context.pos);
531
+ const textBefore = line.text.slice(0, context.pos - line.from);
532
+
533
+ if (isInsideString(textBefore)) return null;
534
+
535
+ const schemaDot = textBefore.match(/(\w+)\.(\w*)$/);
536
+ if (schemaDot) {
537
+ const schemaName = schemaDot[1]!;
538
+ const tables = schemas[schemaName];
539
+ if (!tables) return null;
540
+ return {
541
+ from: context.pos - (schemaDot[2] ?? "").length,
542
+ options: tables.map((t) => ({ label: t, type: "table" })),
543
+ };
544
+ }
545
+
546
+ const word = context.matchBefore(/\w*/);
547
+ if (!word) return null;
548
+
549
+ const beforeWord = textBefore.slice(0, word.from - line.from);
550
+ if (!SQL_TABLE_KEYWORDS.test(beforeWord) && !context.explicit) return null;
551
+
552
+ const options: { label: string; type: string; detail?: string }[] = [];
553
+
554
+ for (const [schema, tables] of Object.entries(schemas)) {
555
+ options.push({ label: `${schema}.`, type: "keyword", detail: "schema" });
556
+ for (const table of tables) {
557
+ if (schema === "public") {
558
+ options.push({ label: table, type: "table" });
559
+ } else {
560
+ options.push({
561
+ label: `${schema}.${table}`,
562
+ type: "table",
563
+ detail: schema,
564
+ });
565
+ }
566
+ }
567
+ }
568
+
569
+ return { from: word.from, options };
570
+ };
571
+
572
+ return EditorState.languageData.of(() => [{ autocomplete: source }]);
573
+ }
574
+
575
+ function columnCompletionExtension(ctx: {
576
+ schemas: SchemaMap;
577
+ columns: ColumnMap;
578
+ }): Extension {
579
+ const source = (context: CompletionContext): CompletionResult | null => {
580
+ const line = context.state.doc.lineAt(context.pos);
581
+ const textBefore = line.text.slice(0, context.pos - line.from);
582
+
583
+ if (isInsideString(textBefore)) return null;
584
+
585
+ const fullDoc = context.state.doc.toString();
586
+ const statement = getCurrentStatement(fullDoc, context.pos);
587
+ const aliases = buildAliasMap(statement, ctx.schemas);
588
+
589
+ if (Object.keys(aliases).length === 0) return null;
590
+
591
+ const aliasDot = textBefore.match(/(\w+)\.(\w*)$/);
592
+ if (aliasDot) {
593
+ const beforeAlias = textBefore.slice(
594
+ 0,
595
+ textBefore.length - aliasDot[0].length,
596
+ );
597
+ if (SQL_TABLE_KEYWORDS.test(beforeAlias)) return null;
598
+
599
+ const aliasName = aliasDot[1]!;
600
+ const entry = aliases[aliasName.toLowerCase()];
601
+ if (!entry) return null;
602
+ const key = `${entry.schema}.${entry.table}`;
603
+ const cols = ctx.columns[key];
604
+ if (!cols || cols.length === 0) return null;
605
+
606
+ return {
607
+ from: context.pos - (aliasDot[2] ?? "").length,
608
+ options: cols.map((c) => ({
609
+ label: c.name,
610
+ type: "variable",
611
+ detail: c.dataType,
612
+ })),
613
+ };
614
+ }
615
+
616
+ const word = context.matchBefore(/\w*/);
617
+ if (!word) return null;
618
+ if (word.from === word.to && !context.explicit) return null;
619
+
620
+ const textBeforeWord = textBefore.slice(0, word.from - line.from);
621
+ if (SQL_TABLE_KEYWORDS.test(textBeforeWord)) return null;
622
+
623
+ const seen = new Set<string>();
624
+ const options: { label: string; type: string; detail: string }[] = [];
625
+ for (const entry of Object.values(aliases)) {
626
+ const key = `${entry.schema}.${entry.table}`;
627
+ const cols = ctx.columns[key];
628
+ if (!cols) continue;
629
+ for (const c of cols) {
630
+ const dedup = `${c.name}::${entry.table}`;
631
+ if (seen.has(dedup)) continue;
632
+ seen.add(dedup);
633
+ options.push({
634
+ label: c.name,
635
+ type: "variable",
636
+ detail: `${c.dataType} · ${entry.table}`,
637
+ });
638
+ }
639
+ }
640
+
641
+ if (options.length === 0) return null;
642
+ return { from: word.from, options };
643
+ };
644
+
645
+ return EditorState.languageData.of(() => [{ autocomplete: source }]);
646
+ }
647
+
648
+ function triggerCompletionAfter(view: EditorView) {
649
+ requestAnimationFrame(() => startCompletion(view));
650
+ }
651
+
652
+ function jsonbOperatorExtension(): Extension {
653
+ const source = (context: CompletionContext): CompletionResult | null => {
654
+ const line = context.state.doc.lineAt(context.pos);
655
+ const textBefore = line.text.slice(0, context.pos - line.from);
656
+
657
+ if (/\w\s*#$/.test(textBefore)) {
658
+ return {
659
+ from: context.pos - 1,
660
+ options: [
661
+ {
662
+ label: "#>> '{}'",
663
+ type: "operator",
664
+ apply: (view, _completion, from, to) => {
665
+ const insert = "#>> '{";
666
+ view.dispatch({
667
+ changes: { from, to, insert: `${insert}}' ` },
668
+ selection: { anchor: from + insert.length },
669
+ });
670
+ triggerCompletionAfter(view);
671
+ },
672
+ },
673
+ {
674
+ label: "#> '{}'",
675
+ type: "operator",
676
+ apply: (view, _completion, from, to) => {
677
+ const insert = "#> '{";
678
+ view.dispatch({
679
+ changes: { from, to, insert: `${insert}}' ` },
680
+ selection: { anchor: from + insert.length },
681
+ });
682
+ triggerCompletionAfter(view);
683
+ },
684
+ },
685
+ ],
686
+ };
687
+ }
688
+
689
+ if (/\w\s*->$/.test(textBefore) && !/\w\s*->>$/.test(textBefore)) {
690
+ return {
691
+ from: context.pos - 2,
692
+ options: [
693
+ {
694
+ label: "->> ''",
695
+ type: "operator",
696
+ apply: (view, _completion, from, to) => {
697
+ const insert = "->> '";
698
+ view.dispatch({
699
+ changes: { from, to, insert: `${insert}' ` },
700
+ selection: { anchor: from + insert.length },
701
+ });
702
+ triggerCompletionAfter(view);
703
+ },
704
+ },
705
+ {
706
+ label: "-> ''",
707
+ type: "operator",
708
+ apply: (view, _completion, from, to) => {
709
+ const insert = "-> '";
710
+ view.dispatch({
711
+ changes: { from, to, insert: `${insert}' ` },
712
+ selection: { anchor: from + insert.length },
713
+ });
714
+ triggerCompletionAfter(view);
715
+ },
716
+ },
717
+ {
718
+ label: "->> 0",
719
+ type: "operator",
720
+ apply: (view, _completion, from, to) => {
721
+ const insert = "->> ";
722
+ view.dispatch({
723
+ changes: { from, to, insert: `${insert}0 ` },
724
+ selection: {
725
+ anchor: from + insert.length,
726
+ head: from + insert.length + 1,
727
+ },
728
+ });
729
+ },
730
+ },
731
+ {
732
+ label: "-> 0",
733
+ type: "operator",
734
+ apply: (view, _completion, from, to) => {
735
+ const insert = "-> ";
736
+ view.dispatch({
737
+ changes: { from, to, insert: `${insert}0 ` },
738
+ selection: {
739
+ anchor: from + insert.length,
740
+ head: from + insert.length + 1,
741
+ },
742
+ });
743
+ },
744
+ },
745
+ ],
746
+ };
747
+ }
748
+
749
+ return null;
750
+ };
751
+
752
+ return EditorState.languageData.of(() => [{ autocomplete: source }]);
753
+ }
754
+
755
+ function jsonbCompletionExtension(ctx: {
756
+ schemas: SchemaMap;
757
+ jsonbColumns: JsonbColumnMap;
758
+ sdCache: Record<string, FhirPathChildren>;
759
+ sdNotFound: Set<string>;
760
+ fetchSchema: (type: string) => Promise<FhirPathChildren | null>;
761
+ }): Extension {
762
+ const resolveChain = (
763
+ context: CompletionContext,
764
+ ): { chain: JsonbChain; resourceType: string } | null => {
765
+ const line = context.state.doc.lineAt(context.pos);
766
+ const textBefore = line.text.slice(0, context.pos - line.from);
767
+
768
+ const chain = parseJsonbChain(textBefore);
769
+ if (!chain) return null;
770
+
771
+ const fullDoc = context.state.doc.toString();
772
+ const statement = getCurrentStatement(fullDoc, context.pos);
773
+ const aliases = buildAliasMap(statement, ctx.schemas);
774
+
775
+ let resolved: AliasEntry | null = null;
776
+
777
+ if (chain.tableOrAlias) {
778
+ resolved = aliases[chain.tableOrAlias.toLowerCase()] ?? null;
779
+ } else {
780
+ for (const entry of Object.values(aliases)) {
781
+ const key = `${entry.schema}.${entry.table}`;
782
+ const cols = ctx.jsonbColumns[key];
783
+ if (cols?.includes(chain.column)) {
784
+ resolved = entry;
785
+ break;
786
+ }
787
+ }
788
+ }
789
+
790
+ if (!resolved) return null;
791
+
792
+ const jsonbKey = `${resolved.schema}.${resolved.table}`;
793
+ const jsonbCols = ctx.jsonbColumns[jsonbKey];
794
+ if (!jsonbCols?.includes(chain.column)) return null;
795
+
796
+ if (chain.column !== "resource") return null;
797
+
798
+ const resourceType = tableToResourceType(resolved.table);
799
+ if (ctx.sdNotFound.has(resourceType)) return null;
800
+
801
+ return { chain, resourceType };
802
+ };
803
+
804
+ const complete = async (
805
+ chain: JsonbChain,
806
+ resourceType: string,
807
+ pathChildren: FhirPathChildren,
808
+ context: CompletionContext,
809
+ ): Promise<CompletionResult | null> => {
810
+ if (chain.path.length > 0) {
811
+ await resolveNestedTypes(
812
+ pathChildren,
813
+ resourceType,
814
+ chain.path,
815
+ ctx.fetchSchema,
816
+ );
817
+ }
818
+
819
+ if (isArrayPosition(chain, pathChildren, resourceType)) {
820
+ return buildArrayIndexResult(chain, context);
821
+ }
822
+
823
+ return buildJsonbResult(chain, pathChildren, resourceType, context);
824
+ };
825
+
826
+ const source = (
827
+ context: CompletionContext,
828
+ ): CompletionResult | null | Promise<CompletionResult | null> => {
829
+ const info = resolveChain(context);
830
+ if (!info) return null;
831
+
832
+ const { chain, resourceType } = info;
833
+
834
+ const cached = ctx.sdCache[resourceType];
835
+ if (cached) {
836
+ if (chain.path.length === 0) {
837
+ return buildJsonbResult(chain, cached, resourceType, context);
838
+ }
839
+ return complete(chain, resourceType, cached, context);
840
+ }
841
+
842
+ return ctx.fetchSchema(resourceType).then((fetched) => {
843
+ if (!fetched) return null;
844
+ return complete(chain, resourceType, fetched, context);
845
+ });
846
+ };
847
+
848
+ return EditorState.languageData.of(() => [{ autocomplete: source }]);
849
+ }
850
+
851
+ function sqlCompletionOverride(): Extension {
852
+ return autocompletion({
853
+ override: [
854
+ async (context: CompletionContext): Promise<CompletionResult | null> => {
855
+ const line = context.state.doc.lineAt(context.pos);
856
+ const textBefore = line.text.slice(0, context.pos - line.from);
857
+ const inJsonb = isInJsonbContext(textBefore);
858
+
859
+ const langSources = context.state.languageDataAt<CompletionSource>(
860
+ "autocomplete",
861
+ context.pos,
862
+ );
863
+
864
+ const results = (
865
+ await Promise.all(
866
+ langSources.map((src) => Promise.resolve(src(context))),
867
+ )
868
+ ).filter((r): r is CompletionResult => r !== null);
869
+
870
+ if (results.length === 0) return null;
871
+
872
+ if (inJsonb) {
873
+ const jsonbTypes = new Set(["property", "enum", "operator"]);
874
+ const jsonbResult = results.find((r) =>
875
+ r.options.some((o) => jsonbTypes.has(o.type ?? "")),
876
+ );
877
+ if (!jsonbResult) return null;
878
+ return {
879
+ ...jsonbResult,
880
+ options: jsonbResult.options.filter((o) =>
881
+ jsonbTypes.has(o.type ?? ""),
882
+ ),
883
+ };
884
+ }
885
+
886
+ const hasTableResults = results.some((r) =>
887
+ r.options.some((o) => o.type === "table"),
888
+ );
889
+
890
+ if (hasTableResults) {
891
+ const tableOptions = results.flatMap((r) =>
892
+ r.options.filter((o) => o.type === "table" || o.type === "keyword"),
893
+ );
894
+ const from = results.find((r) =>
895
+ r.options.some((o) => o.type === "table"),
896
+ )?.from;
897
+ if (from == null) return null;
898
+ return { from, options: tableOptions };
899
+ }
900
+
901
+ if (results.length === 1) return results[0]!;
902
+
903
+ const groups = new Map<
904
+ number,
905
+ { from: number; options: Completion[] }
906
+ >();
907
+ for (const r of results) {
908
+ const existing = groups.get(r.from);
909
+ if (existing) {
910
+ existing.options.push(...r.options);
911
+ } else {
912
+ groups.set(r.from, {
913
+ from: r.from,
914
+ options: [...r.options],
915
+ });
916
+ }
917
+ }
918
+
919
+ let best: { from: number; options: Completion[] } | null = null;
920
+ for (const g of groups.values()) {
921
+ if (!best || g.options.length > best.options.length) best = g;
922
+ }
923
+ if (best) {
924
+ best.options = best.options.map((o) => {
925
+ if (o.type === "keyword") return { ...o, boost: 2 };
926
+ if (o.type === "type") return { ...o, boost: 1 };
927
+ if (o.type === "variable") return { ...o, boost: -1 };
928
+ return o;
929
+ });
930
+ }
931
+ return best;
932
+ },
933
+ ],
934
+ });
935
+ }
936
+
937
+ // ── Public API ──
938
+
939
+ export async function fetchSqlMetadata(
940
+ executeSql: SqlConfig["executeSql"],
941
+ ): Promise<SqlMetadata> {
942
+ const [tablesRows, jsonbRows, functionsRows, columnsRows] = await Promise.all(
943
+ [
944
+ executeSql(TABLES_QUERY, "tables"),
945
+ executeSql(JSONB_COLUMNS_QUERY, "jsonb_columns"),
946
+ executeSql(FUNCTIONS_QUERY, "functions"),
947
+ executeSql(COLUMNS_QUERY, "columns"),
948
+ ],
949
+ );
950
+
951
+ const schemas: SchemaMap = {};
952
+ for (const row of tablesRows) {
953
+ const s = String(row.table_schema);
954
+ if (!schemas[s]) schemas[s] = [];
955
+ schemas[s].push(String(row.table_name));
956
+ }
957
+
958
+ const jsonbColumns: JsonbColumnMap = {};
959
+ for (const row of jsonbRows) {
960
+ const key = `${row.table_schema}.${row.table_name}`;
961
+ if (!jsonbColumns[key]) jsonbColumns[key] = [];
962
+ jsonbColumns[key].push(String(row.column_name));
963
+ }
964
+
965
+ const functions = functionsRows.map((r) => String(r.name));
966
+
967
+ const columns: ColumnMap = {};
968
+ for (const row of columnsRows) {
969
+ const key = `${row.table_schema}.${row.table_name}`;
970
+ if (!columns[key]) columns[key] = [];
971
+ columns[key].push({
972
+ name: String(row.column_name),
973
+ dataType: String(row.data_type),
974
+ });
975
+ }
976
+
977
+ return { schemas, jsonbColumns, functions, columns };
978
+ }
979
+
980
+ export function buildSqlCompletionExtensions(
981
+ metadata: SqlMetadata,
982
+ executeSql: SqlConfig["executeSql"],
983
+ ): Extension[] {
984
+ const sdCache: Record<string, FhirPathChildren> = {};
985
+ const sdNotFound = new Set<string>();
986
+
987
+ const fetchSchema = async (
988
+ resourceType: string,
989
+ ): Promise<FhirPathChildren | null> => {
990
+ if (sdCache[resourceType]) return sdCache[resourceType];
991
+ if (sdNotFound.has(resourceType)) return null;
992
+
993
+ try {
994
+ const name = resourceType.replace(/'/g, "''");
995
+ const rows = await executeSql(
996
+ `SELECT resource FROM far.canonicalresource WHERE rt = 'StructureDefinition' AND resource->>'name' = '${name}' LIMIT 1`,
997
+ "structure_definition",
998
+ );
999
+ const sd = (rows[0]?.resource ?? null) as StructureDefinition | null;
1000
+ if (!sd) {
1001
+ sdNotFound.add(resourceType);
1002
+ return null;
1003
+ }
1004
+
1005
+ const pathChildren = buildFromStructureDefinition(sd);
1006
+ transformReferenceFields(pathChildren);
1007
+ sdCache[resourceType] = pathChildren;
1008
+ return pathChildren;
1009
+ } catch {
1010
+ sdNotFound.add(resourceType);
1011
+ return null;
1012
+ }
1013
+ };
1014
+
1015
+ return [
1016
+ EditorView.theme({
1017
+ ".cm-tooltip.cm-tooltip-autocomplete": {
1018
+ background: "var(--color-bg-primary)",
1019
+ border: "1px solid var(--color-border-primary)",
1020
+ borderRadius: "var(--radius-md)",
1021
+ padding: "4px",
1022
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
1023
+ fontFamily: "var(--font-family-sans)",
1024
+ fontSize: "14px",
1025
+ },
1026
+ ".cm-tooltip.cm-tooltip-autocomplete > ul": {
1027
+ maxHeight: "300px",
1028
+ },
1029
+ ".cm-tooltip-autocomplete ul li": {
1030
+ padding: "4px 8px",
1031
+ borderRadius: "4px",
1032
+ },
1033
+ ".cm-tooltip-autocomplete ul li[aria-selected]": {
1034
+ background: "var(--color-bg-quaternary)",
1035
+ color: "var(--color-text-primary)",
1036
+ },
1037
+ ".cm-completionLabel": {
1038
+ color: "var(--color-text-primary)",
1039
+ fontSize: "14px",
1040
+ },
1041
+ ".cm-completionDetail": {
1042
+ color: "var(--color-text-tertiary)",
1043
+ fontSize: "12px",
1044
+ fontStyle: "normal",
1045
+ marginLeft: "8px",
1046
+ },
1047
+ ".cm-completionIcon": {
1048
+ padding: "0",
1049
+ marginRight: "6px",
1050
+ width: "18px",
1051
+ height: "18px",
1052
+ display: "inline-flex",
1053
+ alignItems: "center",
1054
+ justifyContent: "center",
1055
+ borderRadius: "4px",
1056
+ fontSize: "11px",
1057
+ fontWeight: "600",
1058
+ lineHeight: "1",
1059
+ boxSizing: "border-box",
1060
+ },
1061
+ ".cm-completionIcon-table": {
1062
+ background: "var(--color-blue-100)",
1063
+ color: "var(--color-blue-600)",
1064
+ },
1065
+ ".cm-completionIcon-table::after": {
1066
+ content: "'T'",
1067
+ },
1068
+ ".cm-completionIcon-keyword": {
1069
+ background: "var(--color-green-200)",
1070
+ color: "var(--color-green-700)",
1071
+ },
1072
+ ".cm-completionIcon-keyword::after": {
1073
+ content: "'S'",
1074
+ },
1075
+ ".cm-completionIcon-property": {
1076
+ background: "var(--color-purple-100)",
1077
+ color: "var(--color-purple-600)",
1078
+ },
1079
+ ".cm-completionIcon-property::after": {
1080
+ content: "'F'",
1081
+ },
1082
+ ".cm-completionIcon-variable": {
1083
+ background: "var(--color-yellow-200)",
1084
+ color: "var(--color-yellow-700)",
1085
+ },
1086
+ ".cm-completionIcon-variable::after": {
1087
+ content: "'C'",
1088
+ },
1089
+ }),
1090
+ tableCompletionExtension(metadata.schemas),
1091
+ columnCompletionExtension({
1092
+ schemas: metadata.schemas,
1093
+ columns: metadata.columns,
1094
+ }),
1095
+ jsonbCompletionExtension({
1096
+ schemas: metadata.schemas,
1097
+ jsonbColumns: metadata.jsonbColumns,
1098
+ sdCache,
1099
+ sdNotFound,
1100
+ fetchSchema,
1101
+ }),
1102
+ jsonbOperatorExtension(),
1103
+ sqlCompletionOverride(),
1104
+ ];
1105
+ }