UNPKG

@atomic-ehr/fhirpath

Version:

A TypeScript implementation of FHIRPath

1,336 lines (1,154 loc) 52.2 kB
import type { ASTNode, BinaryNode, IdentifierNode, LiteralNode, FunctionNode, Diagnostic, AnalysisResult, UnaryNode, IndexNode, CollectionNode, MembershipTestNode, TypeCastNode, TypeInfo, ModelProvider, VariableNode, TypeName, TypeOrIdentifierNode, ErrorNode } from './types'; import { NodeType, DiagnosticSeverity } from './types'; import { registry } from './registry'; import { Errors, toDiagnostic } from './errors'; import { isCursorNode, CursorContext } from './cursor-nodes'; import type { AnyCursorNode } from './cursor-nodes'; export interface AnalyzerOptions { cursorMode?: boolean; } export interface AnalysisResultWithCursor extends AnalysisResult { stoppedAtCursor?: boolean; cursorContext?: { typeBeforeCursor?: TypeInfo; expectedType?: TypeInfo; cursorNode?: AnyCursorNode; }; } export class Analyzer { private diagnostics: Diagnostic[] = []; private variables: Set<string> = new Set(['$this', '$index', '$total', 'context', 'resource', 'rootResource']); private modelProvider?: ModelProvider; private userVariableTypes: Map<string, TypeInfo> = new Map(); private systemVariableTypes: Map<string, TypeInfo> = new Map(); private cursorMode: boolean = false; private stoppedAtCursor: boolean = false; private cursorContext?: { typeBeforeCursor?: TypeInfo; expectedType?: TypeInfo; cursorNode?: AnyCursorNode; }; constructor(modelProvider?: ModelProvider) { this.modelProvider = modelProvider; } async analyze( ast: ASTNode, userVariables?: Record<string, any>, inputType?: TypeInfo, options?: AnalyzerOptions ): Promise<AnalysisResultWithCursor> { this.diagnostics = []; this.userVariableTypes.clear(); this.cursorMode = options?.cursorMode ?? false; this.stoppedAtCursor = false; this.cursorContext = undefined; if (userVariables) { Object.keys(userVariables).forEach(name => { this.variables.add(name); // Try to infer types from values const value = userVariables[name]; if (value !== undefined && value !== null) { this.userVariableTypes.set(name, this.inferValueType(value)); } }); } // Annotate AST with type information await this.annotateAST(ast, inputType); // Perform validation with type checking (if not stopped at cursor) if (!this.stoppedAtCursor) { this.visitNode(ast); } return { diagnostics: this.diagnostics, ast, stoppedAtCursor: this.cursorMode ? this.stoppedAtCursor : undefined, cursorContext: this.cursorMode ? this.cursorContext : undefined }; } private visitNode(node: ASTNode): void { // Check for cursor node in cursor mode if (this.cursorMode && isCursorNode(node)) { this.stoppedAtCursor = true; this.cursorContext = { cursorNode: node as AnyCursorNode, typeBeforeCursor: (node as any).typeInfo }; return; // Short-circuit } // Handle error nodes - process them for diagnostics but don't traverse if (node.type === 'Error') { // Diagnostics already added in annotateAST return; } // If we've already stopped at cursor, don't continue if (this.stoppedAtCursor) { return; } switch (node.type) { case NodeType.Binary: this.visitBinaryOperator(node as BinaryNode); break; case NodeType.Identifier: this.visitIdentifier(node as IdentifierNode); break; case NodeType.Function: this.visitFunctionCall(node as FunctionNode); break; case NodeType.Index: const indexNode = node as IndexNode; this.visitNode(indexNode.expression); this.visitNode(indexNode.index); break; case NodeType.Collection: (node as CollectionNode).elements.forEach(el => this.visitNode(el)); break; case NodeType.Unary: this.visitNode((node as UnaryNode).operand); break; case NodeType.MembershipTest: this.visitMembershipTest(node as MembershipTestNode); break; case NodeType.TypeCast: this.visitTypeCast(node as TypeCastNode); break; case NodeType.Variable: this.validateVariable((node as VariableNode).name, node); break; case NodeType.Literal: case NodeType.TypeOrIdentifier: case NodeType.TypeReference: // These are always valid break; } } private visitBinaryOperator(node: BinaryNode): void { this.visitNode(node.left); // Track defineVariable for validation - collect all variables defined in the chain if (node.operator === '.') { const definedVars = this.collectDefinedVariables(node.left); if (definedVars.size > 0) { // Track which variables were already known const previouslyKnown = new Set<string>(); definedVars.forEach(varName => { if (this.variables.has(varName)) { previouslyKnown.add(varName); } this.variables.add(varName); }); // Visit right side with new variables in scope this.visitNode(node.right); // Restore previous state definedVars.forEach(varName => { if (!previouslyKnown.has(varName)) { this.variables.delete(varName); } }); return; } } // Special handling for dot operator with function on right side if (node.operator === '.' && node.right.type === NodeType.Function) { const funcNode = node.right as FunctionNode; if (funcNode.name.type === NodeType.Identifier) { const funcName = (funcNode.name as IdentifierNode).name; const func = registry.getFunction(funcName); if (func && func.signatures && func.signatures.length > 0 && node.left.typeInfo) { // Check if any signature matches the input type let matchFound = false; let expectedTypes: string[] = []; for (const signature of func.signatures) { if (signature.input) { if (this.isTypeCompatible(node.left.typeInfo, signature.input)) { matchFound = true; break; } expectedTypes.push(this.typeToString(signature.input)); } else { // If any signature has no input constraint, it matches matchFound = true; break; } } if (!matchFound) { const inputTypeStr = this.typeToString(node.left.typeInfo); const firstSignature = func.signatures[0]; if (!firstSignature) return; // Check if this is specifically a singleton/collection mismatch const inputIsCollection = !node.left.typeInfo.singleton; const expectedIsSingleton = firstSignature.input?.singleton; // Check if the base types are compatible (same type or subtype) const typesCompatible = firstSignature.input && ( node.left.typeInfo.type === firstSignature.input.type || this.isSubtypeOf(node.left.typeInfo.type, firstSignature.input.type) ); if (inputIsCollection && expectedIsSingleton && typesCompatible) { // Compatible base types but collection vs singleton mismatch this.diagnostics.push( toDiagnostic(Errors.singletonTypeRequired(funcName, inputTypeStr, funcNode.range)) ); } else { // Function received invalid operand type - report as runtime error this.diagnostics.push( toDiagnostic(Errors.invalidOperandType(funcName + '()', inputTypeStr, funcNode.range)) ); } } } } } this.visitNode(node.right); // For dot operator, we don't need to check operator types if (node.operator === '.') { return; } const op = registry.getOperatorDefinition(node.operator); if (!op) { this.diagnostics.push( toDiagnostic(Errors.unknownOperator(node.operator, node.range)) ); return; } // Type check if we have type information if (node.left.typeInfo && node.right.typeInfo) { this.checkBinaryOperatorTypes(node, op); } } private visitIdentifier(node: IdentifierNode): void { this.validateVariable(node.name, node); } private visitFunctionCall(node: FunctionNode): void { if (node.name.type === NodeType.Identifier) { const funcName = (node.name as IdentifierNode).name; // Check if this is a type operation that requires ModelProvider if (funcName === 'ofType' && !this.modelProvider) { // Check if the type argument is a primitive type const primitiveTypes = ['String', 'Integer', 'Decimal', 'Boolean', 'Date', 'DateTime', 'Time', 'Quantity']; let isPrimitive = false; if (node.arguments.length > 0) { const typeArg = node.arguments[0]!; if (typeArg.type === NodeType.Identifier) { isPrimitive = primitiveTypes.includes((typeArg as IdentifierNode).name); } else if ((typeArg as any).type === NodeType.TypeOrIdentifier || (typeArg as any).type === NodeType.TypeReference) { isPrimitive = primitiveTypes.includes((typeArg as any).name); } } if (!isPrimitive) { this.diagnostics.push( toDiagnostic(Errors.modelProviderRequired('ofType', node.range)) ); } } // Check ofType with union types if (funcName === 'ofType' && node.typeInfo) { const inputType = node.typeInfo; if (node.arguments.length > 0 && inputType.modelContext && typeof inputType.modelContext === 'object' && 'isUnion' in inputType.modelContext && inputType.modelContext.isUnion && 'choices' in inputType.modelContext && Array.isArray(inputType.modelContext.choices)) { // Extract target type from argument let targetType: string | undefined; const typeArg = node.arguments[0]!; if (typeArg.type === NodeType.Identifier) { targetType = (typeArg as IdentifierNode).name; } else if ((typeArg as any).type === NodeType.TypeOrIdentifier || (typeArg as any).type === NodeType.TypeReference) { targetType = (typeArg as any).name; } if (targetType) { const validChoice = inputType.modelContext.choices.find((choice: any) => choice.type === targetType || choice.code === targetType ); if (!validChoice) { this.diagnostics.push({ severity: DiagnosticSeverity.Warning, code: 'invalid-type-filter', message: `Type '${targetType}' is not present in the union type. Available types: ${ inputType.modelContext.choices.map((c: any) => c.type || c.code).join(', ') }`, range: node.range }); } } } } const func = registry.getFunction(funcName); if (!func) { this.diagnostics.push( toDiagnostic(Errors.unknownFunction(funcName, node.range)) ); } else { // Check argument count based on signature const params = func.signatures?.[0]?.parameters || []; const requiredParams = params.filter(p => !p.optional).length; const maxParams = params.length; if (node.arguments.length < requiredParams) { this.diagnostics.push( toDiagnostic(Errors.wrongArgumentCount(funcName, requiredParams, node.arguments.length, node.range)) ); } else if (node.arguments.length > maxParams) { this.diagnostics.push( toDiagnostic(Errors.wrongArgumentCount(funcName, maxParams, node.arguments.length, node.range)) ); } // Type check arguments if we have type information if (node.typeInfo || node.arguments.some(arg => arg.typeInfo)) { this.checkFunctionArgumentTypes(node, func); } } } node.arguments.forEach(arg => this.visitNode(arg)); } private visitMembershipTest(node: MembershipTestNode): void { // Check if ModelProvider is required // Basic primitive types can be checked without ModelProvider const primitiveTypes = ['String', 'Integer', 'Decimal', 'Boolean', 'Date', 'DateTime', 'Time', 'Quantity']; if (!this.modelProvider && !primitiveTypes.includes(node.targetType)) { this.diagnostics.push( toDiagnostic(Errors.modelProviderRequired('is', node.range)) ); } // Check 'is' with union types if (node.expression.typeInfo) { const leftType = node.expression.typeInfo; if (leftType.modelContext && typeof leftType.modelContext === 'object' && 'isUnion' in leftType.modelContext && leftType.modelContext.isUnion && 'choices' in leftType.modelContext && Array.isArray(leftType.modelContext.choices)) { const targetTypeName = node.targetType; const validChoice = leftType.modelContext.choices.find((choice: any) => choice.type === targetTypeName || choice.code === targetTypeName ); if (!validChoice) { this.diagnostics.push({ severity: DiagnosticSeverity.Warning, code: 'invalid-type-test', message: `Type test 'is ${targetTypeName}' will always be false. Type '${targetTypeName}' is not in the union. Available types: ${ leftType.modelContext.choices.map((c: any) => c.type || c.code).join(', ') }`, range: node.range }); } } } this.visitNode(node.expression); } private visitTypeCast(node: TypeCastNode): void { // Check if ModelProvider is required // Basic primitive types can be checked without ModelProvider const primitiveTypes = ['String', 'Integer', 'Decimal', 'Boolean', 'Date', 'DateTime', 'Time', 'Quantity']; if (!this.modelProvider && !primitiveTypes.includes(node.targetType)) { this.diagnostics.push( toDiagnostic(Errors.modelProviderRequired('as', node.range)) ); } // Check 'as' with union types if (node.expression.typeInfo) { const leftType = node.expression.typeInfo; if (leftType.modelContext && typeof leftType.modelContext === 'object' && 'isUnion' in leftType.modelContext && leftType.modelContext.isUnion && 'choices' in leftType.modelContext && Array.isArray(leftType.modelContext.choices)) { const targetTypeName = node.targetType; const validChoice = leftType.modelContext.choices.find((choice: any) => choice.type === targetTypeName || choice.code === targetTypeName ); if (!validChoice) { this.diagnostics.push({ severity: DiagnosticSeverity.Warning, code: 'invalid-type-cast', message: `Type cast 'as ${targetTypeName}' may fail. Type '${targetTypeName}' is not guaranteed in the union. Available types: ${ leftType.modelContext.choices.map((c: any) => c.type || c.code).join(', ') }`, range: node.range }); } } } this.visitNode(node.expression); } // Unified variable validation to eliminate duplication private validateVariable(name: string, node: ASTNode): void { if (name.startsWith('$')) { if (!this.variables.has(name)) { this.diagnostics.push( toDiagnostic(Errors.unknownVariable(name, node.range)) ); } } else if (name.startsWith('%')) { const varName = name.substring(1); if (!this.variables.has(varName)) { this.diagnostics.push( toDiagnostic(Errors.unknownUserVariable(name, node.range)) ); } } } private collectDefinedVariables(node: ASTNode): Set<string> { const vars = new Set<string>(); // If this is a defineVariable call, extract the variable name if (node.type === NodeType.Function) { const funcNode = node as FunctionNode; if (funcNode.name.type === NodeType.Identifier && (funcNode.name as IdentifierNode).name === 'defineVariable' && funcNode.arguments.length >= 1) { const nameArg = funcNode.arguments[0]; if (nameArg && nameArg.type === NodeType.Literal && nameArg.valueType === 'string') { vars.add(nameArg.value as string); } } } // If this is a binary dot operator, collect from left side recursively if (node.type === NodeType.Binary) { const binaryNode = node as BinaryNode; if (binaryNode.operator === '.') { // Collect from left side const leftVars = this.collectDefinedVariables(binaryNode.left); leftVars.forEach(v => vars.add(v)); // Check if right side is also defineVariable if (binaryNode.right.type === NodeType.Function) { const rightFunc = binaryNode.right as FunctionNode; if (rightFunc.name.type === NodeType.Identifier && (rightFunc.name as IdentifierNode).name === 'defineVariable' && rightFunc.arguments.length >= 1) { const nameArg = rightFunc.arguments[0]; if (nameArg && nameArg.type === NodeType.Literal && nameArg.valueType === 'string') { vars.add(nameArg.value as string); } } } } } return vars; } private collectDefinedVariablesWithTypes(node: ASTNode): Map<string, TypeInfo> { const varsWithTypes = new Map<string, TypeInfo>(); // If this is a defineVariable call, extract the variable name and type if (node.type === NodeType.Function) { const funcNode = node as FunctionNode; if (funcNode.name.type === NodeType.Identifier && (funcNode.name as IdentifierNode).name === 'defineVariable' && funcNode.arguments.length >= 1) { const nameArg = funcNode.arguments[0]; if (nameArg && nameArg.type === NodeType.Literal && nameArg.valueType === 'string') { const varName = nameArg.value as string; let varType: TypeInfo; if (funcNode.arguments.length >= 2 && funcNode.arguments[1]!.typeInfo) { // Has value expression - use its type varType = funcNode.arguments[1]!.typeInfo; } else if (node.typeInfo) { // No value expression - uses input as value (defineVariable returns input) varType = node.typeInfo; } else { varType = { type: 'Any', singleton: false }; } varsWithTypes.set(varName, varType); } } } // If this is a binary dot operator, collect from entire chain if (node.type === NodeType.Binary) { const binaryNode = node as BinaryNode; if (binaryNode.operator === '.') { // Collect from left side recursively const leftVars = this.collectDefinedVariablesWithTypes(binaryNode.left); leftVars.forEach((type, name) => varsWithTypes.set(name, type)); // Check if right side is also defineVariable if (binaryNode.right.type === NodeType.Function) { const rightFunc = binaryNode.right as FunctionNode; if (rightFunc.name.type === NodeType.Identifier && (rightFunc.name as IdentifierNode).name === 'defineVariable' && rightFunc.arguments.length >= 1) { const nameArg = rightFunc.arguments[0]; if (nameArg && nameArg.type === NodeType.Literal && nameArg.valueType === 'string') { const varName = nameArg.value as string; let varType: TypeInfo; if (rightFunc.arguments.length >= 2 && rightFunc.arguments[1]!.typeInfo) { varType = rightFunc.arguments[1]!.typeInfo; } else if (binaryNode.typeInfo) { varType = binaryNode.typeInfo; } else { varType = { type: 'Any', singleton: false }; } varsWithTypes.set(varName, varType); } } } } } return varsWithTypes; } // Type inference methods private async inferType(node: ASTNode, inputType?: TypeInfo): Promise<TypeInfo> { // Handle error nodes if (node.type === 'Error') { return this.inferErrorNodeType(node as ErrorNode, inputType); } switch (node.type) { case NodeType.Literal: return this.inferLiteralType(node as LiteralNode); case NodeType.Binary: return await this.inferBinaryType(node as BinaryNode, inputType); case NodeType.Unary: return this.inferUnaryType(node as UnaryNode); case NodeType.Function: return await this.inferFunctionType(node as FunctionNode, inputType); case NodeType.Identifier: return await this.inferIdentifierType(node as IdentifierNode, inputType); case NodeType.Variable: return this.inferVariableType(node as VariableNode); case NodeType.Collection: return await this.inferCollectionType(node as CollectionNode); case NodeType.TypeCast: return await this.inferTypeCastType(node as TypeCastNode); case NodeType.MembershipTest: return { type: 'Boolean', singleton: true }; case NodeType.TypeOrIdentifier: return await this.inferTypeOrIdentifierType(node as TypeOrIdentifierNode, inputType); default: return { type: 'Any', singleton: false }; } } private inferErrorNodeType(errorNode: ErrorNode, inputType?: TypeInfo): TypeInfo { // For error nodes, return a generic type that allows partial analysis to continue // This enables type checking for valid parts of broken expressions return { type: 'Any', singleton: false }; } private inferLiteralType(node: LiteralNode): TypeInfo { switch (node.valueType) { case 'string': return { type: 'String', singleton: true }; case 'number': const num = node.value as number; return { type: Number.isInteger(num) ? 'Integer' : 'Decimal', singleton: true }; case 'boolean': return { type: 'Boolean', singleton: true }; case 'date': return { type: 'Date', singleton: true }; case 'datetime': return { type: 'DateTime', singleton: true }; case 'time': return { type: 'Time', singleton: true }; case 'null': return { type: 'Any', singleton: false }; // Empty collection default: return { type: 'Any', singleton: true }; } } private async inferBinaryType(node: BinaryNode, inputType?: TypeInfo): Promise<TypeInfo> { const operator = registry.getOperatorDefinition(node.operator); if (!operator) { return { type: 'Any', singleton: false }; } // For navigation (dot operator), we need special handling if (node.operator === '.') { return await this.inferNavigationType(node, inputType); } // Infer types of operands const leftType = await this.inferType(node.left, inputType); const rightType = await this.inferType(node.right, inputType); // Find matching signature for (const sig of operator.signatures) { if (this.isTypeCompatible(leftType, sig.left) && this.isTypeCompatible(rightType, sig.right)) { return this.resolveResultType(sig.result, inputType, leftType, rightType); } } // Default to first signature's result type const defaultResult = operator.signatures[0]?.result || { type: 'Any', singleton: false }; return this.resolveResultType(defaultResult, inputType, leftType, rightType); } private async inferNavigationType(node: BinaryNode, inputType?: TypeInfo): Promise<TypeInfo> { const leftType = await this.inferType(node.left, inputType); // If the right side is a function, return the function's type if (node.right.type === NodeType.Function) { return await this.inferType(node.right, leftType); } // If we have a model provider and the right side is an identifier if (this.modelProvider && node.right.type === NodeType.Identifier) { const propertyName = (node.right as IdentifierNode).name; // Use getElementType to navigate the property const resultType = await this.modelProvider.getElementType(leftType, propertyName); if (resultType) { return resultType; } // If property not found and we have a concrete type from model provider, report error // Skip diagnostics for union types - they may have dynamic properties if (leftType.namespace && leftType.name && leftType.modelContext && !(leftType.modelContext as any).isUnion) { this.diagnostics.push( toDiagnostic(Errors.unknownProperty(propertyName, `${leftType.namespace}.${leftType.name}`, node.right.range)) ); } } // Default navigation behavior return { type: 'Any', singleton: false }; } private inferUnaryType(node: UnaryNode): TypeInfo { const operator = registry.getOperatorDefinition(node.operator); if (!operator) { return { type: 'Any', singleton: false }; } // Unary operators typically have one signature const signature = operator.signatures[0]; if (signature && typeof signature.result === 'object') { return signature.result; } return { type: 'Any', singleton: false }; } private async inferFunctionType(node: FunctionNode, inputType?: TypeInfo): Promise<TypeInfo> { if (node.name.type !== NodeType.Identifier) { return { type: 'Any', singleton: false }; } const funcName = (node.name as IdentifierNode).name; const func = registry.getFunction(funcName); if (!func) { return { type: 'Any', singleton: false }; } // Special handling for iif function if (funcName === 'iif') { // iif returns the common type of the true and false branches if (node.arguments.length >= 2) { const trueBranchType = await this.inferType(node.arguments[1]!, inputType); if (node.arguments.length >= 3) { const falseBranchType = await this.inferType(node.arguments[2]!, inputType); // If both branches have the same type, use that if (trueBranchType.type === falseBranchType.type && trueBranchType.singleton === falseBranchType.singleton) { return trueBranchType; } // If types are the same but singleton differs, return as collection if (trueBranchType.type === falseBranchType.type) { // One is singleton, one is collection - result must be collection return { type: trueBranchType.type, singleton: false }; } // Otherwise, check if one is a subtype of the other if (this.isTypeCompatible(trueBranchType, falseBranchType)) { return falseBranchType; } if (this.isTypeCompatible(falseBranchType, trueBranchType)) { return trueBranchType; } } else { // Only true branch, result can be that type or empty return { ...trueBranchType, singleton: false }; } } return { type: 'Any', singleton: false }; } // Special handling for defineVariable function if (funcName === 'defineVariable') { // defineVariable returns its input type unchanged return inputType || { type: 'Any', singleton: false }; } // Special handling for aggregate function if (funcName === 'aggregate') { // If init parameter is provided, use its type to infer result type if (node.arguments.length >= 2) { const initType = await this.inferType(node.arguments[1]!, inputType); // The result type is the same as init type return initType; } // Without init, we can't fully infer the type without running annotation // This is a limitation - the actual type will be set during annotateAST if (node.arguments.length >= 1) { // We could try to infer, but it would require setting up system variables // For now, return Any and let annotateAST handle proper typing return { type: 'Any', singleton: false }; } // No arguments at all return { type: 'Any', singleton: false }; } // Special handling for children function if (funcName === 'children') { if (inputType && this.modelProvider && 'getChildrenType' in this.modelProvider) { const childrenType = await this.modelProvider.getChildrenType(inputType); if (childrenType) { return childrenType; } } // Fallback to Any collection return { type: 'Any', singleton: false }; } // Special handling for descendants function // Returns Any type due to combinatorial explosion of possible types if (funcName === 'descendants') { return { type: 'Any', singleton: false }; } // Special handling for functions with dynamic result types // Use first matching signature's result type const matchingSignature = func.signatures?.find(sig => !sig.input || !inputType || this.isTypeCompatible(inputType, sig.input) ) || func.signatures?.[0]; if (!matchingSignature) { return { type: 'Any', singleton: false }; } if (matchingSignature.result === 'inputType') { // Functions like where() return the same type as input but always as collection return inputType ? { ...inputType, singleton: false } : { type: 'Any', singleton: false }; } else if (matchingSignature.result === 'inputTypeSingleton') { // Functions like first(), last() return the same type as input but as singleton return inputType ? { ...inputType, singleton: true } : { type: 'Any', singleton: true }; } else if (matchingSignature.result === 'parameterType' && node.arguments.length > 0) { // Functions like select() return the type of the first parameter expression as collection const paramType = await this.inferType(node.arguments[0]!, inputType); return { ...paramType, singleton: false }; } else if (typeof matchingSignature.result === 'object') { return matchingSignature.result; } return { type: 'Any', singleton: false }; } private async inferIdentifierType(node: IdentifierNode, inputType?: TypeInfo): Promise<TypeInfo> { // First, try to navigate from input type (most common case) if (inputType && this.modelProvider) { const elementType = await this.modelProvider.getElementType(inputType, node.name); if (elementType) { return elementType; } } // Only check if it's a type name if it starts with uppercase (FHIR convention) // or if there's no input type context // Skip common FHIRPath keywords and function names that aren't types const fhirPathKeywords = ['Boolean', 'String', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time', 'Quantity', 'ofType']; if (this.modelProvider && (!inputType || /^[A-Z]/.test(node.name)) && !fhirPathKeywords.includes(node.name)) { // Try to get type from model provider const typeInfo = await this.modelProvider.getType(node.name); if (typeInfo) { return typeInfo; } } return { type: 'Any', singleton: false }; } private async inferTypeOrIdentifierType(node: TypeOrIdentifierNode, inputType?: TypeInfo): Promise<TypeInfo> { // TypeOrIdentifier can be either a type name or a property navigation // First, try navigation from input type (most common case) if (inputType && this.modelProvider) { const elementType = await this.modelProvider.getElementType(inputType, node.name); if (elementType) { return elementType; } } // Then check if it's a type name (only for uppercase names or no input context) // Skip common FHIRPath keywords and function names that aren't types const fhirPathKeywords = ['Boolean', 'String', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time', 'Quantity', 'ofType']; if (this.modelProvider && (!inputType || /^[A-Z]/.test(node.name)) && !fhirPathKeywords.includes(node.name)) { // Try to get type from model provider const typeInfo = await this.modelProvider.getType(node.name); if (typeInfo) { return typeInfo; } } return { type: 'Any', singleton: false }; } private inferVariableType(node: VariableNode): TypeInfo { // System variables - check temporary context if (node.name.startsWith('$')) { const systemType = this.systemVariableTypes.get(node.name); if (systemType) { return systemType; } // Fallback defaults for system variables switch (node.name) { case '$this': return { type: 'Any', singleton: false }; case '$index': return { type: 'Integer', singleton: true }; case '$total': return { type: 'Any', singleton: false }; default: return { type: 'Any', singleton: false }; } } // Special FHIRPath environment variables if (node.name === '%context' || node.name === '%resource' || node.name === '%rootResource') { return { type: 'Any', singleton: false }; // These return the original input } // User-defined variables - check with or without % prefix let varName = node.name; if (varName.startsWith('%')) { varName = varName.substring(1); } const userType = this.userVariableTypes.get(varName); if (userType) { return userType; } return { type: 'Any', singleton: true }; } private async inferCollectionType(node: CollectionNode): Promise<TypeInfo> { if (node.elements.length === 0) { return { type: 'Any', singleton: false }; } // Infer types of all elements const elementTypes = await Promise.all(node.elements.map(el => this.inferType(el))); // If all elements have the same type, use that const firstType = elementTypes[0]; if (firstType) { const allSameType = elementTypes.every(t => t.type === firstType.type && t.namespace === firstType.namespace && t.name === firstType.name ); if (allSameType) { return { ...firstType, singleton: false }; } } // Otherwise, it's a heterogeneous collection return { type: 'Any', singleton: false }; } private async inferTypeCastType(node: TypeCastNode): Promise<TypeInfo> { const targetType = node.targetType; // If we have a model provider, try to get the type if (this.modelProvider) { const typeInfo = await this.modelProvider.getType(targetType); if (typeInfo) { return typeInfo; } } // Otherwise, check if it's a FHIRPath primitive type const fhirPathTypes = ['String', 'Boolean', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time', 'Quantity']; if (fhirPathTypes.includes(targetType)) { return { type: targetType as TypeName, singleton: true }; } return { type: 'Any', singleton: true }; } private isTypeCompatible(source: TypeInfo, target: TypeInfo): boolean { // Exact match if (source.type === target.type && source.singleton === target.singleton) { return true; } // Any is compatible with everything if (source.type === 'Any' || target.type === 'Any') { return true; } // Singleton can be promoted to collection if (source.singleton && !target.singleton && source.type === target.type) { return true; } // Type hierarchy compatibility if (this.isSubtypeOf(source.type, target.type)) { // Check singleton compatibility if (source.singleton === target.singleton || (source.singleton && !target.singleton)) { return true; } } // Numeric type compatibility if (this.isNumericType(source.type) && this.isNumericType(target.type)) { // Integer can be used where Decimal is expected if (source.type === 'Integer' && target.type === 'Decimal') { return source.singleton !== undefined && target.singleton !== undefined && (source.singleton === target.singleton || (source.singleton && !target.singleton)); } } return false; } private isSubtypeOf(source: TypeName, target: TypeName): boolean { // Basic subtyping rules if (source === target) return true; if (target === 'Any') return true; // Integer is a subtype of Decimal if (source === 'Integer' && target === 'Decimal') return true; // Model-specific subtyping would be checked via ModelProvider // For now, we don't have other subtyping rules return false; } private isNumericType(type: TypeName): boolean { return type === 'Integer' || type === 'Decimal' || type === 'Quantity'; } private inferValueType(value: any): TypeInfo { if (Array.isArray(value)) { if (value.length === 0) { return { type: 'Any', singleton: false }; } // Infer from first element const elementType = this.inferValueType(value[0]); return { ...elementType, singleton: false }; } if (typeof value === 'string') { return { type: 'String', singleton: true }; } else if (typeof value === 'number') { return { type: Number.isInteger(value) ? 'Integer' : 'Decimal', singleton: true }; } else if (typeof value === 'boolean') { return { type: 'Boolean', singleton: true }; } else if (value instanceof Date) { return { type: 'DateTime', singleton: true }; } else { return { type: 'Any', singleton: true }; } } private resolveResultType( resultSpec: TypeInfo | 'inputType' | 'leftType' | 'rightType', inputType?: TypeInfo, leftType?: TypeInfo, rightType?: TypeInfo ): TypeInfo { if (typeof resultSpec !== 'string') { return resultSpec; } switch (resultSpec) { case 'inputType': return inputType || { type: 'Any', singleton: false }; case 'leftType': // For union-like operators, result is always a collection return leftType ? { ...leftType, singleton: false } : { type: 'Any', singleton: false }; case 'rightType': return rightType ? { ...rightType, singleton: false } : { type: 'Any', singleton: false }; default: return { type: 'Any', singleton: false }; } } private checkBinaryOperatorTypes(node: BinaryNode, operator: import('./types').OperatorDefinition): void { const leftType = node.left.typeInfo!; const rightType = node.right.typeInfo!; // Find if any signature matches let foundMatch = false; for (const sig of operator.signatures) { if (this.isTypeCompatible(leftType, sig.left) && this.isTypeCompatible(rightType, sig.right)) { foundMatch = true; break; } } if (!foundMatch) { const leftTypeStr = this.typeToString(leftType); const rightTypeStr = this.typeToString(rightType); this.diagnostics.push( toDiagnostic(Errors.operatorTypeMismatch(node.operator, leftTypeStr, rightTypeStr, node.range)) ); } } private checkFunctionArgumentTypes(node: FunctionNode, func: import('./types').FunctionDefinition): void { const params = func.signatures?.[0]?.parameters || []; for (let i = 0; i < Math.min(node.arguments.length, params.length); i++) { const arg = node.arguments[i]!; const param = params[i]!; if (arg.typeInfo && !param.expression) { // For non-expression parameters, check type compatibility if (!this.isTypeCompatible(arg.typeInfo, param.type)) { const argTypeStr = this.typeToString(arg.typeInfo); const paramTypeStr = this.typeToString(param.type); this.diagnostics.push( toDiagnostic(Errors.argumentTypeMismatch(i + 1, func.name, paramTypeStr, argTypeStr, arg.range)) ); } } } } private typeToString(type: TypeInfo): string { const singletonStr = type.singleton ? '' : '[]'; if (type.namespace && type.name) { return `${type.namespace}.${type.name}${singletonStr}`; } return `${type.type}${singletonStr}`; } /** * Infer the expected type at a cursor position based on context */ private inferExpectedTypeForCursor(cursorNode: AnyCursorNode, inputType?: TypeInfo): TypeInfo | undefined { const context = cursorNode.context; switch (context) { case CursorContext.Identifier: // After dot, expecting a member of the input type return inputType; case CursorContext.Type: // After is/as/ofType, expecting a type name return { type: 'System.String' as TypeName, singleton: true }; case CursorContext.Argument: // In function argument, would need to look up function signature // For now, return Any return { type: 'Any' as TypeName, singleton: false }; case CursorContext.Index: // In indexer, expecting Integer return { type: 'Integer' as TypeName, singleton: true }; case CursorContext.Operator: // Between expressions, could be any operator // Return the input type as context return inputType; default: return undefined; } } /** * Annotate AST with type information */ private async annotateAST(node: ASTNode, inputType?: TypeInfo): Promise<void> { // Check for cursor node in cursor mode if (this.cursorMode && isCursorNode(node)) { this.stoppedAtCursor = true; this.cursorContext = { cursorNode: node as AnyCursorNode, typeBeforeCursor: inputType, expectedType: this.inferExpectedTypeForCursor(node as AnyCursorNode, inputType) }; // Still attach a type to the cursor node for consistency (node as any).typeInfo = inputType || { type: 'Any', singleton: false }; return; // Short-circuit } // If we've already stopped at cursor, don't continue if (this.stoppedAtCursor) { return; } // Handle error nodes if (node.type === 'Error') { const errorNode = node as ErrorNode; // Infer a reasonable type for error nodes node.typeInfo = this.inferErrorNodeType(errorNode, inputType); // Add diagnostic for the error this.diagnostics.push({ severity: errorNode.severity || DiagnosticSeverity.Error, message: errorNode.message, range: errorNode.range, code: errorNode.code?.toString() || 'FP5003', source: 'fhirpath' }); return; } // Infer and attach type info node.typeInfo = await this.inferType(node, inputType); // Recursively annotate children switch (node.type) { case NodeType.Binary: const binaryNode = node as BinaryNode; await this.annotateAST(binaryNode.left, inputType); // If we stopped at cursor, don't continue if (this.stoppedAtCursor) { break; } // Check if right side is a cursor node - if so, set type from left if (this.cursorMode && isCursorNode(binaryNode.right)) { this.stoppedAtCursor = true; this.cursorContext = { cursorNode: binaryNode.right as AnyCursorNode, typeBeforeCursor: binaryNode.left.typeInfo, expectedType: this.inferExpectedTypeForCursor(binaryNode.right as AnyCursorNode, binaryNode.left.typeInfo) }; // Still attach type to cursor node (binaryNode.right as any).typeInfo = binaryNode.left.typeInfo || { type: 'Any', singleton: false }; break; } // For navigation, pass the left's type as input to the right if (binaryNode.operator === '.') { // Collect all variables defined in the left side chain const definedVarsWithTypes = this.collectDefinedVariablesWithTypes(binaryNode.left); if (definedVarsWithTypes.size > 0) { // Save current variable types const savedTypes = new Map<string, TypeInfo>(); definedVarsWithTypes.forEach((type, varName) => { const currentType = this.userVariableTypes.get(varName); if (currentType) { savedTypes.set(varName, currentType); } this.userVariableTypes.set(varName, type); }); // Annotate right side with new variables in scope await this.annotateAST(binaryNode.right, binaryNode.left.typeInfo); // Restore previous types definedVarsWithTypes.forEach((_, varName) => { const savedType = savedTypes.get(varName); if (savedType) { this.userVariableTypes.set(varName, savedType); } else { this.userVariableTypes.delete(varName); } }); } else { // No defineVariable in chain, proceed normally await this.annotateAST(binaryNode.right, binaryNode.left.typeInfo); } } else { await this.annotateAST(binaryNode.right, inputType); } break; case NodeType.Unary: const unaryNode = node as UnaryNode; await this.annotateAST(unaryNode.operand, inputType); break; case NodeType.Function: const funcNode = node as FunctionNode; await this.annotateAST(funcNode.name, inputType); // Special handling for aggregate function arguments if (funcNode.name.type === NodeType.Identifier && (funcNode.name as IdentifierNode).name === 'aggregate') { // Aggregate establishes both $this and $total if (funcNode.arguments.length >= 1) { const itemType = inputType ? { ...inputType, singleton: true } : { type: 'Any' as TypeName, singleton: true }; // Save current system variable context const savedThis = this.systemVariableTypes.get('$this'); const savedTotal = this.systemVariableTypes.get('$total'); // Set $this for iteration this.systemVariableTypes.set('$this', itemType); if (funcNode.arguments.length >= 2) { // Has init parameter - evaluate it first await this.annotateAST(funcNode.arguments[1]!, inputType); const initType = funcNode.arguments[1]!.typeInfo; // Set $total to init type if (initType) { this.systemVariableTypes.set('$total', initType); } else { this.systemVariableTypes.set('$total', { type: 'Any', singleton: false }); } // Process aggregator with both variables set await this.annotateAST(funcNode.arguments[0]!, inputType); // Process remaining arguments for (const arg of funcNode.arguments.slice(2)) { await this.annotateAST(arg, inputType); if (this.stoppedAtCursor) break; } } else { // No init - first pass to infer aggregator type this.systemVariableTypes.set('$total', { type: 'Any', singleton: false }); await this.annotateAST(funcNode.arguments[0]!, inputType); // Second pass with inferred type const aggregatorType = funcNode.arguments[0]!.typeInfo; if (aggregatorType) { this.systemVariableTypes.set('$total', aggregatorType); // Re-annotate with proper $total type await this.annotateAST(funcNode.arguments[0]!, inputType); } } // Restore previous context if (savedThis) { this.systemVariableTypes.set('$this', savedThis); } else { this.systemVariableTypes.delete('$this'); } if (savedTotal) { this.systemVariableTypes.set('$total', savedTotal); } else { this.systemVariableTypes.delete('$total'); } } } else { // Special handling for functions that pass their input as context to arguments const funcName = funcNode.name.type === NodeType.Identifier ? (funcNode.name as IdentifierNode).name : null; if (funcName && ['where', 'select', 'all', 'exists'].includes(funcName)) { // These functions establish $this as each element of the input collection const elementType = inputType ? { ...inputType, singleton: true } : { type: 'Any' as TypeName, singleton: true }; // Save current system variable context const savedThis = this.systemVariableTypes.get('$this');