@atomic-ehr/fhirpath 0.0.1-canary.69eb286.20250724163205
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/README.md +307 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +8256 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/analyzer/analyzer.ts +486 -0
- package/src/analyzer/model-provider.ts +244 -0
- package/src/analyzer/schemas/index.ts +2 -0
- package/src/analyzer/schemas/types.ts +40 -0
- package/src/analyzer/types.ts +142 -0
- package/src/api/builder.ts +148 -0
- package/src/api/errors.ts +134 -0
- package/src/api/expression.ts +149 -0
- package/src/api/index.ts +57 -0
- package/src/api/registry.ts +128 -0
- package/src/api/types.ts +154 -0
- package/src/compiler/compiler.ts +589 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/types.ts +23 -0
- package/src/index.ts +52 -0
- package/src/interpreter/README.md +78 -0
- package/src/interpreter/context.ts +181 -0
- package/src/interpreter/interpreter.ts +484 -0
- package/src/interpreter/types.ts +132 -0
- package/src/lexer/char-tables.ts +37 -0
- package/src/lexer/errors.ts +31 -0
- package/src/lexer/index.ts +5 -0
- package/src/lexer/lexer.ts +745 -0
- package/src/lexer/token.ts +104 -0
- package/src/parser/ast.ts +123 -0
- package/src/parser/index.ts +3 -0
- package/src/parser/parser.ts +701 -0
- package/src/parser/pprint.ts +169 -0
- package/src/registry/default-analyzers.ts +257 -0
- package/src/registry/default-compilers.ts +31 -0
- package/src/registry/index.ts +93 -0
- package/src/registry/operations/arithmetic.ts +506 -0
- package/src/registry/operations/collection.ts +422 -0
- package/src/registry/operations/comparison.ts +432 -0
- package/src/registry/operations/existence.ts +719 -0
- package/src/registry/operations/filtering.ts +374 -0
- package/src/registry/operations/literals.ts +341 -0
- package/src/registry/operations/logical.ts +402 -0
- package/src/registry/operations/math.ts +128 -0
- package/src/registry/operations/membership.ts +132 -0
- package/src/registry/operations/string.ts +507 -0
- package/src/registry/operations/subsetting.ts +174 -0
- package/src/registry/operations/type-checking.ts +162 -0
- package/src/registry/operations/type-conversion.ts +404 -0
- package/src/registry/operations/type-operators.ts +307 -0
- package/src/registry/operations/utility.ts +553 -0
- package/src/registry/registry.ts +146 -0
- package/src/registry/types.ts +162 -0
- package/src/registry/utils/evaluation-helpers.ts +93 -0
- package/src/registry/utils/index.ts +3 -0
- package/src/registry/utils/type-system.ts +173 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ASTNode,
|
|
3
|
+
LiteralNode,
|
|
4
|
+
IdentifierNode,
|
|
5
|
+
VariableNode,
|
|
6
|
+
BinaryNode,
|
|
7
|
+
UnaryNode,
|
|
8
|
+
FunctionNode,
|
|
9
|
+
CollectionNode,
|
|
10
|
+
IndexNode,
|
|
11
|
+
UnionNode,
|
|
12
|
+
MembershipTestNode,
|
|
13
|
+
TypeCastNode,
|
|
14
|
+
TypeReferenceNode,
|
|
15
|
+
TypeOrIdentifierNode
|
|
16
|
+
} from '../parser/ast';
|
|
17
|
+
import { NodeType } from '../parser/ast';
|
|
18
|
+
import { TokenType } from '../lexer/token';
|
|
19
|
+
import type { Context, EvaluationResult } from './types';
|
|
20
|
+
import { EvaluationError, CollectionUtils } from './types';
|
|
21
|
+
import { ContextManager } from './context';
|
|
22
|
+
import { TypeSystem } from '../registry/utils/type-system';
|
|
23
|
+
import { Registry } from '../registry';
|
|
24
|
+
import type { Interpreter as IInterpreter } from '../registry/types';
|
|
25
|
+
|
|
26
|
+
// Import registry to trigger operation registration
|
|
27
|
+
import '../registry';
|
|
28
|
+
|
|
29
|
+
// Type for node evaluator functions
|
|
30
|
+
type NodeEvaluator = (node: any, input: any[], context: Context) => EvaluationResult;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* FHIRPath Interpreter - evaluates AST nodes following the stream-processing model.
|
|
34
|
+
* Every node is a processing unit: (input, context) → (output, new context)
|
|
35
|
+
*
|
|
36
|
+
* This refactored version uses object lookup instead of switch statements.
|
|
37
|
+
*/
|
|
38
|
+
export class Interpreter implements IInterpreter {
|
|
39
|
+
// Object lookup for node evaluators
|
|
40
|
+
private readonly nodeEvaluators: Record<NodeType, NodeEvaluator> = {
|
|
41
|
+
[NodeType.Literal]: this.evaluateLiteral.bind(this),
|
|
42
|
+
[NodeType.Identifier]: this.evaluateIdentifier.bind(this),
|
|
43
|
+
[NodeType.TypeOrIdentifier]: this.evaluateTypeOrIdentifier.bind(this),
|
|
44
|
+
[NodeType.Variable]: this.evaluateVariable.bind(this),
|
|
45
|
+
[NodeType.Binary]: this.evaluateBinary.bind(this),
|
|
46
|
+
[NodeType.Unary]: this.evaluateUnary.bind(this),
|
|
47
|
+
[NodeType.Function]: this.evaluateFunction.bind(this),
|
|
48
|
+
[NodeType.Collection]: this.evaluateCollection.bind(this),
|
|
49
|
+
[NodeType.Index]: this.evaluateIndex.bind(this),
|
|
50
|
+
[NodeType.Union]: this.evaluateUnion.bind(this),
|
|
51
|
+
[NodeType.MembershipTest]: this.evaluateMembershipTest.bind(this),
|
|
52
|
+
[NodeType.TypeCast]: this.evaluateTypeCast.bind(this),
|
|
53
|
+
[NodeType.TypeReference]: this.evaluateTypeReference.bind(this),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Main evaluation method - uses object lookup instead of switch
|
|
58
|
+
*/
|
|
59
|
+
evaluate(node: ASTNode, input: any[], context: Context): EvaluationResult {
|
|
60
|
+
try {
|
|
61
|
+
// Ensure $this is set in the context if not already present
|
|
62
|
+
if (!context.env.$this) {
|
|
63
|
+
context = {
|
|
64
|
+
...context,
|
|
65
|
+
env: {
|
|
66
|
+
...context.env,
|
|
67
|
+
$this: input
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const evaluator = this.nodeEvaluators[node.type];
|
|
73
|
+
|
|
74
|
+
if (!evaluator) {
|
|
75
|
+
throw new EvaluationError(
|
|
76
|
+
`Unknown node type: ${node.type}`,
|
|
77
|
+
node.position
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return evaluator(node, input, context);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Add position information if not already present
|
|
84
|
+
if (error instanceof EvaluationError && !error.position && node.position) {
|
|
85
|
+
error.position = node.position;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private evaluateLiteral(node: LiteralNode, input: any[], context: Context): EvaluationResult {
|
|
92
|
+
// If literal has operation reference from parser
|
|
93
|
+
if (node.operation && node.operation.kind === 'literal') {
|
|
94
|
+
return node.operation.evaluate(this, context, input);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fallback for legacy literals
|
|
98
|
+
const value = node.value === null ? [] : [node.value];
|
|
99
|
+
return { value, context };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private evaluateIdentifier(node: IdentifierNode, input: any[], context: Context): EvaluationResult {
|
|
103
|
+
// Check if this identifier could be a resource type name
|
|
104
|
+
// Resource types in FHIR typically start with uppercase
|
|
105
|
+
if (node.name[0] === node?.name?.[0]?.toUpperCase()) {
|
|
106
|
+
// Check if any input items have this as their resourceType
|
|
107
|
+
const hasMatchingResourceType = input.some(item =>
|
|
108
|
+
item && typeof item === 'object' && item.resourceType === node.name
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (hasMatchingResourceType) {
|
|
112
|
+
// This is a type filter - return only items matching this resourceType
|
|
113
|
+
const filtered = input.filter(item =>
|
|
114
|
+
item && typeof item === 'object' && item.resourceType === node.name
|
|
115
|
+
);
|
|
116
|
+
return { value: filtered, context };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Regular property navigation
|
|
121
|
+
const results: any[] = [];
|
|
122
|
+
|
|
123
|
+
for (const item of input) {
|
|
124
|
+
if (item == null || typeof item !== 'object') {
|
|
125
|
+
// Primitives don't have properties - skip
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const value = item[node.name];
|
|
130
|
+
if (value !== undefined) {
|
|
131
|
+
// Add to results - flatten if array
|
|
132
|
+
if (Array.isArray(value)) {
|
|
133
|
+
results.push(...value);
|
|
134
|
+
} else {
|
|
135
|
+
results.push(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Missing properties return empty (not added to results)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { value: results, context };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private evaluateTypeOrIdentifier(node: TypeOrIdentifierNode, input: any[], context: Context): EvaluationResult {
|
|
145
|
+
// TypeOrIdentifier can act as either a type reference or property navigation
|
|
146
|
+
|
|
147
|
+
// First, check if this is a known type name (e.g., Patient, Observation)
|
|
148
|
+
// In FHIR context, type names match resourceType values
|
|
149
|
+
const possibleTypeName = node.name;
|
|
150
|
+
|
|
151
|
+
// Check if any input items have this as their resourceType
|
|
152
|
+
const hasMatchingResourceType = input.some(item =>
|
|
153
|
+
item && typeof item === 'object' && item.resourceType === possibleTypeName
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (hasMatchingResourceType) {
|
|
157
|
+
// This is a type filter - return only items matching this resourceType
|
|
158
|
+
const filtered = input.filter(item =>
|
|
159
|
+
item && typeof item === 'object' && item.resourceType === possibleTypeName
|
|
160
|
+
);
|
|
161
|
+
return { value: filtered, context };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Not a type filter, treat as property navigation
|
|
165
|
+
return this.evaluateIdentifier(node as any, input, context);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private evaluateVariable(node: VariableNode, input: any[], context: Context): EvaluationResult {
|
|
169
|
+
// Variables ignore input and return value from context
|
|
170
|
+
let value: any[] = [];
|
|
171
|
+
|
|
172
|
+
if (node.name.startsWith('$')) {
|
|
173
|
+
// Special environment variables - use object lookup
|
|
174
|
+
const envVarHandlers: Record<string, () => any[]> = {
|
|
175
|
+
'$this': () => context.env.$this || [],
|
|
176
|
+
'$index': () => context.env.$index !== undefined ? [context.env.$index] : [],
|
|
177
|
+
'$total': () => context.env.$total || [],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handler = envVarHandlers[node.name];
|
|
181
|
+
if (!handler) {
|
|
182
|
+
throw new EvaluationError(`Unknown special variable: ${node.name}`, node.position);
|
|
183
|
+
}
|
|
184
|
+
value = handler();
|
|
185
|
+
} else {
|
|
186
|
+
// User-defined variables (remove % prefix if present)
|
|
187
|
+
const varName = node.name.startsWith('%') ? node.name.substring(1) : node.name;
|
|
188
|
+
value = ContextManager.getVariable(context, varName) || [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { value, context };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private evaluateBinary(node: BinaryNode, input: any[], context: Context): EvaluationResult {
|
|
195
|
+
// Special handling for dot operator - it's a pipeline
|
|
196
|
+
if (node.operator === TokenType.DOT) {
|
|
197
|
+
// Phase 1: Evaluate left with original input/context
|
|
198
|
+
const leftResult = this.evaluate(node.left, input, context);
|
|
199
|
+
|
|
200
|
+
// Phase 2: Evaluate right with left's output as input
|
|
201
|
+
const rightResult = this.evaluate(node.right, leftResult.value, leftResult.context);
|
|
202
|
+
|
|
203
|
+
return rightResult;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle case where parser incorrectly creates BinaryNode for unary minus
|
|
207
|
+
if (!node.left && !node.right && (node as any).operand) {
|
|
208
|
+
// This is actually a unary operation
|
|
209
|
+
const unaryOp = Registry.getByToken(node.operator, 'prefix');
|
|
210
|
+
if (unaryOp && unaryOp.kind === 'operator') {
|
|
211
|
+
const operandResult = this.evaluate((node as any).operand, input, context);
|
|
212
|
+
return unaryOp.evaluate(this, operandResult.context, input, operandResult.value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Get operation from registry (binary operators are infix)
|
|
217
|
+
const operation = node.operation || Registry.getByToken(node.operator, 'infix');
|
|
218
|
+
if (!operation || operation.kind !== 'operator') {
|
|
219
|
+
throw new EvaluationError(`Unknown operator: ${node.operator}`, node.position);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!node.left || !node.right) {
|
|
223
|
+
throw new EvaluationError(`Binary operator ${node.operator} missing operands`, node.position);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Special handling for union operator - both sides should use the same context
|
|
227
|
+
if (node.operator === TokenType.PIPE) {
|
|
228
|
+
const leftResult = this.evaluate(node.left, input, context);
|
|
229
|
+
const rightResult = this.evaluate(node.right, input, context); // Use original context, not leftResult.context
|
|
230
|
+
|
|
231
|
+
// Use operation's evaluate method
|
|
232
|
+
return operation.evaluate(this, context, input, leftResult.value, rightResult.value);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Normal operators - context flows from left to right
|
|
236
|
+
const leftResult = this.evaluate(node.left, input, context);
|
|
237
|
+
const rightResult = this.evaluate(node.right, input, leftResult.context);
|
|
238
|
+
|
|
239
|
+
// Use operation's evaluate method
|
|
240
|
+
return operation.evaluate(this, rightResult.context, input, leftResult.value, rightResult.value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private evaluateUnary(node: UnaryNode, input: any[], context: Context): EvaluationResult {
|
|
244
|
+
// Get operation from registry (unary operators are prefix)
|
|
245
|
+
// Don't use node.operation as parser might have assigned wrong operation
|
|
246
|
+
const operation = Registry.getByToken(node.operator, 'prefix');
|
|
247
|
+
if (!operation || operation.kind !== 'operator') {
|
|
248
|
+
throw new EvaluationError(`Unknown unary operator: ${node.operator}`, node.position);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Evaluate operand
|
|
252
|
+
const operandResult = this.evaluate(node.operand, input, context);
|
|
253
|
+
|
|
254
|
+
// Use operation's evaluate method
|
|
255
|
+
return operation.evaluate(this, operandResult.context, input, operandResult.value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private evaluateFunction(node: FunctionNode, input: any[], context: Context): EvaluationResult {
|
|
259
|
+
// Extract function name and handle method call syntax
|
|
260
|
+
let funcName: string;
|
|
261
|
+
let functionInput = input;
|
|
262
|
+
|
|
263
|
+
if (node.name.type === NodeType.Identifier) {
|
|
264
|
+
funcName = (node.name as IdentifierNode).name;
|
|
265
|
+
} else if (node.name.type === NodeType.Binary && (node.name as BinaryNode).operator === TokenType.DOT) {
|
|
266
|
+
// Method call syntax: expression.function(args)
|
|
267
|
+
const binaryNode = node.name as BinaryNode;
|
|
268
|
+
|
|
269
|
+
// Evaluate the left side to get the input
|
|
270
|
+
const leftResult = this.evaluate(binaryNode.left, input, context);
|
|
271
|
+
functionInput = leftResult.value;
|
|
272
|
+
context = leftResult.context;
|
|
273
|
+
|
|
274
|
+
// Get the function name from the right side
|
|
275
|
+
if (binaryNode.right.type === NodeType.Identifier) {
|
|
276
|
+
funcName = (binaryNode.right as IdentifierNode).name;
|
|
277
|
+
} else {
|
|
278
|
+
throw new EvaluationError('Invalid method call syntax', node.position);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
throw new EvaluationError('Complex function names not yet supported', node.position);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check for custom functions first
|
|
285
|
+
if (context.customFunctions && funcName in context.customFunctions) {
|
|
286
|
+
const customFunc = context.customFunctions[funcName];
|
|
287
|
+
|
|
288
|
+
// Evaluate all arguments
|
|
289
|
+
const evaluatedArgs: any[] = [];
|
|
290
|
+
for (const arg of node.arguments) {
|
|
291
|
+
const argResult = this.evaluate(arg, functionInput, context);
|
|
292
|
+
evaluatedArgs.push(argResult.value);
|
|
293
|
+
context = argResult.context;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Call custom function
|
|
297
|
+
const result = customFunc!(context, functionInput, ...evaluatedArgs);
|
|
298
|
+
return { value: result, context };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Get function from registry
|
|
302
|
+
const operation = Registry.get(funcName);
|
|
303
|
+
if (!operation || operation.kind !== 'function') {
|
|
304
|
+
throw new EvaluationError(`Unknown function: ${funcName}`, node.position);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check propagateEmptyInput flag
|
|
308
|
+
if (operation.signature.propagatesEmpty && functionInput.length === 0) {
|
|
309
|
+
return { value: [], context };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Evaluate arguments based on parameter definitions
|
|
313
|
+
const evaluatedArgs: any[] = [];
|
|
314
|
+
for (let i = 0; i < node.arguments.length; i++) {
|
|
315
|
+
const arg = node.arguments[i];
|
|
316
|
+
const param = operation.signature.parameters[i];
|
|
317
|
+
|
|
318
|
+
if (param && param.kind === 'expression') {
|
|
319
|
+
// Pass expression as-is, will be evaluated by the function
|
|
320
|
+
evaluatedArgs.push(arg);
|
|
321
|
+
} else {
|
|
322
|
+
// Evaluate the argument to get its value
|
|
323
|
+
const argResult = this.evaluate(arg!, functionInput, context);
|
|
324
|
+
evaluatedArgs.push(argResult.value);
|
|
325
|
+
context = argResult.context;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Use operation's evaluate method
|
|
330
|
+
return operation.evaluate(this, context, functionInput, ...evaluatedArgs);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private evaluateCollection(node: CollectionNode, input: any[], context: Context): EvaluationResult {
|
|
334
|
+
// Evaluate each element and combine results
|
|
335
|
+
const results: any[] = [];
|
|
336
|
+
let currentContext = context;
|
|
337
|
+
|
|
338
|
+
for (const element of node.elements) {
|
|
339
|
+
const result = this.evaluate(element, input, currentContext);
|
|
340
|
+
results.push(...result.value);
|
|
341
|
+
currentContext = result.context;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { value: results, context: currentContext };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private evaluateIndex(node: IndexNode, input: any[], context: Context): EvaluationResult {
|
|
348
|
+
// Evaluate the expression being indexed
|
|
349
|
+
const exprResult = this.evaluate(node.expression, input, context);
|
|
350
|
+
|
|
351
|
+
// Evaluate the index expression in the original context
|
|
352
|
+
const indexResult = this.evaluate(node.index, input, context);
|
|
353
|
+
|
|
354
|
+
// Index must be a single integer
|
|
355
|
+
if (indexResult.value.length === 0) {
|
|
356
|
+
return { value: [], context: indexResult.context };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const index = CollectionUtils.toSingleton(indexResult.value);
|
|
360
|
+
if (typeof index !== 'number' || !Number.isInteger(index)) {
|
|
361
|
+
throw new EvaluationError('Index must be an integer', node.position);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// FHIRPath uses 0-based indexing
|
|
365
|
+
if (index < 0 || index >= exprResult.value.length) {
|
|
366
|
+
// Out of bounds returns empty
|
|
367
|
+
return { value: [], context: indexResult.context };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { value: [exprResult.value[index]], context: indexResult.context };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private evaluateUnion(node: UnionNode, input: any[], context: Context): EvaluationResult {
|
|
374
|
+
// Union combines results from all operands
|
|
375
|
+
// Each operand should be evaluated with the SAME original context
|
|
376
|
+
// to prevent variable definitions from leaking between branches
|
|
377
|
+
const results: any[] = [];
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
|
|
380
|
+
for (const operand of node.operands) {
|
|
381
|
+
// Always use the original context for each operand
|
|
382
|
+
const result = this.evaluate(operand, input, context);
|
|
383
|
+
|
|
384
|
+
// Remove duplicates
|
|
385
|
+
for (const item of result.value) {
|
|
386
|
+
const key = JSON.stringify(item);
|
|
387
|
+
if (!seen.has(key)) {
|
|
388
|
+
seen.add(key);
|
|
389
|
+
results.push(item);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Return the original context, not a modified one
|
|
395
|
+
return { value: results, context };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private evaluateMembershipTest(node: MembershipTestNode, input: any[], context: Context): EvaluationResult {
|
|
399
|
+
// Evaluate the expression to get values to test
|
|
400
|
+
const exprResult = this.evaluate(node.expression, input, context);
|
|
401
|
+
|
|
402
|
+
// Empty collection: is returns empty
|
|
403
|
+
if (exprResult.value.length === 0) {
|
|
404
|
+
return { value: [], context: exprResult.context };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Check if ALL values match the type
|
|
408
|
+
for (const value of exprResult.value) {
|
|
409
|
+
if (!TypeSystem.isType(value, node.targetType)) {
|
|
410
|
+
return { value: [false], context: exprResult.context };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// All values match the type
|
|
415
|
+
return { value: [true], context: exprResult.context };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private evaluateTypeCast(node: TypeCastNode, input: any[], context: Context): EvaluationResult {
|
|
419
|
+
// Evaluate the expression to get values to cast
|
|
420
|
+
const exprResult = this.evaluate(node.expression, input, context);
|
|
421
|
+
|
|
422
|
+
// For each value, attempt to cast to the target type
|
|
423
|
+
const results: any[] = [];
|
|
424
|
+
for (const value of exprResult.value) {
|
|
425
|
+
// If already the correct type, keep it
|
|
426
|
+
if (TypeSystem.isType(value, node.targetType)) {
|
|
427
|
+
results.push(value);
|
|
428
|
+
}
|
|
429
|
+
// Otherwise, try to cast (returns null if fails)
|
|
430
|
+
else {
|
|
431
|
+
const castValue = TypeSystem.cast(value, node.targetType);
|
|
432
|
+
if (castValue !== null) {
|
|
433
|
+
results.push(castValue);
|
|
434
|
+
}
|
|
435
|
+
// Failed casts are filtered out (not added to results)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Return filtered collection
|
|
440
|
+
return { value: results, context: exprResult.context };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private evaluateTypeReference(node: TypeReferenceNode, input: any[], context: Context): EvaluationResult {
|
|
444
|
+
// Type references don't evaluate to values directly
|
|
445
|
+
throw new EvaluationError(`Type reference cannot be evaluated: ${node.typeName}`, node.position);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Helper function to evaluate a FHIRPath expression
|
|
451
|
+
*/
|
|
452
|
+
export function evaluateFHIRPath(
|
|
453
|
+
expression: string | ASTNode,
|
|
454
|
+
input: any,
|
|
455
|
+
context?: Context
|
|
456
|
+
): any[] {
|
|
457
|
+
// Parse if string
|
|
458
|
+
const ast = typeof expression === 'string'
|
|
459
|
+
? require('../parser').parse(expression)
|
|
460
|
+
: expression;
|
|
461
|
+
|
|
462
|
+
// Convert input to collection
|
|
463
|
+
const inputCollection = CollectionUtils.toCollection(input);
|
|
464
|
+
|
|
465
|
+
// Create context if not provided and set initial $this
|
|
466
|
+
let evalContext = context || ContextManager.create(inputCollection);
|
|
467
|
+
|
|
468
|
+
// Set initial $this to the input collection if not already set
|
|
469
|
+
if (!evalContext.env.$this) {
|
|
470
|
+
evalContext = {
|
|
471
|
+
...evalContext,
|
|
472
|
+
env: {
|
|
473
|
+
...evalContext.env,
|
|
474
|
+
$this: inputCollection
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Create interpreter and evaluate
|
|
480
|
+
const interpreter = new Interpreter();
|
|
481
|
+
const result = interpreter.evaluate(ast, inputCollection, evalContext);
|
|
482
|
+
|
|
483
|
+
return result.value;
|
|
484
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Core types for FHIRPath interpreter following the stream-processing mental model
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The result of evaluating any FHIRPath expression.
|
|
5
|
+
* Every expression returns a collection and potentially modified context.
|
|
6
|
+
*/
|
|
7
|
+
export interface EvaluationResult {
|
|
8
|
+
value: any[]; // Always a collection (even single values are collections of one)
|
|
9
|
+
context: Context;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context carries variables and environment data parallel to the data stream.
|
|
14
|
+
* It flows through expressions and can be modified by certain operations.
|
|
15
|
+
*
|
|
16
|
+
* Uses JavaScript prototype chain for efficient inheritance.
|
|
17
|
+
*/
|
|
18
|
+
export interface Context {
|
|
19
|
+
// User-defined variables (%varName)
|
|
20
|
+
// Using Record instead of Map for prototype chain compatibility
|
|
21
|
+
variables: Record<string, any[]>;
|
|
22
|
+
|
|
23
|
+
// Special environment variables
|
|
24
|
+
env: {
|
|
25
|
+
$this?: any[]; // Current item in iterator functions
|
|
26
|
+
$index?: number; // Current index in iterator functions
|
|
27
|
+
$total?: any[]; // Accumulator in aggregate function
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Root context variables
|
|
31
|
+
$context?: any[]; // Original input to the expression
|
|
32
|
+
$resource?: any[]; // Current resource being processed
|
|
33
|
+
$rootResource?: any[]; // Top-level resource
|
|
34
|
+
|
|
35
|
+
// Custom functions (if any)
|
|
36
|
+
customFunctions?: Record<string, (context: Context, input: any[], ...args: any[]) => any[]>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Error thrown during evaluation with position information
|
|
41
|
+
*/
|
|
42
|
+
export class EvaluationError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
message: string,
|
|
45
|
+
public position?: { line: number; column: number; offset: number }
|
|
46
|
+
) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'EvaluationError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Type information for runtime type checking
|
|
54
|
+
*/
|
|
55
|
+
export interface TypeInfo {
|
|
56
|
+
namespace: string; // 'System' or 'FHIR'
|
|
57
|
+
name: string; // Type name like 'String', 'Patient'
|
|
58
|
+
isCollection?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Helper type for ensuring we always work with collections
|
|
63
|
+
*/
|
|
64
|
+
export type Collection<T = any> = T[];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Singleton conversion result
|
|
68
|
+
*/
|
|
69
|
+
export type SingletonResult<T = any> = T | undefined;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Helper functions for working with collections
|
|
73
|
+
*/
|
|
74
|
+
export const CollectionUtils = {
|
|
75
|
+
/**
|
|
76
|
+
* Convert any value to a collection
|
|
77
|
+
*/
|
|
78
|
+
toCollection(value: any): any[] {
|
|
79
|
+
if (value === null || value === undefined) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
return Array.isArray(value) ? value : [value];
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply singleton evaluation rules
|
|
87
|
+
* @returns The single value or undefined if rules don't apply
|
|
88
|
+
* @throws Error if multiple items when single expected
|
|
89
|
+
*/
|
|
90
|
+
toSingleton(collection: any[], expectedType?: string): SingletonResult {
|
|
91
|
+
if (collection.length === 0) {
|
|
92
|
+
return undefined; // Empty propagates
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (collection.length === 1) {
|
|
96
|
+
const value = collection[0];
|
|
97
|
+
|
|
98
|
+
// Rule 2: Collection with one item, expecting Boolean → true
|
|
99
|
+
if (expectedType === 'boolean' && typeof value !== 'boolean') {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Rule 1: Collection with one item convertible to expected type → use it
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Rule 4: Multiple items → ERROR
|
|
108
|
+
throw new EvaluationError(`Expected single value but got ${collection.length} items`);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a collection is empty
|
|
113
|
+
*/
|
|
114
|
+
isEmpty(collection: any[]): boolean {
|
|
115
|
+
return collection.length === 0;
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Flatten nested collections
|
|
120
|
+
*/
|
|
121
|
+
flatten(collection: any[]): any[] {
|
|
122
|
+
const result: any[] = [];
|
|
123
|
+
for (const item of collection) {
|
|
124
|
+
if (Array.isArray(item)) {
|
|
125
|
+
result.push(...item);
|
|
126
|
+
} else {
|
|
127
|
+
result.push(item);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Character classification lookup table for O(1) checks
|
|
2
|
+
export const CHAR_FLAGS = new Uint8Array(128);
|
|
3
|
+
|
|
4
|
+
// Bit flags for character properties
|
|
5
|
+
export const FLAG_DIGIT = 1 << 0;
|
|
6
|
+
export const FLAG_ALPHA = 1 << 1;
|
|
7
|
+
export const FLAG_WHITESPACE = 1 << 2;
|
|
8
|
+
export const FLAG_IDENTIFIER_START = 1 << 3;
|
|
9
|
+
export const FLAG_IDENTIFIER_CONT = 1 << 4;
|
|
10
|
+
|
|
11
|
+
// Initialize lookup table (called once at startup)
|
|
12
|
+
export function initCharTables(): void {
|
|
13
|
+
// Digits
|
|
14
|
+
for (let i = 48; i <= 57; i++) {
|
|
15
|
+
CHAR_FLAGS[i]! |= FLAG_DIGIT | FLAG_IDENTIFIER_CONT;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Letters
|
|
19
|
+
for (let i = 65; i <= 90; i++) {
|
|
20
|
+
CHAR_FLAGS[i]! |= FLAG_ALPHA | FLAG_IDENTIFIER_START | FLAG_IDENTIFIER_CONT;
|
|
21
|
+
}
|
|
22
|
+
for (let i = 97; i <= 122; i++) {
|
|
23
|
+
CHAR_FLAGS[i]! |= FLAG_ALPHA | FLAG_IDENTIFIER_START | FLAG_IDENTIFIER_CONT;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Underscore
|
|
27
|
+
CHAR_FLAGS[95]! |= FLAG_IDENTIFIER_START | FLAG_IDENTIFIER_CONT;
|
|
28
|
+
|
|
29
|
+
// Whitespace
|
|
30
|
+
CHAR_FLAGS[32]! |= FLAG_WHITESPACE; // space
|
|
31
|
+
CHAR_FLAGS[9]! |= FLAG_WHITESPACE; // tab
|
|
32
|
+
CHAR_FLAGS[10]! |= FLAG_WHITESPACE; // newline
|
|
33
|
+
CHAR_FLAGS[13]! |= FLAG_WHITESPACE; // carriage return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Initialize the table immediately
|
|
37
|
+
initCharTables();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Position } from './token';
|
|
2
|
+
|
|
3
|
+
export class LexerError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
message: string,
|
|
6
|
+
public position: Position,
|
|
7
|
+
public char?: string
|
|
8
|
+
) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'LexerError';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override toString(): string {
|
|
14
|
+
const location = `${this.position.line}:${this.position.column}`;
|
|
15
|
+
const charInfo = this.char ? ` (found '${this.char}')` : '';
|
|
16
|
+
return `${this.name}: ${this.message} at ${location}${charInfo}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatError(error: LexerError, input: string): string {
|
|
21
|
+
const lines = input.split('\n');
|
|
22
|
+
const line = lines[error.position.line - 1] || '';
|
|
23
|
+
const pointer = ' '.repeat(error.position.column - 1) + '^';
|
|
24
|
+
|
|
25
|
+
return [
|
|
26
|
+
error.toString(),
|
|
27
|
+
'',
|
|
28
|
+
line,
|
|
29
|
+
pointer
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { FHIRPathLexer, lex } from './lexer';
|
|
2
|
+
export type { Token, Position } from './token';
|
|
3
|
+
export { TokenType, Channel } from './token';
|
|
4
|
+
export { LexerError, formatError } from './errors';
|
|
5
|
+
export { CHAR_FLAGS, FLAG_DIGIT, FLAG_ALPHA, FLAG_WHITESPACE, FLAG_IDENTIFIER_START, FLAG_IDENTIFIER_CONT } from './char-tables';
|