UNPKG

fume-fhir-converter

Version:

FHIR-Utilized Mapping Engine - Community

455 lines (425 loc) 23.4 kB
/** * © Copyright Outburn Ltd. 2022-2024 All Rights Reserved * Project name: FUME-COMMUNITY */ import config from '../../config'; import { getFhirPackageIndex } from '../conformance'; import { valueSetExpandDictionary } from '../conformance/conformance'; import expressions from '../jsonataExpression'; import { funcs } from '../jsonataFuncs'; import { getStructureDefinition } from '../jsonataFunctions'; import { getLogger } from '../logger'; import { CastToFhirOptions, FlashMergeOptions } from '../runtime'; import { duplicate, endsWith, initCapOnce, splitToLines, startsWith, substringAfter, substringBefore } from '../stringFunctions'; import thrower from '../thrower'; import { getElementDefinition } from './getElementDefinition'; import { removeComments } from './removeComments'; const dev = process.env.NODE_ENV === 'dev'; export const toJsonataString = async (inExpr: string): Promise<string | undefined> => { if (dev) console.log(toJsonataString); let res: string | undefined; // decrlare FLASH block variables: let rootIndentOffset: string | undefined; // the padding of the first declerations in current flash block let prevRuleDepth: number; // the number of indentations the last rule had let prevRuleNodes: number[]; // the number of chained nodes the last rule had (no chain is 1) let rootStructDef: any; // the current flash block's StructureDefinition resource let instanceDeclerationFlag: boolean = false; // there was an Instance decleration somewhere in the current flash block let instanceOfDeclerationFlag: boolean = false; // there was an InstanceOf decleration somewhere in the current flash block let ruleClosureString: string = ''; // add this string to the end of the flash block - it closes all open rules let flashBlockHanging: boolean = false; // true if we are inside a flash block let currentFshPath: string = ''; // the absolute path relative to root base type // declare expression global variables: let currentBracketDepth: number = 0; // count global hanging open parenthesis let expressionHasFlash: boolean = false; // there is some FLASH inside the expression const lineParser = async (line: string, index: number, allLines: string[]) => { // line by line parsing into native jsonata expressions const trimmedLine = line.trim(); // trim line // check if line is an Instance decleration const isInstanceDeclaration = trimmedLine.startsWith('Instance:'); // check if line is InstanceOf decleration const isInstanceOfDeclaration = trimmedLine.startsWith('InstanceOf:'); // raise expressionHasFlash flag if Instance[Of] was declared expressionHasFlash = expressionHasFlash || isInstanceDeclaration || isInstanceOfDeclaration; // check if line is a FLASH rule const isRule = await isLineFlashRule(trimmedLine); if (isInstanceDeclaration) { // this line starts with 'Instance:' if (instanceOfDeclerationFlag) { // this instance decleration comes after an InstanceOf return thrower.throwParseError(`'Instance:' decleration must come *before* the 'InstanceOf:' decleration in a FLASH block: '${trimmedLine}'`); }; if (instanceDeclerationFlag) { // this is the second instance decleration in current flash block return thrower.throwParseError(`only a single 'Instance:' decleration is allowed in each FLASH block. Found: '${trimmedLine}'`); }; // raise flag so we can trace Instance[Of] parse errors in the current flash block instanceDeclerationFlag = true; // register open flash block flashBlockHanging = true; // extract the expression that comes after Instance: const after: string | undefined = substringAfter(trimmedLine, 'Instance:'); // the indentation in Instance: is the root indentation rootIndentOffset = substringBefore(line, 'Instance:'); // set this so indentations of following rules can be validated prevRuleDepth = 0; prevRuleNodes = []; // return the native jsonata corresponding to the "Instance:" decleration return `${rootIndentOffset}$__flashInstanceId := ${after}`; }; if (isInstanceOfDeclaration) { // current line is an "InstanceOf:" declaration if (instanceOfDeclerationFlag) { // this is the second InstanceOf decleration in current flash block return thrower.throwParseError(`only a single 'InstanceOf:' decleration is allowed in each FLASH block. Found: '${trimmedLine}'`); }; // raise this flag so we can trace Instance[Of] parsing errors in the current flash block: instanceOfDeclerationFlag = true; // register that we are in an open flash block flashBlockHanging = true; // extract the InstanceOf: *** part const rootTypeId: string = (substringAfter(line, 'InstanceOf:') || '').trim(); // make sure it's a potentially valid FHIR type if (!(await funcs.isTypeNameValid(rootTypeId))) return thrower.throwParseError(`value after "InstanceOf:" must be a valid type name, id or URL, and cannot be an expression. Found: "${rootTypeId}"`); // try to fetch the type's StructureDefinition resource rootStructDef = await getStructureDefinition(rootTypeId, config.getFhirVersion(), getFhirPackageIndex(), getLogger()); // throw error if StructureDefinition can't be fetched if (rootStructDef === undefined) return thrower.throwParseError(`can't find definition of ${rootTypeId}!`); // currentFshPath = rootStructDef.type; currentFshPath = ''; // the indentation of InstanceOf: const indent: string | undefined = substringBefore(line, 'InstanceOf:'); // might need to put a semicolon at the beginning of this line, this var will hold it let prefix: string; if (rootIndentOffset === undefined) { // this means it's an InstanceOf declaration without a preceding // "Instance:", so there is no need for a semicolon at the start prefix = ''; } else { // there's a preceding Instance: declaration, so we may need to terminate it with a semicolon const prevline: string = allLines[index - 1]; const isSingleLineExpression = prevline === line; const isPrevLineTerminated = prevline.trimEnd()[-1] === ';'; prefix = !(isSingleLineExpression || isPrevLineTerminated) ? ';' : ''; }; if (rootIndentOffset !== undefined && indent !== rootIndentOffset) { // different indetations for Instance: vs. InstanceOf return thrower.throwParseError('indentation of "InstanceOf:" must be the same as "Instance:"!'); }; // if rootIndent was already set than it's the same as current indent. // if it wasn't set, we need to set it now: rootIndentOffset = indent; prevRuleDepth = 0; prevRuleNodes = []; // set resourceType if it's an instance of a resource or resource profile const resourceType = rootStructDef?.kind === 'resource' ? `'${rootStructDef?.type}'` : 'undefined'; // set meta with profile tag if it's a resource profile const meta = rootStructDef?.kind === 'resource' && rootStructDef?.derivation === 'constraint' ? `{'profile': ['${rootStructDef?.url}']}` : 'undefined'; // create a regex test expression for the id if it's a resource const testId = resourceType === 'undefined' ? '$__flashInstanceId' : '$__checkResourceId($__flashInstanceId)'; // mandatory fixed elements const mandatoryElementsObj = await funcs.getMandatoriesOfStructure(rootTypeId); const mandatoriesAsString = typeof mandatoryElementsObj === 'object' ? JSON.stringify(mandatoryElementsObj) : '{}'; const res = `${prefix}${indent}$__flashInstance := $merge([{'resourceType': ${resourceType}},{'id': ${testId}, 'meta': ${meta}},${mandatoriesAsString}]);`; return res; }; if (flashBlockHanging && isRule) { // logger.info('flashBlockHanging && isRule'); // this line is a flash rule if (!instanceOfDeclerationFlag) return thrower.throwParseError(`rules cannot appear before an "InstaceOf:" declaration. Rule: "${trimmedLine}"`); // check indentation // // root indentation (Instance & InstanceOf) is regarded as zero // any root rule is 2 // when indentation is lower than previous one, need to close previous rules - depending on the diff // when indentation is higher than previous one it can only be by 2 spaces - no need to close previous rule // when indentation is the same - close previous rule const indent: string | undefined = substringBefore(line, '* '); // the indentation of this rule in actual spaces const ruleDepth: number = (indent.length - rootIndentOffset!.length) + 2; // any root rule is 2, so all rules are shifted by 2 if (ruleDepth < 2) { return thrower.throwParseError(`expected indentation of at least ${rootIndentOffset!.length} spaces, got ${indent.length} instead. Rule: ${line}`); } if (ruleDepth > prevRuleDepth + 2) { return thrower.throwParseError(`expected indentation of max ${rootIndentOffset!.length + prevRuleDepth} spaces, got ${indent.length} instead. Rule: ${line}`); } if (ruleDepth % 2 !== 0) { return thrower.throwParseError(`rule indentation must be in increments of two spaces. Rule: ${line}`); } // indentation correct let rulesToClose: number; // how many rules we need to close at the beginning of this line if (ruleDepth < prevRuleDepth) { const steps: number = ((prevRuleDepth - ruleDepth) / 2); // eslint-disable-next-line @typescript-eslint/restrict-plus-operands rulesToClose = steps + sumCarriedNodes(steps, true) + 1; // add carried nodes from previous chained paths } else if (ruleDepth === prevRuleDepth) { const carried: number = sumCarriedNodes(1, false); rulesToClose = carried + 1; } else { rulesToClose = 0; prevRuleNodes.push(0); }; // this will prefix the jsonata line ruleClosureString = rulesToClose === 0 ? '' : await duplicate(')));', rulesToClose); // update current path currentFshPath = climbFshPath(currentFshPath, rulesToClose); // update prevRuleDepth to current value prevRuleDepth = ruleDepth; // check if rule starts with a (context) expression const isContextStart = (substringAfter(trimmedLine, '* ') || '').trim().charAt(0) === '('; if (isContextStart) { const openingIndex = line.indexOf('('); // check if context ends on this line const closingIndex: number = funcs.findClosingBrackets(line, openingIndex + 1, 1); if (closingIndex > -1) { // this is a single-lined context - opens and closes on this line const contextPart = line.substring(openingIndex, closingIndex + 1); const restOfLine = line.substring(closingIndex + 1); // check a period comes after closing bracket if (restOfLine.trimStart()[0] !== '.') return thrower.throwParseError(`expected a period between the context and the path. Rule: '${line}'`); const afterPeriod = (substringAfter(restOfLine, '.') || '').trim(); const pathOnly = (substringBefore(afterPeriod, '=') || '').trim(); let valueOnly = (substringAfter(afterPeriod, '=') || ''); // when there is no assigned value, the result of valueOnly will be equal to the whole line after the period valueOnly = valueOnly === afterPeriod ? '' : valueOnly.trim(); const res = await constructLine(ruleClosureString, pathOnly, valueOnly, contextPart); return res; } else { // no closing parenthesis - syntax error or multiline context // multiline context. TODO: make it work return thrower.throwParseError(`Expected ')' to close the context part. Rule: '${line}'`); } } else { // regular rule without context const pathPart: string = (substringBefore(substringAfter(line, '* ') || '', '=') || '').trim(); let valuePart: string = (substringAfter(line, '=') || ''); valuePart = valuePart === line ? '' : valuePart.trim(); // trace hanging parenthesis const hangingBracketDiff = await funcs.calcBracketDiff(trimmedLine); // eslint-disable-next-line @typescript-eslint/restrict-plus-operands currentBracketDepth = currentBracketDepth + hangingBracketDiff; const res = await constructLine(ruleClosureString, pathPart, valuePart); return res; }; }; const endOfFlashBlock = funcs.findClosingBrackets(line, 0, 1); if (expressionHasFlash && (endOfFlashBlock >= 0 || (flashBlockHanging && index === (allLines.length - 1)))) { // this terminates a flash block const lhs = line.substring(0, endOfFlashBlock); const rhs = line.substring(endOfFlashBlock); const hangingBracketDiff: number = (await funcs.calcBracketDiff(rhs)); // eslint-disable-next-line @typescript-eslint/restrict-plus-operands const steps: number = (prevRuleDepth / 2); const rulesToClose: number = steps + sumCarriedNodes(steps, true); // logger.info({ rulesToClose, steps }); let closure: string = ''; // = (await duplicate(')));', rulesToClose)) + '$__flashInstance := $__finalize($__flashInstance);'; if (rulesToClose !== 0) { // eslint-disable-next-line @typescript-eslint/restrict-plus-operands closure = (await duplicate(')));', rulesToClose)) + '$__flashInstance := $__finalize($__flashInstance);'; } // closure = closure + '$__flashInstance := $__finalize($__flashInstance);'; // logger.info('calling resetVariable()'); resetVariables(); currentBracketDepth = currentBracketDepth - hangingBracketDiff; let res: string; if (endOfFlashBlock >= 0) { res = `${closure}${lhs}${rhs}`; } else { res = `${line}${closure}`; }; return res; }; // no special handling required for this line - return it as-is // trace hanging parenthesis const hangingBracketDiff = await funcs.calcBracketDiff(trimmedLine); // eslint-disable-next-line @typescript-eslint/restrict-plus-operands currentBracketDepth = currentBracketDepth + hangingBracketDiff; return line; }; const resetVariables = (): void => { currentFshPath = ruleClosureString = ''; flashBlockHanging = instanceDeclerationFlag = instanceOfDeclerationFlag = false; rootIndentOffset = rootStructDef = undefined; prevRuleDepth = 0; prevRuleNodes = []; }; const climbFshPath = (path: string, steps: number): string => { const startPath: string = path === '' ? currentFshPath : path; // goes up in path <steps> levels if (steps === 0) return startPath; // no change const nodes = startPath.split('.'); // split to path nodes nodes.splice(nodes.length - steps, steps); // remove top levels const newPath = nodes.join('.'); // concatenate back into a path chain currentFshPath = newPath; // update currentFshPath return newPath; }; const isLineFlashRule = async (line: string): Promise<boolean> => { // check if this line should be parsed as a FLASH rule return expressionHasFlash && // if this is false, // it couldn't be a FLASH rule since those are only // present in expressions that have FLASH scripts in them. flashBlockHanging && // Even if previous condition is true, the line must // be inside a FLASH block otherwise it's probably // just a potentially valid part of a jsontata expression line.startsWith('*'); // a rule line starts with an asterisk }; const parseFumeExpression = async (expr: string): Promise<string> => { const bindings = { expr, splitLineFunc: splitToLines, lineParser, startsWith, endsWith }; const parsed = await expressions.parseFumeExpression.evaluate({}, bindings); // logger.info({ parsed }); return parsed; }; const sumCarriedNodes = (steps: number, climb: boolean): number => { // hard to explain... // TODO: explain... // logger.info('sumCarriedNodes'); if (climb) prevRuleNodes = prevRuleNodes.filter((item) => item > 0); if (prevRuleNodes.length === 0) return 0; const sum: number = prevRuleNodes.slice(-steps).reduce((acc, val) => acc + val); prevRuleNodes = prevRuleNodes.slice(0, prevRuleNodes.length - steps); return sum; }; const constructLine = async ( prefix: string, path: string, value?: string, context?: string, chainNode?: boolean ): Promise<string | undefined> => { // take the parts of a flash rule and return a jsonata equivalent line // wrap context in parenthesis and add a period const contextPart = context && context !== '' ? `${context}.` : ''; // take value into __value or create an empty object const valuePart = value && value.trim() !== '' ? `$__flashInstance := {'__value': ${value}}` : '$__flashInstance := {}'; // split path to separate nodes const nodes = path.split('.'); if (nodes.length === 1) { // this is a single node iteration (may still be a step in a chain) // add node to accumulating FSH path currentFshPath = currentFshPath === '' ? path : `${currentFshPath}.${path}`; if (chainNode) { // this is an internal node in a chain of nodes (but not the last one) // increase node counter prevRuleNodes[prevRuleNodes.length - 1] += 1; }; // fetch element definition if (dev) console.log('(toJsonataString): getElementDefinition()', { originPath: currentFshPath, newPath: currentFshPath }); let eDef = await getElementDefinition(rootStructDef.id, { originPath: currentFshPath, newPath: currentFshPath }); if (eDef) { const eDefPath: string = eDef?.path; const jsonName: string = await funcs.getJsonElementName(eDef, path); let chosenTypeEntry: object | any; const isPoly = eDefPath.endsWith('[x]'); if (isPoly) { // it's a polymorfic element (e.g. value[x]) // extract type name if (eDef?.type.length === 1) { // if constrained to a single type, just take it chosenTypeEntry = eDef?.type[0]; } else { // get type from end of fsh path const typeByFshPath: string = path.split(eDefPath.split('.').slice(-1)[0].split('[x]')[0]).slice(-1)[0]; const compareTypes = (typeFromDef: object | any): boolean => { // this function compares types by lowercasing their names const typeCode: string = typeFromDef?.code; return typeCode.toLowerCase() === typeByFshPath.toLowerCase(); }; // select from types using comparison function chosenTypeEntry = eDef?.type?.filter(compareTypes)[0]; try { // if there's an explicit type slice, it overrules the polymorphic head element const eDefIDNoRoot: string = eDef.id.split('.').slice(1).join('.'); const typeSliceElementId: string = `${eDefIDNoRoot}:${jsonName}`; // try to fetch the explicit type slice if (dev) console.log('(toJsonataString): getElementDefinition()', { 'rootStructDef.id': rootStructDef.id }, { originPath: typeSliceElementId, newPath: typeSliceElementId }); const typeSlice = await getElementDefinition(rootStructDef.id, { originPath: typeSliceElementId, newPath: typeSliceElementId }); if (typeSlice) { // replace head element with type slice element eDef = typeSlice; } } catch {}; } } else { // not polymorphic, there can only be a single type in the array chosenTypeEntry = eDef?.type[0]; }; const baseType = chosenTypeEntry?.code; let kind: string = ''; if (!baseType.startsWith('http://hl7.org/fhirpath/System.')) { // fetch StructureDefintion of type const typeStructDef = await getStructureDefinition(baseType, config.getFhirVersion(), getFhirPackageIndex(), getLogger()); kind = typeStructDef?.kind; }; const jsonPrimitiveProfile: string = chosenTypeEntry?.extension?.filter((ext: any) => ext?.url === 'http://hl7.org/fhir/StructureDefinition/structuredefinition-fhir-type')[0]?.valueUrl; const mandatoryObj = await funcs.getMandatoriesOfElement(rootStructDef.id, currentFshPath); const typeForFixed = jsonPrimitiveProfile ?? baseType; const fixed: any = (eDef['fixed' + initCapOnce(typeForFixed)] ?? eDef['pattern' + initCapOnce(typeForFixed)]) ?? mandatoryObj; const bindingUrl: string = eDef?.binding?.strength === 'required' ? eDef.binding.valueSet : ''; const vsDictionary = bindingUrl !== '' ? await valueSetExpandDictionary(bindingUrl) : undefined; let pathForCardinality = currentFshPath; if (eDefPath.split('.').length > currentFshPath.split('.').length) { pathForCardinality = eDefPath.split('.')[0] + '.' + currentFshPath; } // create cast options object const castOptions: CastToFhirOptions = { name: jsonName, path: pathForCardinality, baseType, kind, jsonPrimitiveProfile, fixed, vsDictionary }; const mergeOptions: FlashMergeOptions = { key: jsonName, baseMax: eDef?.base?.max ?? '1', max: eDef?.max ?? '1', min: eDef?.min ?? 0, path: pathForCardinality, eDefPath, kind }; // construct line const line = `${prefix}$__context := ${contextPart}($);$__flashInstance := $__flashMerge($merge([${JSON.stringify(mergeOptions)},{'mandatory': $count($__context)>0 ? ${JSON.stringify(mandatoryObj)} : undefined }]),$__flashInstance,$__castToFhir(${JSON.stringify(castOptions)}, ${contextPart}(${valuePart};`; return line; }; } else if (nodes.length > 1) { // chained path, iterate through each one prevRuleNodes.push(0); const bindings = { nodes, construct: constructLine, prefix, value: value ?? '', context: context ?? '' }; const line = await expressions.constructLineIterator.evaluate({}, bindings); return line; }; // didn't find any edef return thrower.throwParseError(`Invalid path: '${currentFshPath}'`); }; const withoutComments: string = removeComments(inExpr); let parsed: string = await parseFumeExpression(withoutComments); if (!expressionHasFlash) { res = withoutComments; } else { // check if expression starts with "(" const isEnclosed = parsed.trimStart().startsWith('(') && parsed.trimEnd().endsWith('('); // parsed = parsed.includes('$__finalize') ? parsed : `${parsed}$__flashInstance := $__finalize($__flashInstance);` parsed = !isEnclosed ? `(${parsed})` : parsed; // enclose in () if needed res = parsed.includes('$__finalize') ? parsed : parsed + '~>$__finalize'; } return res; };