fume-fhir-converter
Version:
FHIR-Utilized Mapping Engine - Community
455 lines (425 loc) • 23.4 kB
text/typescript
/**
* © 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;
};