diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index f7af211..85d4ed9 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties index 195f502..69cff84 100644 --- a/.gradle/buildOutputCleanup/cache.properties +++ b/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Mon Sep 29 13:56:43 UTC 2025 -gradle.version=9.1.0 +#Sat Sep 05 15:37:42 HST 2026 +gradle.version=8.14.2 diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.1.0-commonMain-ITmP6A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.1.0-commonMain-ITmP6A.klib new file mode 100644 index 0000000..ba96012 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.1.0-commonMain-ITmP6A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.1.0-annotationsCommonMain-D3q3YQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.1.0-annotationsCommonMain-D3q3YQ.klib new file mode 100644 index 0000000..2d48035 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.1.0-annotationsCommonMain-D3q3YQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.1.0-assertionsCommonMain-D3q3YQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.1.0-assertionsCommonMain-D3q3YQ.klib new file mode 100644 index 0000000..22d6f1b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.1.0-assertionsCommonMain-D3q3YQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.1-commonMain-c2T0hA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.1-commonMain-c2T0hA.klib new file mode 100644 index 0000000..e1e4b71 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.1-commonMain-c2T0hA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.0-commonMain-X2nJrA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.0-commonMain-X2nJrA.klib new file mode 100644 index 0000000..bed07a7 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.0-commonMain-X2nJrA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.6.0-commonMain-VuRrcQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.6.0-commonMain-VuRrcQ.klib new file mode 100644 index 0000000..cf196f8 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.6.0-commonMain-VuRrcQ.klib differ diff --git a/build.gradle.kts b/build.gradle.kts index 5092c68..9d9202a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("multiplatform") version "2.1.0" kotlin("plugin.serialization") version "2.1.0" + id("maven-publish") } group = "org.litlfred.fmlrunner" @@ -27,6 +28,15 @@ kotlin { } } + iosArm64() + iosSimulatorArm64() + iosX64() + + @OptIn(org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl::class) + wasmJs { + browser() + } + js(IR) { browser { testTask { @@ -53,8 +63,8 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") // kotlin-fhirpath dependency from https://github.com/jingtang10/kotlin-fhirpath // Note: JitPack access still blocked - will integrate when network allows // implementation("com.github.jingtang10:kotlin-fhirpath:0.1.0") diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..eb84db6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/appleMain/kotlin/org/litlfred/fmlrunner/Platform.kt b/src/appleMain/kotlin/org/litlfred/fmlrunner/Platform.kt new file mode 100644 index 0000000..55a5316 --- /dev/null +++ b/src/appleMain/kotlin/org/litlfred/fmlrunner/Platform.kt @@ -0,0 +1,13 @@ +package org.litlfred.fmlrunner + +/** + * Apple platform-specific implementations + */ +actual class PlatformLogger { + actual fun log(level: String, message: String, data: Any?) { + val logMessage = "${level.uppercase()}: $message" + println("$logMessage ${data ?: ""}") + } +} + +actual fun getPlatformName(): String = "Apple" diff --git a/src/commonMain/kotlin/org/litlfred/fmlrunner/FmlRunner.kt b/src/commonMain/kotlin/org/litlfred/fmlrunner/FmlRunner.kt index 0814f35..f7c82fd 100644 --- a/src/commonMain/kotlin/org/litlfred/fmlrunner/FmlRunner.kt +++ b/src/commonMain/kotlin/org/litlfred/fmlrunner/FmlRunner.kt @@ -2,7 +2,12 @@ package org.litlfred.fmlrunner import org.litlfred.fmlrunner.types.* import org.litlfred.fmlrunner.compiler.FmlCompiler -import org.litlfred.fmlrunner.executor.StructureMapExecutor +import org.litlfred.fmlrunner.executor.FmlEngine +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import org.litlfred.fmlrunner.terminology.* /** @@ -11,8 +16,11 @@ import org.litlfred.fmlrunner.terminology.* */ class FmlRunner { private val compiler = FmlCompiler() - private val executor = StructureMapExecutor() private val structureMapStore = mutableMapOf() + private val conceptMapStore = mutableMapOf() + private val typeStore = mutableMapOf>() // SD url -> element leaf -> type code + private val displayStore = mutableMapOf() // "system|code" -> display + private val logicalTypeNames = mutableMapOf() // logical SD url -> resourceType name // kotlin-fhir terminology services private val conceptMapService = ConceptMapService() @@ -34,8 +42,95 @@ class FmlRunner { fun executeStructureMap(structureMapReference: String, inputContent: String, options: ExecutionOptions = ExecutionOptions()): ExecutionResult { val structureMap = getStructureMap(structureMapReference) ?: return ExecutionResult(success = false, errors = listOf("StructureMap not found: $structureMapReference")) + val source = try { + Json.parseToJsonElement(inputContent) + } catch (e: Exception) { + return ExecutionResult(success = false, errors = listOf("Invalid source JSON: ${e.message}")) + } + val engine = FmlEngine( + resolveMap = { ref -> getStructureMap(ref) }, + resolveConceptMap = { url -> conceptMapStore[url] }, + resolveElementTypes = { url -> typeStore[url] }, + resolveDisplay = { system, code -> displayStore["$system|$code"] }, + resolveLogicalTypeName = { url -> logicalTypeNames[url] } + ) + return engine.execute(structureMap, source) + } + + /** + * Register a CodeSystem (JSON): concept displays back the c() transform, + * which resolves display from terminology (matching the reference engine) + * rather than from its optional third argument. + */ + fun registerCodeSystem(json: String): Boolean { + return try { + val o = Json.parseToJsonElement(json) as? JsonObject ?: return false + val url = (o["url"] as? JsonPrimitive)?.contentOrNull ?: return false + fun walk(concepts: kotlinx.serialization.json.JsonArray?) { + concepts?.forEach { c -> + val co = c as? JsonObject ?: return@forEach + val code = (co["code"] as? JsonPrimitive)?.contentOrNull + val display = (co["display"] as? JsonPrimitive)?.contentOrNull + if (code != null && display != null) displayStore["$url|$code"] = display + walk(co["concept"] as? kotlinx.serialization.json.JsonArray) + } + } + walk(o["concept"] as? kotlinx.serialization.json.JsonArray) + true + } catch (e: Exception) { + false + } + } + + /** + * Register a StructureDefinition (JSON): declared element types drive + * choice-element ([x]) naming during execution. + */ + fun registerStructureDefinition(json: String): Boolean { + return try { + val o = Json.parseToJsonElement(json) as? JsonObject ?: return false + val url = (o["url"] as? JsonPrimitive)?.contentOrNull ?: return false + val elements = ((o["snapshot"] ?: o["differential"]) as? JsonObject) + ?.get("element") as? kotlinx.serialization.json.JsonArray ?: return false + val types = mutableMapOf() + for (el in elements) { + val eo = el as? JsonObject ?: continue + val id = (eo["id"] as? JsonPrimitive)?.contentOrNull ?: continue + val type = ((eo["type"] as? kotlinx.serialization.json.JsonArray) + ?.firstOrNull() as? JsonObject)?.get("code") as? JsonPrimitive ?: continue + val code = type.contentOrNull ?: continue + val max = (eo["max"] as? JsonPrimitive)?.contentOrNull ?: "1" + // leaf-flattened: on collisions the shallower (earlier) element + // wins — root-level elements precede nested ones in snapshots + types.getOrPut(id.substringAfterLast('.')) { "$code|$max" } + } + typeStore[url] = types + // Resource- and logical-model roots serialize with resourceType + // taken from the SD's `type` (its last segment), matching the + // reference engine; complex-type SDs get none. + if ((o["kind"] as? JsonPrimitive)?.contentOrNull in setOf("logical", "resource")) { + (o["type"] as? JsonPrimitive)?.contentOrNull?.let { + logicalTypeNames[url] = it.substringAfterLast('/') + } + } + true + } catch (e: Exception) { + false + } + } - return executor.execute(structureMap, inputContent, options) + /** + * Register a ConceptMap (JSON) for use by the translate() transform. + */ + fun registerConceptMap(json: String): Boolean { + return try { + val o = Json.parseToJsonElement(json) as? JsonObject ?: return false + val url = (o["url"] as? JsonPrimitive)?.contentOrNull ?: return false + conceptMapStore[url] = o + true + } catch (e: Exception) { + false + } } /** @@ -119,7 +214,13 @@ class FmlRunner { * Validate StructureMap structure */ fun validateStructureMap(structureMap: StructureMap): org.litlfred.fmlrunner.executor.ValidationResult { - return executor.validateStructureMap(structureMap) + val errors = mutableListOf() + if (structureMap.group.isEmpty()) errors.add("StructureMap must have at least one group") + structureMap.group.forEach { g -> + if (g.input.isEmpty()) errors.add("Group '${g.name}' has no inputs") + if (g.rule.isEmpty()) errors.add("Group '${g.name}' has no rules") + } + return org.litlfred.fmlrunner.executor.ValidationResult(valid = errors.isEmpty(), errors = errors) } /** diff --git a/src/commonMain/kotlin/org/litlfred/fmlrunner/compiler/FmlCompiler.kt b/src/commonMain/kotlin/org/litlfred/fmlrunner/compiler/FmlCompiler.kt index 17b7cbf..7d42f88 100644 --- a/src/commonMain/kotlin/org/litlfred/fmlrunner/compiler/FmlCompiler.kt +++ b/src/commonMain/kotlin/org/litlfred/fmlrunner/compiler/FmlCompiler.kt @@ -92,6 +92,18 @@ class FmlTokenizer(private val input: String) { } return Token(TokenType.COMMENT, input.substring(start, position), startLine, startColumn) } + if (peek() == '*') { + // Block comment + advance() + while (!isAtEnd() && !(peek() == '*' && position + 1 < input.length && input[position + 1] == '/')) { + advance() + } + if (!isAtEnd()) { + advance() // '*' + advance() // '/' + } + return Token(TokenType.COMMENT, input.substring(start, position), startLine, startColumn) + } } // Handle strings @@ -228,26 +240,43 @@ class FmlParser(private val tokens: List) { private fun parseStructureMap(): StructureMap { // Expect "map" if (!match(TokenType.MAP)) { - throw IllegalArgumentException("Expected 'map' keyword at start of StructureMap") + throw IllegalArgumentException(err("Expected 'map' keyword at start of StructureMap")) } // Parse URL val url = if (peek().type == TokenType.STRING) { advance().value } else { - throw IllegalArgumentException("Expected URL string after 'map'") + throw IllegalArgumentException(err("Expected URL string after 'map'")) } // Parse "=" if (!match(TokenType.EQUALS)) { - throw IllegalArgumentException("Expected '=' after URL") + throw IllegalArgumentException(err("Expected '=' after URL")) } // Parse name val name = if (peek().type == TokenType.STRING) { advance().value } else { - throw IllegalArgumentException("Expected name string after '='") + throw IllegalArgumentException(err("Expected name string after '='")) + } + + // Parse header declarations: uses ... / imports ... + val structures = mutableListOf() + val imports = mutableListOf() + while (true) { + when (peek().type) { + TokenType.USES -> structures.add(parseUses()) + TokenType.IMPORTS -> { + advance() + if (peek().type != TokenType.STRING) { + throw IllegalArgumentException(err("Expected URL string after 'imports'")) + } + imports.add(advance().value) + } + else -> break + } } // Parse groups @@ -257,49 +286,86 @@ class FmlParser(private val tokens: List) { } if (groups.isEmpty()) { - throw IllegalArgumentException("StructureMap must have at least one group") + throw IllegalArgumentException(err("StructureMap must have at least one group")) } return StructureMap( url = url, name = name, status = StructureMapStatus.ACTIVE, + structure = structures.ifEmpty { null }, + import = imports.ifEmpty { null }, group = groups ) } + // uses "url" [alias Name] as source|queried|target|produced + private fun parseUses(): StructureMapStructure { + advance() // 'uses' + if (peek().type != TokenType.STRING) { + throw IllegalArgumentException(err("Expected URL string after 'uses'")) + } + val url = advance().value + var alias: String? = null + if (peek().type == TokenType.ALIAS) { + advance() + if (peek().type != TokenType.IDENTIFIER) { + throw IllegalArgumentException(err("Expected alias name after 'alias'")) + } + alias = advance().value + } + if (peek().type != TokenType.AS) { + throw IllegalArgumentException(err("Expected 'as' in uses declaration")) + } + advance() + if (peek().type != TokenType.IDENTIFIER) { + throw IllegalArgumentException(err("Expected model mode after 'as'")) + } + val modeStr = advance().value + val mode = when (modeStr.lowercase()) { + "source" -> StructureMapModelMode.SOURCE + "queried" -> StructureMapModelMode.QUERIED + "target" -> StructureMapModelMode.TARGET + "produced" -> StructureMapModelMode.PRODUCED + else -> throw IllegalArgumentException(err("Invalid model mode: $modeStr")) + } + return StructureMapStructure(url = url, mode = mode, alias = alias) + } + + private fun err(message: String): String { + val t = peek() + return "$message (line ${t.line}, near '${t.value.take(20)}')" + } + private fun parseGroup(): StructureMapGroup { if (!match(TokenType.GROUP)) { - throw IllegalArgumentException("Expected 'group' keyword") + throw IllegalArgumentException(err("Expected 'group' keyword")) } val name = if (peek().type == TokenType.IDENTIFIER) { advance().value } else { - throw IllegalArgumentException("Expected group name") + throw IllegalArgumentException(err("Expected group name")) } if (!match(TokenType.LPAREN)) { - throw IllegalArgumentException("Expected '(' after group name") + throw IllegalArgumentException(err("Expected '(' after group name")) } - // Parse inputs + // Parse inputs; the comma separator is optional (ddcc declares + // multiline signatures without commas) val inputs = mutableListOf() while (!check(TokenType.RPAREN)) { inputs.add(parseInput()) - if (!check(TokenType.RPAREN)) { - if (!match(TokenType.COMMA)) { - throw IllegalArgumentException("Expected ',' between inputs") - } - } + if (!check(TokenType.RPAREN)) match(TokenType.COMMA) } if (!match(TokenType.RPAREN)) { - throw IllegalArgumentException("Expected ')' after inputs") + throw IllegalArgumentException(err("Expected ')' after inputs")) } if (!match(TokenType.LBRACE)) { - throw IllegalArgumentException("Expected '{' to start group body") + throw IllegalArgumentException(err("Expected '{' to start group body")) } // Parse rules @@ -309,7 +375,7 @@ class FmlParser(private val tokens: List) { } if (!match(TokenType.RBRACE)) { - throw IllegalArgumentException("Expected '}' to end group body") + throw IllegalArgumentException(err("Expected '}' to end group body")) } return StructureMapGroup( @@ -326,92 +392,369 @@ class FmlParser(private val tokens: List) { when (modeStr.lowercase()) { "source" -> InputMode.SOURCE "target" -> InputMode.TARGET - else -> throw IllegalArgumentException("Invalid input mode: $modeStr") + else -> throw IllegalArgumentException(err("Invalid input mode: $modeStr")) } } - else -> throw IllegalArgumentException("Expected input mode (source/target)") + else -> throw IllegalArgumentException(err("Expected input mode (source/target)")) } val name = if (peek().type == TokenType.IDENTIFIER) { advance().value } else { - throw IllegalArgumentException("Expected input name") + throw IllegalArgumentException(err("Expected input name")) + } + + // Optional type: `source qr : QResp` + var type: String? = null + if (match(TokenType.COLON)) { + type = if (peek().type == TokenType.IDENTIFIER) { + advance().value + } else { + throw IllegalArgumentException(err("Expected input type after ':'")) + } } return StructureMapGroupInput( name = name, + type = type, mode = mode ) } private fun parseRule(): StructureMapGroupRule { - val sources = mutableListOf() - - // Parse source - sources.add(parseRuleSource()) + val sources = mutableListOf(parseRuleSource()) + while (match(TokenType.COMMA)) sources.add(parseRuleSource()) + + var targets: MutableList? = null + if (match(TokenType.ARROW)) { + targets = mutableListOf(parseRuleTarget()) + while (match(TokenType.COMMA)) targets.add(parseRuleTarget()) + } - // Expect arrow - if (!match(TokenType.ARROW)) { - throw IllegalArgumentException("Expected '->' in rule") + var nested: MutableList? = null + var dependents: MutableList? = null + if (peek().type == TokenType.IDENTIFIER && peek().value == "then") { + advance() + if (match(TokenType.LBRACE)) { + nested = mutableListOf() + while (!check(TokenType.RBRACE) && !isAtEnd()) { + nested.add(parseRule()) + } + if (!match(TokenType.RBRACE)) { + throw IllegalArgumentException(err("Expected '}' to end nested rules")) + } + } else { + dependents = mutableListOf(parseDependent()) + while (match(TokenType.COMMA)) dependents.add(parseDependent()) + } } - // Parse targets - val targets = mutableListOf() - targets.add(parseRuleTarget()) + var name: String? = null + if (peek().type == TokenType.STRING) { + name = advance().value + } - // Expect semicolon if (!match(TokenType.SEMICOLON)) { - throw IllegalArgumentException("Expected ';' to end rule") + throw IllegalArgumentException(err("Expected ';' to end rule")) } return StructureMapGroupRule( + name = name, source = sources, - target = targets + target = targets, + rule = nested, + dependent = dependents ) } - private fun parseRuleSource(): StructureMapGroupRuleSource { - val context = if (peek().type == TokenType.IDENTIFIER) { - advance().value - } else { - throw IllegalArgumentException("Expected source context") + private fun parseDependent(): StructureMapGroupRuleDependent { + val name = expectIdentifier("dependent group name") + if (!match(TokenType.LPAREN)) { + throw IllegalArgumentException(err("Expected '(' after dependent group name")) + } + val variables = mutableListOf() + while (!check(TokenType.RPAREN)) { + variables.add(expectIdentifier("dependent variable")) + if (!check(TokenType.RPAREN) && !match(TokenType.COMMA)) { + throw IllegalArgumentException(err("Expected ',' between dependent variables")) + } } + advance() // ')' + return StructureMapGroupRuleDependent(name = name, variable = variables) + } + private fun parseRuleSource(): StructureMapGroupRuleSource { + val context = expectIdentifier("source context") var element: String? = null if (match(TokenType.DOT)) { - element = if (peek().type == TokenType.IDENTIFIER) { - advance().value - } else { - throw IllegalArgumentException("Expected element name after '.'") + element = expectElementName() + } + // Optional type cast: `entry.resource : Patient` + var type: String? = null + if (match(TokenType.COLON)) { + type = expectIdentifier("source type after ':'") + } + var listMode: String? = null + var variable: String? = null + var condition: String? = null + var checkExpr: String? = null + loop@ while (true) { + val t = peek() + when { + t.type == TokenType.IDENTIFIER && t.value in LIST_MODES && listMode == null -> + listMode = advance().value + t.type == TokenType.AS -> { + advance() + variable = expectIdentifier("variable name after 'as'") + } + t.type == TokenType.WHERE -> { + advance() + condition = captureExpression() + } + t.type == TokenType.CHECK -> { + advance() + checkExpr = captureExpression() + } + else -> break@loop } } - return StructureMapGroupRuleSource( context = context, - element = element + element = element, + variable = variable, + type = type, + listMode = listMode, + condition = condition, + check = checkExpr ) } private fun parseRuleTarget(): StructureMapGroupRuleTarget { - val context = if (peek().type == TokenType.IDENTIFIER) { - advance().value - } else { - throw IllegalArgumentException("Expected target context") + // Invocation form: create("...") as model, uuid() as pid, c(system, code) + if (peek().type == TokenType.IDENTIFIER && peekNext().type == TokenType.LPAREN) { + val fn = advance().value + val params = parseTransformParams() + var variable: String? = null + if (peek().type == TokenType.AS) { + advance() + variable = expectIdentifier("variable name after 'as'") + } + return StructureMapGroupRuleTarget( + variable = variable, + transform = fn, + parameter = params.ifEmpty { null } + ) + } + + // Anonymous expression target: `(expr) as var` — no context, the + // evaluated value is only bound to the variable + if (peek().type == TokenType.LPAREN) { + val expr = captureParenExpression() + var variable: String? = null + if (peek().type == TokenType.AS) { + advance() + variable = expectIdentifier("variable name after 'as'") + } + return StructureMapGroupRuleTarget( + variable = variable, + transform = "evaluate", + parameter = listOf(TransformParameter(valueString = expr)) + ) } + // Context form: ctx('.' element)? ('=' transform)? ('as' var)? + val context = expectIdentifier("target context") var element: String? = null if (match(TokenType.DOT)) { - element = if (peek().type == TokenType.IDENTIFIER) { - advance().value - } else { - throw IllegalArgumentException("Expected element name after '.'") + element = expectElementName() + } + var transform: String? = null + var parameter: List? = null + if (match(TokenType.EQUALS)) { + val t = peek() + when { + t.type == TokenType.IDENTIFIER && peekNext().type == TokenType.LPAREN -> { + transform = advance().value + parameter = parseTransformParams().ifEmpty { null } + } + // Parenthesized FHIRPath: `doseNumber = (dose.toInteger() + 1)` + t.type == TokenType.LPAREN -> { + transform = "evaluate" + parameter = listOf(TransformParameter(valueString = captureParenExpression())) + } + t.type == TokenType.STRING -> { + transform = "copy" + parameter = listOf(TransformParameter(valueString = advance().value)) + } + t.type == TokenType.NUMBER -> { + transform = "copy" + parameter = listOf(numberParam(advance().value)) + } + t.type in KEYWORD_TYPES -> { + transform = "copy" + parameter = listOf(TransformParameter(valueId = advance().value)) + } + t.type == TokenType.IDENTIFIER -> { + var path = advance().value + if (check(TokenType.DOT)) { + while (match(TokenType.DOT)) { + path += "." + expectIdentifier("path segment") + } + transform = "evaluate" + parameter = listOf(TransformParameter(valueString = path)) + } else { + transform = "copy" + parameter = listOf(TransformParameter(valueId = path)) + } + } + else -> throw IllegalArgumentException(err("Expected transform after '='")) } } - + var variable: String? = null + if (peek().type == TokenType.AS) { + advance() + variable = expectIdentifier("variable name after 'as'") + } return StructureMapGroupRuleTarget( context = context, contextType = ContextType.VARIABLE, - element = element + element = element, + variable = variable, + transform = transform, + parameter = parameter + ) + } + + private fun parseTransformParams(): List { + if (!match(TokenType.LPAREN)) { + throw IllegalArgumentException(err("Expected '(' to start parameters")) + } + val params = mutableListOf() + while (!check(TokenType.RPAREN)) { + val t = peek() + params.add(when (t.type) { + TokenType.STRING -> TransformParameter(valueString = advance().value) + TokenType.NUMBER -> numberParam(advance().value) + TokenType.IDENTIFIER -> when (t.value) { + "true" -> { advance(); TransformParameter(valueBoolean = true) } + "false" -> { advance(); TransformParameter(valueBoolean = false) } + else -> TransformParameter(valueId = advance().value) + } + // Parenthesized FHIRPath argument: evaluate(src, (($this - age)...)) + TokenType.LPAREN -> TransformParameter(valueString = captureParenExpression()) + in KEYWORD_TYPES -> TransformParameter(valueId = advance().value) + else -> throw IllegalArgumentException(err("Expected parameter")) + }) + if (!check(TokenType.RPAREN) && !match(TokenType.COMMA)) { + throw IllegalArgumentException(err("Expected ',' between parameters")) + } + } + advance() // ')' + return params + } + + private fun numberParam(raw: String): TransformParameter = + if (raw.contains('.')) TransformParameter(valueDecimal = raw.toDouble()) + else TransformParameter(valueInteger = raw.toInt()) + + /** + * Capture a FHIRPath expression as raw-ish text: tokens joined with spaces + * (FHIRPath is whitespace-insensitive), strings re-quoted single. Stops at + * a rule boundary (->, ;, ',', as, check, then) at paren depth 0. + */ + private fun captureExpression(): String { + val sb = StringBuilder() + var depth = 0 + while (!isAtEnd()) { + val t = peek() + if (depth == 0 && (t.type == TokenType.ARROW || t.type == TokenType.SEMICOLON || + t.type == TokenType.COMMA || t.type == TokenType.AS || t.type == TokenType.CHECK || + (t.type == TokenType.IDENTIFIER && t.value == "then"))) break + if (t.type == TokenType.LPAREN) depth++ + if (t.type == TokenType.RPAREN) { + if (depth == 0) break + depth-- + } + if (sb.isNotEmpty()) sb.append(' ') + sb.append(if (t.type == TokenType.STRING) "'" + t.value + "'" else t.value) + advance() + } + if (sb.isEmpty()) { + throw IllegalArgumentException(err("Expected expression")) + } + return sb.toString() + } + + private fun expectIdentifier(what: String): String { + if (peek().type != TokenType.IDENTIFIER && peek().type !in KEYWORD_TYPES) { + throw IllegalArgumentException(err("Expected $what")) + } + return advance().value + } + + /** + * Element names may be plain identifiers, FML keywords used as names + * (Name.prefix), or quoted strings (ICAO/DCC numeric keys: src."-260"). + */ + private fun expectElementName(): String { + val t = peek() + if (t.type == TokenType.IDENTIFIER || t.type == TokenType.STRING || t.type in KEYWORD_TYPES) { + return advance().value + } + throw IllegalArgumentException(err("Expected element name after '.'")) + } + + /** + * Capture a parenthesized FHIRPath expression: consumes from the opening + * '(' through its matching ')', returning the inner text with spacing + * reconstructed (no space around '.', after '(', before ')' or ',', + * before a call's '(', or after '$'). + */ + private fun captureParenExpression(): String { + if (!match(TokenType.LPAREN)) { + throw IllegalArgumentException(err("Expected '(' to start expression")) + } + val sb = StringBuilder() + var depth = 1 + var prev: Token? = null + while (!isAtEnd()) { + val t = peek() + if (t.type == TokenType.RPAREN) { + depth-- + if (depth == 0) { advance(); break } + } + if (t.type == TokenType.LPAREN) depth++ + if (sb.isNotEmpty() && needSpace(prev, t)) sb.append(' ') + sb.append(if (t.type == TokenType.STRING) "'" + t.value + "'" else t.value) + prev = t + advance() + } + if (depth != 0) { + throw IllegalArgumentException(err("Unbalanced parentheses in expression")) + } + return sb.toString() + } + + private fun needSpace(prev: Token?, t: Token): Boolean { + if (prev == null) return false + if (t.type == TokenType.DOT || t.type == TokenType.COMMA || t.type == TokenType.RPAREN) return false + if (prev.type == TokenType.DOT || prev.type == TokenType.LPAREN) return false + if (t.type == TokenType.LPAREN && + (prev.type == TokenType.IDENTIFIER || prev.type in KEYWORD_TYPES)) return false + if (prev.value == "$") return false + return true + } + + private fun peekNext(): Token = + if (current + 1 < tokens.size) tokens[current + 1] else tokens[tokens.size - 1] + + companion object { + private val LIST_MODES = setOf("first", "not_first", "last", "not_last", "only_one") + private val KEYWORD_TYPES = setOf( + TokenType.MAP, TokenType.USES, TokenType.IMPORTS, TokenType.CONCEPTMAP, + TokenType.PREFIX, TokenType.GROUP, TokenType.INPUT, TokenType.RULE, + TokenType.WHERE, TokenType.CHECK, TokenType.LOG, TokenType.AS, + TokenType.ALIAS, TokenType.MODE ) } diff --git a/src/commonMain/kotlin/org/litlfred/fmlrunner/executor/FmlEngine.kt b/src/commonMain/kotlin/org/litlfred/fmlrunner/executor/FmlEngine.kt new file mode 100644 index 0000000..8599e42 --- /dev/null +++ b/src/commonMain/kotlin/org/litlfred/fmlrunner/executor/FmlEngine.kt @@ -0,0 +1,543 @@ +package org.litlfred.fmlrunner.executor + +import kotlinx.serialization.json.* +import org.litlfred.fmlrunner.types.* +import kotlin.random.Random + +/** + * StructureMap execution over generic JSON trees. + * + * Sources are read from immutable JsonElements (or from target nodes built + * earlier, e.g. a logical-model instance created by `create(...)` and then + * used as the source input of a dependent group). Targets are built in a + * mutable tree and serialized at the end. + * + * Unknown transforms and unresolvable groups/variables are hard errors — + * never silent no-ops. + */ +data class ValidationResult( + val valid: Boolean, + val errors: List = emptyList(), + val warnings: List = emptyList() +) + +class FmlEngine( + private val resolveMap: (String) -> StructureMap?, + private val resolveConceptMap: (String) -> JsonObject? = { null }, + private val resolveElementTypes: (String) -> Map? = { null }, + private val resolveLogicalTypeName: (String) -> String? = { null }, + private val resolveDisplay: (String, String) -> String? = { _, _ -> null } +) { + + // ---- mutable target tree ---- + + sealed class MNode + class MObj : MNode() { + /** StructureDefinition url of the type this node instantiates, when known. */ + var typeUrl: String? = null + /** Simple complex-type name (e.g. HumanName), when created or declared as one. */ + var typeName: String? = null + val fields = LinkedHashMap>() + fun append(name: String, node: MNode, replaceIfSingular: Boolean = false) { + val list = fields.getOrPut(name) { mutableListOf() } + // A computed re-write of a singular element replaces (last write wins, + // e.g. a default status then a translated one). Creation writes and + // known repeating elements always append. + if (replaceIfSingular && list.isNotEmpty() && name !in REPEATING_ELEMENTS) list.clear() + list.add(node) + } + } + class MPrim(val value: JsonPrimitive, val srcChoice: String? = null) : MNode() + + companion object { + /** FHIR repeating elements the corpus writes more than once per node. */ + val RESOURCE_TYPES = setOf( + "Patient", "RelatedPerson", "Observation", "Immunization", "AdverseEvent", + "Bundle", "Practitioner", "Encounter", "Condition", "Organization", + "Composition", "DiagnosticReport", "Specimen" + ) + val REPEATING_ELEMENTS = setOf( + "entry", "extension", "identifier", "name", "telecom", "address", + "given", "coding", "item", "answer", "contact", "performer", + "note", "reasonCode", "protocolApplied", "target", "contained" + ) + } + + private fun jsonToM(e: JsonElement): MNode = when (e) { + is JsonPrimitive -> MPrim(e) + is JsonObject -> MObj().also { o -> e.forEach { (k, v) -> + if (v is JsonArray) v.forEach { o.append(k, jsonToM(it)) } else o.append(k, jsonToM(v)) + } } + is JsonArray -> throw EngineError("Cannot bind a bare JSON array to a single node") + else -> throw EngineError("Unsupported JSON element") + } + + private fun mToJson(n: MNode): JsonElement? = when (n) { + is MPrim -> n.value + is MObj -> { + val o = buildMap { + for ((k, list) in n.fields) { + val items = list.mapNotNull { mToJson(it) } + if (items.isEmpty()) continue + // FHIR JSON requires arrays for repeating elements even + // when a single value is present + val repeating = k in REPEATING_ELEMENTS || + n.typeUrl?.let { resolveElementTypes(it)?.get(k) } + ?.substringAfter('|', "1") == "*" + put(k, if (items.size == 1 && !repeating) items[0] else JsonArray(items)) + } + } + if (o.isEmpty()) null else JsonObject(o) + } + } + + // ---- values & scopes ---- + + class EngineError(message: String) : Exception(message) + + private class Scope(val parent: Scope?) { + val vars = mutableMapOf() // JsonElement | MNode + fun lookup(name: String): Any? = vars[name] ?: parent?.lookup(name) + fun bind(name: String, v: Any) { vars[name] = v } + } + + /** Read child elements by name from a source-side value (choice-aware). */ + private fun readElement(v: Any, name: String): List { return when (v) { + // `.value` on a primitive is the primitive itself (FHIRPath's view of + // primitive types, e.g. `linkId.value`) + is JsonPrimitive -> if (name == "value") listOf(v) else emptyList() + is MPrim -> if (name == "value") listOf(v) else emptyList() + is JsonObject -> { + val exact = v[name] + val hits = mutableListOf() + if (exact != null) { + if (exact is JsonArray) hits.addAll(exact) else hits.add(exact) + } else { + // choice type: value[x] + val tagged = mutableListOf() + v.forEach { (k, e) -> + if (k.length > name.length && k.startsWith(name) && k[name.length].isUpperCase()) { + val suffix = k.substring(name.length) + val elems = if (e is JsonArray) e.toList() else listOf(e) + elems.forEach { el -> + tagged.add(if (el is JsonPrimitive) MPrim(el, suffix) else el) + } + } + } + return tagged + } + hits + } + is MObj -> { + val exact = v.fields[name] + if (exact != null) { + val declared = v.typeUrl?.let { resolveElementTypes(it)?.get(name) }?.substringBefore('|') + if (declared != null && declared.first().isLowerCase()) { + return exact.map { hit -> + val prim = when { + hit is MPrim -> hit.value + hit is MObj && hit.fields.size == 1 -> + (hit.fields["value"]?.singleOrNull() as? MPrim)?.value + else -> null + } + if (prim != null) MPrim(prim, declared.replaceFirstChar { c -> c.uppercase() }) else hit + } + } + if (declared != null) { + val childUrl = childTypeUrl(declared, v.typeUrl) + exact.forEach { hit -> if (hit is MObj && hit.typeUrl == null) hit.typeUrl = childUrl } + } + exact.toList() + } else v.fields.entries + .filter { it.key.length > name.length && it.key.startsWith(name) && it.key[name.length].isUpperCase() } + .flatMap { it.value } + } + else -> emptyList() + } } + + /** + * SD a declared child type resolves its own elements from: a URL names a + * nested model directly; a named datatype maps to its core SD; inline + * backbones (and primitives) stay on the parent's leaf-flattened map. + */ + private fun childTypeUrl(declared: String, parentUrl: String?): String? = when { + declared.startsWith("http") -> declared + declared == "BackboneElement" || declared == "Element" -> parentUrl + declared.first().isUpperCase() -> "http://hl7.org/fhir/StructureDefinition/$declared" + else -> parentUrl + } + + private fun typeNameOf(item: Any?): String? = when (item) { + is JsonObject -> (item["resourceType"] as? JsonPrimitive)?.contentOrNull + is MObj -> (item.fields["resourceType"]?.firstOrNull() as? MPrim)?.value?.contentOrNull + else -> null + } + + private fun primString(v: Any?): String? = when (v) { + is JsonPrimitive -> v.contentOrNull + is MPrim -> v.value.contentOrNull + is MObj -> (v.fields["value"]?.firstOrNull() as? MPrim)?.value?.contentOrNull + is JsonObject -> (v["value"] as? JsonPrimitive)?.contentOrNull + else -> null + } + + // ---- entry ---- + + fun execute(map: StructureMap, source: JsonElement): ExecutionResult { + return try { + val group = map.group.firstOrNull() ?: throw EngineError("Map has no groups") + val scope = Scope(null) + var targetRoot: MObj? = null + var sourceBound = false + for (input in group.input) { + when (input.mode) { + InputMode.SOURCE -> { + if (!sourceBound) { scope.bind(input.name, source); sourceBound = true } + else throw EngineError("Multiple source inputs on entry group '${group.name}'") + } + InputMode.TARGET -> { + val root = MObj() + // The input's declared type names either a uses alias or + // the last segment of a uses URL (both occur in WHO maps) + input.type?.let { alias -> + map.structure?.firstOrNull { + it.alias == alias || it.url.substringAfterLast('/') == alias + }?.url?.let { root.typeUrl = it } + } + resourceTypeForInput(map, input)?.let { root.append("resourceType", MPrim(JsonPrimitive(it))) } + ?: root.typeUrl?.let { u -> + resolveLogicalTypeName(u)?.let { root.append("resourceType", MPrim(JsonPrimitive(it))) } + } + if (targetRoot == null) targetRoot = root + scope.bind(input.name, root) + } + } + } + val root = targetRoot ?: throw EngineError("Entry group '${group.name}' has no target input") + executeRules(group.rule, map, scope) + val json = mToJson(root) ?: JsonObject(emptyMap()) + ExecutionResult(success = true, result = Json.encodeToString(JsonElement.serializer(), json)) + } catch (e: EngineError) { + ExecutionResult(success = false, errors = listOf(e.message ?: "engine error")) + } + } + + /** Entry target typed as a core FHIR resource gets its resourceType. */ + private fun resourceTypeForInput(map: StructureMap, input: StructureMapGroupInput): String? { + val type = input.type ?: return null + val url = map.structure?.firstOrNull { it.alias == type }?.url ?: return null + return if (url.startsWith("http://hl7.org/fhir/StructureDefinition/")) url.substringAfterLast('/') else null + } + + // ---- rules ---- + + private fun executeRules(rules: List, map: StructureMap, scope: Scope) { + for (rule in rules) executeRule(rule, map, scope) + } + + private fun executeRule(rule: StructureMapGroupRule, map: StructureMap, scope: Scope) { + val src = rule.source.firstOrNull() ?: throw EngineError("Rule '${rule.name}' has no source") + if (rule.source.size > 1) throw EngineError("Multi-source rules not supported (rule '${rule.name}')") + + val ctx = scope.lookup(src.context) ?: throw EngineError("Unknown source context '${src.context}'") + var items: List = if (src.element != null) readElement(ctx, src.element!!) else listOf(ctx) + + // Type cast (`entry.resource : Patient`) filters by resourceType + src.type?.let { t -> items = items.filter { typeNameOf(it) == t } } + + when (src.listMode) { + null -> {} + "first" -> items = items.take(1) + "last" -> items = items.takeLast(1) + "only_one" -> if (items.size > 1) throw EngineError("only_one matched ${items.size} (rule '${rule.name}')") + else -> throw EngineError("Unsupported listMode '${src.listMode}'") + } + src.condition?.let { cond -> + items = items.filter { candidate -> + val condScope = Scope(scope) + src.variable?.let { condScope.bind(it, candidate) } + evalCondition(cond, candidate, condScope) + } + } + + for (item in items) { + val ruleScope = Scope(scope) + src.variable?.let { ruleScope.bind(it, item) } + rule.target?.forEach { executeTarget(it, ruleScope) } + rule.rule?.let { executeRules(it, map, ruleScope) } + rule.dependent?.forEach { dep -> executeDependent(dep, map, ruleScope) } + } + } + + private fun executeDependent(dep: StructureMapGroupRuleDependent, map: StructureMap, scope: Scope) { + val found = resolveGroup(dep.name, map, mutableSetOf()) + ?: throw EngineError("Group '${dep.name}' not found in map or imports") + val (group, owningMap) = found + if (group.input.size != dep.variable.size) { + throw EngineError("Group '${dep.name}' expects ${group.input.size} args, got ${dep.variable.size}") + } + val callScope = Scope(null) + group.input.forEachIndexed { i, input -> + val arg = scope.lookup(dep.variable[i]) + ?: throw EngineError("Unknown variable '${dep.variable[i]}' passed to '${dep.name}'") + callScope.bind(input.name, arg) + } + executeRules(group.rule, owningMap, callScope) + } + + private fun resolveGroup( + name: String, map: StructureMap, visited: MutableSet + ): Pair? { + map.url?.let { if (!visited.add(it)) return null } + map.group.firstOrNull { it.name == name }?.let { return it to map } + map.import?.forEach { url -> + val imported = resolveMap(url) ?: throw EngineError("Imported map not registered: $url") + resolveGroup(name, imported, visited)?.let { return it } + } + return null + } + + // ---- targets ---- + + private fun executeTarget(t: StructureMapGroupRuleTarget, scope: Scope) { + val computed: MNode? = t.transform?.let { applyTransform(it, t.parameter ?: emptyList(), scope) } + + if (t.context != null) { + val node = scope.lookup(t.context!!) as? MObj + ?: throw EngineError("Target context '${t.context}' is not a target node") + if (t.element == null && t.transform == null) { + // Bare context target (`-> tgt` / `-> tgt as t`): names an + // existing node as this rule's target without mutating it + t.variable?.let { scope.bind(it, node) } + return + } + var element = t.element ?: throw EngineError("Target context '${t.context}' without element") + var placed = computed ?: MObj() + if (computed != null && isChoiceWrite(element, node)) { + val (suffix, unwrapped) = choiceSuffix(computed) + element += suffix + placed = unwrapped + } + val declaredEntry = node.typeUrl?.let { resolveElementTypes(it)?.get(t.element!!) } + val declaredCode = declaredEntry?.substringBefore('|') + val declaredMax = declaredEntry?.substringAfter('|', "?") + // A primitive cannot be coerced into a complex-declared element + // (e.g. integer into Quantity); the reference drops such writes + if (computed is MPrim && declaredCode != null && declaredCode.first().isUpperCase()) return + // Carry the declared type so the child's own elements resolve from + // the right SD (nested logical model, named datatype, or inline + // backbone falling back to the parent's leaf-flattened map) + if (placed is MObj && placed.typeUrl == null && declaredCode != null) { + placed.typeUrl = childTypeUrl(declaredCode, node.typeUrl) + } + // Declared max governs replace-vs-append even for computed writes + val replace = if (declaredMax == "*") false else (computed != null || declaredMax == "1") + node.append(element, placed, replaceIfSingular = replace) + t.variable?.let { scope.bind(it, placed) } + } else { + val v = computed ?: throw EngineError("Target with neither context nor transform") + t.variable?.let { scope.bind(it, v) } + } + } + + private fun applyTransform(name: String, params: List, scope: Scope): MNode { + fun arg(i: Int): TransformParameter = + params.getOrNull(i) ?: throw EngineError("Transform '$name' missing parameter $i") + fun argValue(i: Int): Any { + val p = arg(i) + return p.valueId?.let { scope.lookup(it) ?: throw EngineError("Unknown variable '$it' in '$name'") } + ?: p.valueString?.let { JsonPrimitive(it) } + ?: p.valueInteger?.let { JsonPrimitive(it) } + ?: p.valueDecimal?.let { JsonPrimitive(it) } + ?: p.valueBoolean?.let { JsonPrimitive(it) } + ?: throw EngineError("Empty parameter $i for '$name'") + } + + return when (name) { + "copy" -> when (val v = argValue(0)) { + is JsonElement -> jsonToM(v) + is MNode -> v // share the node: later writes through the variable alias the target + else -> throw EngineError("copy: unsupported value") + } + "create" -> MObj().also { o -> + val type = primString(argValue(0)) ?: throw EngineError("create: missing type") + if (type in RESOURCE_TYPES) { + o.append("resourceType", MPrim(JsonPrimitive(type))) + // core SD (when registered) supplies element cardinalities + o.typeUrl = "http://hl7.org/fhir/StructureDefinition/$type" + } + if (type.contains('/')) o.typeUrl = type + else if (type !in RESOURCE_TYPES && type.first().isUpperCase()) o.typeName = type + } + "append" -> MPrim(JsonPrimitive(params.indices.joinToString("") { i -> + primString(argValue(i)) ?: throw EngineError("append: parameter $i has no primitive value") + })) + "uuid" -> MPrim(JsonPrimitive(randomUuid())) + "c" -> MObj().also { o -> + val system = primString(argValue(0)) ?: "" + val code = primString(argValue(1)) ?: "" + o.append("system", MPrim(JsonPrimitive(system))) + o.append("code", MPrim(JsonPrimitive(code))) + // Display comes from terminology, matching the reference engine — + // the optional third argument is ignored (the reference drops + // displays it cannot resolve, e.g. unlicensed code systems). + resolveDisplay(system, code)?.let { o.append("display", MPrim(JsonPrimitive(it))) } + } + "translate" -> { + val src0 = argValue(0) + val code = primString(src0) + ?: readElement(src0, "code").firstOrNull()?.let { primString(it) } + ?: throw EngineError("translate: source has no code") + val cmUrl = primString(argValue(1)) ?: throw EngineError("translate: missing ConceptMap url") + val cm = resolveConceptMap(cmUrl) ?: throw EngineError("translate: ConceptMap not registered: $cmUrl") + val mode = if (params.size > 2) primString(argValue(2)) ?: "code" else "code" + val hit = translateCode(cm, code) + ?: throw EngineError("translate: no mapping for '$code' in $cmUrl") + when (mode) { + "code" -> MPrim(JsonPrimitive(hit.code)) + "display" -> MPrim(JsonPrimitive(hit.display ?: hit.code)) + "coding", "Coding" -> MObj().also { o -> + hit.system?.let { o.append("system", MPrim(JsonPrimitive(it))) } + o.append("code", MPrim(JsonPrimitive(hit.code))) + } + else -> throw EngineError("translate: unsupported output mode '$mode'") + } + } + "evaluate" -> { + val expr = primString(argValue(0)) ?: throw EngineError("evaluate: missing expression") + val v = evalPath(expr, null, scope).firstOrNull() + ?: throw EngineError("evaluate: '$expr' yielded nothing") + when (v) { is JsonElement -> jsonToM(v); is MNode -> v; else -> throw EngineError("evaluate: bad value") } + } + else -> throw EngineError("Unsupported transform '$name'") + } + } + + /** + * A write to one of these elements is a FHIR choice type ([x]) in the + * corpus's target resources: occurrence[x] on Immunization; value[x] on + * Extension (discriminated by the sibling url set in the same rule) and + * on resources like Observation (discriminated by resourceType). + */ + private fun isChoiceWrite(element: String, node: MObj): Boolean = when (element) { + "occurrence", "effective", "onset", "doseNumber", "seriesDoses" -> true + "value" -> node.fields.containsKey("url") || node.fields.containsKey("resourceType") + else -> false + } + + private val dateRe = Regex("\\d{4}(-\\d{2}(-\\d{2})?)?") + private val dateTimeRe = Regex("\\d{4}-\\d{2}-\\d{2}T.*") + + private fun choiceSuffix(v: MNode): Pair { + // provenance from the source's own value[x] key wins + (v as? MPrim)?.srcChoice?.let { return it to v } + if (v is MObj && v.fields.size == 1) { + (v.fields["value"]?.singleOrNull() as? MPrim)?.srcChoice?.let { tag -> + return tag to (v.fields["value"]!!.single()) + } + } + // created/declared complex-type name wins over shape heuristics + if (v is MObj && v.typeName != null) return v.typeName!! to v + // Quantity shape (value + unit) + if (v is MObj && v.fields.containsKey("value") && v.fields.containsKey("unit")) return "Quantity" to v + // Coding shape + if (v is MObj && v.fields.containsKey("code") && v.fields.containsKey("system")) return "Coding" to v + // primitive (possibly wrapped as {value: prim}) + val prim: JsonPrimitive? = when { + v is MPrim -> v.value + v is MObj && v.fields.size == 1 -> (v.fields["value"]?.singleOrNull() as? MPrim)?.value + else -> null + } + if (prim != null) { + val content = prim.contentOrNull ?: "" + val suffix = when { + prim.booleanOrNull != null && !prim.isString -> "Boolean" + prim.intOrNull != null && !prim.isString -> "Integer" + prim.doubleOrNull != null && !prim.isString -> "Decimal" + dateTimeRe.matches(content) -> "DateTime" + dateRe.matches(content) -> "Date" + else -> "String" + } + return suffix to MPrim(prim) + } + return "String" to v + } + + private data class TranslateHit(val code: String, val system: String?, val display: String?) + + private fun translateCode(cm: JsonObject, code: String): TranslateHit? { + val groups = cm["group"] as? JsonArray ?: return null + for (g in groups) { + val go = g as? JsonObject ?: continue + val elements = go["element"] as? JsonArray ?: continue + for (el in elements) { + val o = el as? JsonObject ?: continue + if ((o["code"] as? JsonPrimitive)?.contentOrNull == code) { + val tgt = (o["target"] as? JsonArray)?.firstOrNull() as? JsonObject ?: continue + val tCode = (tgt["code"] as? JsonPrimitive)?.contentOrNull ?: continue + return TranslateHit( + code = tCode, + system = (go["target"] as? JsonPrimitive)?.contentOrNull, + display = (tgt["display"] as? JsonPrimitive)?.contentOrNull + ) + } + } + } + return null + } + + // ---- condition evaluation (corpus-scoped FHIRPath subset) ---- + + /** Handles `path = 'literal'` and bare `path` (existence). */ + private fun evalCondition(expr: String, focus: Any, scope: Scope): Boolean { + // membership: path in ('a', 'b') + Regex("^(.*?)\\s+in\\s*\\((.*)\\)\\s*$").find(expr)?.let { m -> + val values = Regex("'([^']*)'").findAll(m.groupValues[2]).map { it.groupValues[1] }.toSet() + return evalPath(m.groupValues[1].trim(), focus, scope).any { primString(it) in values } + } + // conjunction: A and B (top-level; corpus conditions have no nesting) + if (expr.contains(" and ")) { + return expr.split(" and ").all { evalCondition(it.trim(), focus, scope) } + } + // existence: path.exists() / path.exists().not() + val stripped = expr.replace(" ", "") + if (stripped.endsWith(".exists().not()")) { + return evalPath(stripped.removeSuffix(".exists().not()"), focus, scope).isEmpty() + } + if (stripped.endsWith(".exists()")) { + return evalPath(stripped.removeSuffix(".exists()"), focus, scope).isNotEmpty() + } + val eq = expr.split('=', limit = 2) + return if (eq.size == 2) { + val expected = eq[1].trim().removeSurrounding("'") + evalPath(eq[0].trim(), focus, scope).any { primString(it) == expected } + } else { + evalPath(expr.trim(), focus, scope).isNotEmpty() + } + } + + private fun evalPath(path: String, focus: Any?, scope: Scope): List { + val segments = path.split('.').map { it.trim() } + if (segments.isEmpty()) return emptyList() + var current: List = + scope.lookup(segments[0])?.let { listOf(it) } + ?: focus?.let { f -> + val first = readElement(f, segments[0]) + if (first.isEmpty() && segments.size == 1) return emptyList() else return@let first + } + ?: return emptyList() + val startIdx = if (scope.lookup(segments[0]) != null) 1 else 1 + for (i in startIdx until segments.size) { + current = current.flatMap { readElement(it, segments[i]) } + } + return current + } + + private fun randomUuid(): String { + val b = Random.nextBytes(16) + b[6] = ((b[6].toInt() and 0x0f) or 0x40).toByte() + b[8] = ((b[8].toInt() and 0x3f) or 0x80).toByte() + val hex = b.joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') } + return "${hex.substring(0,8)}-${hex.substring(8,12)}-${hex.substring(12,16)}-${hex.substring(16,20)}-${hex.substring(20)}" + } +} diff --git a/src/commonMain/kotlin/org/litlfred/fmlrunner/executor/StructureMapExecutor.kt b/src/commonMain/kotlin/org/litlfred/fmlrunner/executor/StructureMapExecutor.kt deleted file mode 100644 index d6d1335..0000000 --- a/src/commonMain/kotlin/org/litlfred/fmlrunner/executor/StructureMapExecutor.kt +++ /dev/null @@ -1,304 +0,0 @@ -package org.litlfred.fmlrunner.executor - -import org.litlfred.fmlrunner.types.* -import kotlinx.serialization.json.* -// kotlin-fhirpath for FHIRPath evaluation from https://github.com/jingtang10/kotlin-fhirpath -// Note: JitPack access still blocked - will integrate when network firewall allows -// import com.github.jingtang10.kotlin.fhirpath.FHIRPathEngine -// import com.github.jingtang10.kotlin.fhirpath.FHIRPathEngineFactory - -/** - * StructureMap execution engine - executes StructureMaps on input data - * Ready for kotlin-fhirpath library integration when network access allows - */ -class StructureMapExecutor { - // kotlin-fhirpath engine integration ready when network allows - // private val fhirPathEngine: FHIRPathEngine = FHIRPathEngineFactory.create() - - /** - * Execute a StructureMap on input content - */ - fun execute(structureMap: StructureMap, inputContent: String, options: ExecutionOptions = ExecutionOptions()): ExecutionResult { - return try { - // Basic validation - if (structureMap.group.isEmpty()) { - return ExecutionResult( - success = false, - errors = listOf("StructureMap must have at least one group") - ) - } - - // Parse input JSON - val json = Json { ignoreUnknownKeys = true } - val inputData = json.parseToJsonElement(inputContent) - - // Execute the main group (first group by convention) - val mainGroup = structureMap.group.first() - val result = executeGroup(mainGroup, inputData, options) - - ExecutionResult( - success = true, - result = Json.encodeToString(JsonElement.serializer(), result) - ) - } catch (e: Exception) { - ExecutionResult( - success = false, - errors = listOf("Execution failed: ${e.message}") - ) - } - } - - /** - * Execute a StructureMap group - */ - private fun executeGroup(group: StructureMapGroup, inputData: JsonElement, options: ExecutionOptions): JsonElement { - // Create target data structure - val targetData = JsonObject(emptyMap()).toMutableMap() - - // Find source and target inputs - val sourceInput = group.input.find { it.mode == InputMode.SOURCE } - val targetInput = group.input.find { it.mode == InputMode.TARGET } - - if (sourceInput == null || targetInput == null) { - throw IllegalArgumentException("Group must have both source and target inputs") - } - - // Create execution context - val context = ExecutionContext( - sourceData = inputData, - targetData = targetData, - sourceContext = sourceInput.name, - targetContext = targetInput.name - ) - - // Execute rules - for (rule in group.rule) { - executeRule(rule, context, options) - } - - return JsonObject(targetData) - } - - /** - * Execute a single rule - */ - private fun executeRule(rule: StructureMapGroupRule, context: ExecutionContext, options: ExecutionOptions) { - // Process each source - for (source in rule.source) { - val sourceValue = extractSourceValue(source, context) - - // Process targets if available - rule.target?.forEach { target -> - applyTargetMapping(source, target, sourceValue, context, options) - } - } - } - - /** - * Extract value from source - */ - private fun extractSourceValue(source: StructureMapGroupRuleSource, context: ExecutionContext): JsonElement? { - return try { - when { - source.context == context.sourceContext && source.element != null -> { - // Extract from source data - extractElementValue(context.sourceData, source.element) - } - source.context == context.sourceContext && source.element == null -> { - // Use entire source - context.sourceData - } - else -> null - } - } catch (e: Exception) { - null - } - } - - /** - * Apply target mapping - */ - private fun applyTargetMapping( - source: StructureMapGroupRuleSource, - target: StructureMapGroupRuleTarget, - sourceValue: JsonElement?, - context: ExecutionContext, - options: ExecutionOptions - ) { - if (sourceValue == null) return - - val targetElement = target.element ?: return - - // Apply transformation if specified - val transformedValue = if (target.transform != null) { - applyTransform(target.transform, sourceValue, target.parameter) - } else { - sourceValue - } - - // Set target value - setTargetValue(context, targetElement, transformedValue) - } - - /** - * Extract element value from JSON - */ - private fun extractElementValue(jsonElement: JsonElement, elementPath: String): JsonElement? { - return when (jsonElement) { - is JsonObject -> jsonElement[elementPath] - is JsonArray -> { - // Handle array access - val index = elementPath.toIntOrNull() - if (index != null && index < jsonElement.size) { - jsonElement[index] - } else null - } - else -> null - } - } - - /** - * Set target value in context - */ - private fun setTargetValue(context: ExecutionContext, elementPath: String, value: JsonElement) { - context.targetData[elementPath] = value - } - - /** - * Apply transformation function - */ - private fun applyTransform(transform: String, value: JsonElement, parameters: List?): JsonElement { - return when (transform.lowercase()) { - "copy" -> value - "create" -> { - // Create new object/value based on parameters - parameters?.firstOrNull()?.let { type -> - when (type.lowercase()) { - "string" -> JsonPrimitive("") - "integer" -> JsonPrimitive(0) - "boolean" -> JsonPrimitive(false) - else -> JsonObject(emptyMap()) - } - } ?: value - } - "cast" -> { - // Type casting - parameters?.firstOrNull()?.let { targetType -> - castValue(value, targetType) - } ?: value - } - "evaluate" -> { - // Simple FHIRPath-like evaluation (basic implementation) - parameters?.firstOrNull()?.let { expression -> - evaluateExpression(value, expression) - } ?: value - } - else -> value // Unknown transform, return original value - } - } - - /** - * Cast value to target type - */ - private fun castValue(value: JsonElement, targetType: String): JsonElement { - return when (targetType.lowercase()) { - "string" -> JsonPrimitive(value.toString().trim('"')) - "integer" -> { - val stringValue = if (value is JsonPrimitive) value.content else value.toString() - JsonPrimitive(stringValue.toIntOrNull() ?: 0) - } - "boolean" -> { - val stringValue = if (value is JsonPrimitive) value.content else value.toString() - JsonPrimitive(stringValue.toBooleanStrictOrNull() ?: false) - } - else -> value - } - } - - /** - * Expression evaluation using kotlin-fhirpath engine - */ - private fun evaluateExpression(context: JsonElement, expression: String): JsonElement { - return try { - when (expression) { - "true" -> JsonPrimitive(true) - "false" -> JsonPrimitive(false) - else -> { - // Property access for expressions - if (context is JsonObject && context.containsKey(expression)) { - context[expression] ?: JsonNull - } else { - JsonNull - } - } - } - } catch (e: Exception) { - JsonNull - } - } - - /** - * Validate StructureMap structure - */ - fun validateStructureMap(structureMap: StructureMap): ValidationResult { - val errors = mutableListOf() - - if (structureMap.group.isEmpty()) { - errors.add("StructureMap must have at least one group") - } - - for ((index, group) in structureMap.group.withIndex()) { - if (group.name.isBlank()) { - errors.add("Group $index must have a name") - } - - if (group.input.isEmpty()) { - errors.add("Group '${group.name}' must have at least one input") - } - - val sourceInputs = group.input.filter { it.mode == InputMode.SOURCE } - val targetInputs = group.input.filter { it.mode == InputMode.TARGET } - - if (sourceInputs.isEmpty()) { - errors.add("Group '${group.name}' must have at least one source input") - } - - if (targetInputs.isEmpty()) { - errors.add("Group '${group.name}' must have at least one target input") - } - - if (group.rule.isEmpty()) { - errors.add("Group '${group.name}' must have at least one rule") - } - - for ((ruleIndex, rule) in group.rule.withIndex()) { - if (rule.source.isEmpty()) { - errors.add("Rule $ruleIndex in group '${group.name}' must have at least one source") - } - } - } - - return ValidationResult( - valid = errors.isEmpty(), - errors = errors - ) - } -} - -/** - * Execution context for rule processing - */ -private data class ExecutionContext( - val sourceData: JsonElement, - val targetData: MutableMap, - val sourceContext: String, - val targetContext: String -) - -/** - * Validation result - */ -data class ValidationResult( - val valid: Boolean, - val errors: List -) \ No newline at end of file diff --git a/src/commonMain/kotlin/org/litlfred/fmlrunner/types/FhirTypes.kt b/src/commonMain/kotlin/org/litlfred/fmlrunner/types/FhirTypes.kt index 3975c6f..677d1ab 100644 --- a/src/commonMain/kotlin/org/litlfred/fmlrunner/types/FhirTypes.kt +++ b/src/commonMain/kotlin/org/litlfred/fmlrunner/types/FhirTypes.kt @@ -15,9 +15,24 @@ data class StructureMap( val status: StructureMapStatus, val experimental: Boolean? = null, val description: String? = null, + val structure: List? = null, + val import: List? = null, val group: List ) +@Serializable +data class StructureMapStructure( + val url: String, + val mode: StructureMapModelMode, + val alias: String? = null, + val documentation: String? = null +) + +@Serializable +enum class StructureMapModelMode { + SOURCE, QUERIED, TARGET, PRODUCED +} + @Serializable enum class StructureMapStatus { DRAFT, ACTIVE, RETIRED, UNKNOWN @@ -55,9 +70,17 @@ data class StructureMapGroupRule( val name: String? = null, val source: List, val target: List? = null, + val rule: List? = null, + val dependent: List? = null, val documentation: String? = null ) +@Serializable +data class StructureMapGroupRuleDependent( + val name: String, + val variable: List +) + @Serializable data class StructureMapGroupRuleSource( val context: String, @@ -65,7 +88,11 @@ data class StructureMapGroupRuleSource( val variable: String? = null, val type: String? = null, val min: Int? = null, - val max: String? = null + val max: String? = null, + val listMode: String? = null, + val condition: String? = null, + val check: String? = null, + val logMessage: String? = null ) @Serializable @@ -75,7 +102,21 @@ data class StructureMapGroupRuleTarget( val element: String? = null, val variable: String? = null, val transform: String? = null, - val parameter: List? = null + val parameter: List? = null, + val listMode: List? = null +) + +/** + * Transform parameter, FHIR R4 value[x] shape: valueId carries a variable + * reference, the others carry literals. + */ +@Serializable +data class TransformParameter( + val valueId: String? = null, + val valueString: String? = null, + val valueBoolean: Boolean? = null, + val valueInteger: Int? = null, + val valueDecimal: Double? = null ) @Serializable diff --git a/src/jvmTest/kotlin/org/litlfred/fmlrunner/FmlParseSweep.kt b/src/jvmTest/kotlin/org/litlfred/fmlrunner/FmlParseSweep.kt new file mode 100644 index 0000000..ce1dd76 --- /dev/null +++ b/src/jvmTest/kotlin/org/litlfred/fmlrunner/FmlParseSweep.kt @@ -0,0 +1,62 @@ +package org.litlfred.fmlrunner + +import org.litlfred.fmlrunner.compiler.FmlCompiler +import java.io.File +import kotlin.test.Test + +/** + * Parse-only sweep over every .fml file WHO publishes (org-wide census). + * Diagnostic, not pass/fail: prints a per-file ledger plus a failure tally + * grouped by error signature — the grammar backlog, prioritized by frequency. + * + * Point FML_SWEEP_DIR at a directory of /.fml subdirectories. + */ +class FmlParseSweep { + + @Test + fun sweep() { + val root = File( + System.getenv("FML_SWEEP_DIR") + ?: return println("SWEEP SKIPPED: FML_SWEEP_DIR not set") + ) + val files = root.walkTopDown() + .filter { it.extension == "fml" } + .sortedBy { it.relativeTo(root).path } + .toList() + val compiler = FmlCompiler() + var ok = 0 + val failures = mutableListOf>() + for (f in files) { + val rel = f.relativeTo(root).path + val r = try { + compiler.compile(f.readText()) + } catch (e: Exception) { + failures += rel to "EXCEPTION ${e.message}" + println("SWEEP FAIL $rel :: EXCEPTION ${e.message}") + continue + } + if (r.success && r.structureMap != null) { + ok++ + println("SWEEP OK $rel (${r.structureMap!!.group.size} groups)") + } else { + val err = r.errors.firstOrNull() ?: "no error message" + failures += rel to err + println("SWEEP FAIL $rel :: $err") + } + } + println("SWEEP score: $ok/${files.size}") + // Tally by error signature: strip file-specific line/token detail so + // identical grammar gaps group together. + val signature = { e: String -> + e.replace(Regex("\\(line \\d+, near '[^']*'\\)"), "") + .replace(Regex("'[^']*'"), "'…'") + .trim() + } + failures.groupBy { signature(it.second) } + .entries.sortedByDescending { it.value.size } + .forEach { (sig, hits) -> + println("SWEEP TALLY ${hits.size}x :: $sig") + hits.forEach { println("SWEEP - ${it.first}") } + } + } +} diff --git a/src/jvmTest/kotlin/org/litlfred/fmlrunner/ImmzConformanceSpike.kt b/src/jvmTest/kotlin/org/litlfred/fmlrunner/ImmzConformanceSpike.kt new file mode 100644 index 0000000..220acd5 --- /dev/null +++ b/src/jvmTest/kotlin/org/litlfred/fmlrunner/ImmzConformanceSpike.kt @@ -0,0 +1,130 @@ +package org.litlfred.fmlrunner + +import kotlinx.serialization.json.* +import java.io.File +import kotlin.test.Test + +/** + * Conformance spike: feed fmlrunner WHO smart-immunizations content verbatim + * and score it against the reference-engine oracles + * (github.com/dhes/fmlrunner-conformance). Diagnostic, not pass/fail: + * prints a per-fixture ledger. + */ +class ImmzConformanceSpike { + + // Clone github.com/dhes/fmlrunner-conformance (CC0) and point + // FMLRUNNER_CONFORMANCE_DIR at it; the test skips when absent. + private val fixturesDir = File( + System.getenv("FMLRUNNER_CONFORMANCE_DIR") + ?: File(System.getProperty("user.home"), "projects/fmlrunner-conformance").path + ) + private val mapsDir = File(fixturesDir, "maps") + + private fun loadRunner(): FmlRunner { + val runner = FmlRunner() + mapsDir.listFiles { f -> f.extension == "fml" }?.sortedBy { it.name }?.forEach { f -> + val r = runner.compileFml(f.readText()) + if (r.success && r.structureMap != null) runner.registerStructureMap(r.structureMap!!) + else println("COMPILE FAIL ${f.name}: ${r.errors.take(2)}") + } + File(fixturesDir, "package").listFiles { f -> f.name.startsWith("ConceptMap-") }?.forEach { + runner.registerConceptMap(it.readText()) + } + File(fixturesDir, "package").listFiles { f -> f.name.startsWith("StructureDefinition-") }?.forEach { + runner.registerStructureDefinition(it.readText()) + } + File(fixturesDir, "package").listFiles { f -> f.name.startsWith("CodeSystem-") }?.forEach { + runner.registerCodeSystem(it.readText()) + } + // HL7 terminology CodeSystems the reference engine resolves via its loaded + // terminology packages (from the local FHIR package cache) + val term = File(System.getProperty("user.home"), ".fhir/packages/hl7.terminology.r4#6.2.0/package") + if (!term.isDirectory) { + println("WARN: $term missing — display lookups will diff (fetch it by running validator_cli once)") + } + term.listFiles { f -> f.name.startsWith("CodeSystem-") }?.forEach { + runner.registerCodeSystem(it.readText()) + } + return runner + } + + private fun mapUrlFor(family: String): String { + val name = if (family == "C") "IMMZ.C4.QRToPatient" else "IMMZ.$family.QRToBundle" + return "http://smart.who.int/immunizations/StructureMap/$name" + } + + @Test + fun scoreAllFixtures() { + if (!fixturesDir.isDirectory) { + return println("SKIPPED: conformance corpus not found at $fixturesDir " + + "(clone github.com/dhes/fmlrunner-conformance or set FMLRUNNER_CONFORMANCE_DIR)") + } + val runner = loadRunner() + println("=== Conformance score: 33 fixtures vs oracle ===") + var match = 0; var diff = 0; var error = 0 + File(fixturesDir, "package/example").listFiles { f -> + f.name.startsWith("QuestionnaireResponse-Example.IMMZ.") + }?.sortedBy { it.name }?.forEach { qrFile -> + val short = qrFile.name.removePrefix("QuestionnaireResponse-Example.IMMZ.").removeSuffix(".json") + val family = short.substringBefore('.') + val mapName = if (family == "C") "IMMZ.C4.QRToPatient" else "IMMZ.$family.QRToBundle" + val oracleFile = File(fixturesDir, "oracle/${mapName}__${short}.json") + val exec = runner.executeStructureMap(mapUrlFor(family), qrFile.readText()) + if (!exec.success || exec.result == null) { + error++ + println("ERROR $short: ${exec.errors.firstOrNull()}") + return@forEach + } + val ours = normalize(Json.parseToJsonElement(exec.result!!)) + val oracle = normalize(Json.parseToJsonElement(oracleFile.readText())) + val divergence = firstDiff(oracle, ours, "$") + if (divergence == null) { match++; println("MATCH $short") } + else { diff++; println("DIFF $short at $divergence") } + } + println("== score: $match match, $diff diff, $error error / 33") + } + + // --- normalization: sequence-number UUIDs, unwrap singleton arrays --- + + private val uuidRe = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + private fun normalize(e: JsonElement): JsonElement { + val compact = Json.encodeToString(JsonElement.serializer(), e) + val seen = LinkedHashMap() + val renumbered = uuidRe.replace(compact) { m -> + seen.getOrPut(m.value.lowercase()) { "uuid-${seen.size + 1}" } + } + return unwrap(Json.parseToJsonElement(renumbered)) + } + + private fun unwrap(e: JsonElement): JsonElement = when (e) { + is JsonArray -> if (e.size == 1) unwrap(e[0]) else JsonArray(e.map { unwrap(it) }) + is JsonObject -> JsonObject(e.mapValues { unwrap(it.value) }) + else -> e + } + + private fun firstDiff(a: JsonElement, b: JsonElement, path: String): String? { + if (a::class != b::class) return "$path (kind: oracle=${a::class.simpleName} ours=${b::class.simpleName})" + when (a) { + is JsonObject -> { + b as JsonObject + for (k in a.keys + b.keys) { + val av = a[k]; val bv = b[k] + if (av == null) return "$path.$k (extra in ours: ${short(bv)})" + if (bv == null) return "$path.$k (missing in ours; oracle=${short(av)})" + firstDiff(av, bv, "$path.$k")?.let { return it } + } + } + is JsonArray -> { + b as JsonArray + if (a.size != b.size) return "$path (size: oracle=${a.size} ours=${b.size})" + a.indices.forEach { i -> firstDiff(a[i], b[i], "$path[$i]")?.let { return it } } + } + else -> if (a != b) return "$path (oracle=${short(a)} ours=${short(b)})" + } + return null + } + + private fun short(e: JsonElement?): String = + (e?.toString() ?: "null").let { if (it.length > 60) it.take(60) + "…" else it } +} diff --git a/src/jvmTest/kotlin/org/litlfred/fmlrunner/NcsaConformanceSpike.kt b/src/jvmTest/kotlin/org/litlfred/fmlrunner/NcsaConformanceSpike.kt new file mode 100644 index 0000000..ee4f31f --- /dev/null +++ b/src/jvmTest/kotlin/org/litlfred/fmlrunner/NcsaConformanceSpike.kt @@ -0,0 +1,164 @@ +package org.litlfred.fmlrunner + +import kotlinx.serialization.json.* +import java.io.File +import kotlin.test.Test + +/** + * Conformance spike for the CC-BY-NC-SA WHO corpus (ddcc, smart-ot): + * 14 reference-engine oracles in ~/projects/fmlrunner-conformance-ncsa. + * Diagnostic, not pass/fail: prints a per-fixture ledger. + */ +class NcsaConformanceSpike { + + // This corpus is not published (CC BY-NC-SA content); the test skips + // when it is absent. Set FMLRUNNER_CONFORMANCE_NCSA_DIR to point at it. + private val home = File(System.getProperty("user.home")) + private val corpus = File( + System.getenv("FMLRUNNER_CONFORMANCE_NCSA_DIR") + ?: File(home, "projects/fmlrunner-conformance-ncsa").path + ) + + private fun registerPackage(runner: FmlRunner, dir: File) { + dir.listFiles { f -> f.name.startsWith("ConceptMap-") }?.forEach { + runner.registerConceptMap(it.readText()) + } + dir.listFiles { f -> f.name.startsWith("StructureDefinition-") }?.forEach { + runner.registerStructureDefinition(it.readText()) + } + dir.listFiles { f -> f.name.startsWith("CodeSystem-") }?.forEach { + runner.registerCodeSystem(it.readText()) + } + } + + private fun loadRunner(): FmlRunner { + val runner = FmlRunner() + for (repo in listOf("ddcc", "smart-ot")) { + File(corpus, "$repo/maps").listFiles { f -> f.extension == "fml" } + ?.sortedBy { it.name }?.forEach { f -> + val r = runner.compileFml(f.readText()) + if (r.success && r.structureMap != null) runner.registerStructureMap(r.structureMap!!) + else println("NCSA COMPILE FAIL ${f.name}: ${r.errors.take(1)}") + } + registerPackage(runner, File(corpus, "$repo/package")) + } + registerPackage(runner, File(home, ".fhir/packages/hl7.fhir.uv.shc-vaccination#1.0.0/package")) + // core R4 SDs supply element cardinalities (replace-vs-append on + // singular resource elements like Encounter.subject) + File(home, ".fhir/packages/hl7.fhir.r4.core#4.0.1/package") + .listFiles { f -> f.name.startsWith("StructureDefinition-") }?.forEach { + runner.registerStructureDefinition(it.readText()) + } + File(home, ".fhir/packages/hl7.terminology.r4#6.2.0/package") + .listFiles { f -> f.name.startsWith("CodeSystem-") }?.forEach { + runner.registerCodeSystem(it.readText()) + } + return runner + } + + private data class Fixture(val label: String, val mapUrl: String, val input: File, val oracle: File) + + private fun fixtures(): List { + val list = mutableListOf() + val otBase = "http://worldhealthorganization.github.io/smart-ot/StructureMap" + val ddBase = "http://smart.who.int/ddcc/StructureMap" + val otQr = File(corpus, "smart-ot/package/example/QuestionnaireResponse-Response1.json") + for (m in listOf("MeaslesQuestionnaireToLogicalModel", "MeaslesQuestionnaireToResources")) { + list.add(Fixture("ot/$m", "$otBase/$m", otQr, File(corpus, "oracle/smart-ot/${m}__Response1.json"))) + } + File(corpus, "ddcc/package/example").listFiles { f -> + f.name.startsWith("QuestionnaireResponse-DDCCVSQuestionnaireResponse") + }?.sortedBy { it.name }?.forEach { f -> + val short = f.name.removePrefix("QuestionnaireResponse-DDCCVSQuestionnaireResponse").removeSuffix(".json") + list.add(Fixture("ddcc/QResp-$short", "$ddBase/QRespToVSCoreDataSet", f, + File(corpus, "oracle/ddcc/QRespToVSCoreDataSet__${short}.json"))) + } + File(corpus, "ddcc/package/example").listFiles { f -> + f.name.startsWith("Bundle-Example") + }?.sortedBy { it.name }?.forEach { f -> + val short = f.name.removePrefix("Bundle-Example").removePrefix("-").removeSuffix(".json") + list.add(Fixture("ddcc/toSHC-$short", "$ddBase/DDCCtoSHC", f, + File(corpus, "oracle/ddcc/DDCCtoSHC__${short}.json"))) + } + return list + } + + @Test + fun scoreAllFixtures() { + if (!corpus.isDirectory) { + return println("NCSA SKIPPED: corpus not found at $corpus (local-only, license-separated)") + } + val runner = loadRunner() + val all = fixtures() + println("NCSA === Conformance score: ${all.size} fixtures vs oracle ===") + var match = 0; var diff = 0; var error = 0 + for (fx in all) { + val exec = try { + runner.executeStructureMap(fx.mapUrl, fx.input.readText()) + } catch (e: Exception) { + error++; println("NCSA ERROR ${fx.label}: EXCEPTION ${e.message?.take(160)}"); continue + } + if (!exec.success || exec.result == null) { + error++ + println("NCSA ERROR ${fx.label}: ${exec.errors.firstOrNull()?.take(160)}") + continue + } + val ours = normalize(Json.parseToJsonElement(exec.result!!)) + val oracle = normalize(Json.parseToJsonElement(fx.oracle.readText())) + val divergence = firstDiff(oracle, ours, "$") + if (divergence == null) { match++; println("NCSA MATCH ${fx.label}") } + else { diff++; println("NCSA DIFF ${fx.label} at $divergence") } + } + println("NCSA == score: $match match, $diff diff, $error error / ${all.size}") + } + + // --- normalization: sequence-number UUIDs, unwrap singleton arrays --- + + private val uuidRe = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + private fun normalize(e: JsonElement): JsonElement { + val compact = Json.encodeToString(JsonElement.serializer(), e) + val seen = LinkedHashMap() + // Number uuids by entry identity (fullUrl order) first, so field + // serialization order inside a resource cannot shift the numbering; + // any remaining uuids number by first appearance. + Regex("\"fullUrl\":\"urn:uuid:(${uuidRe.pattern})\"").findAll(compact).forEach { m -> + seen.getOrPut(m.groupValues[1].lowercase()) { "uuid-${seen.size + 1}" } + } + val renumbered = uuidRe.replace(compact) { m -> + seen.getOrPut(m.value.lowercase()) { "uuid-${seen.size + 1}" } + } + return unwrap(Json.parseToJsonElement(renumbered)) + } + + private fun unwrap(e: JsonElement): JsonElement = when (e) { + is JsonArray -> if (e.size == 1) unwrap(e[0]) else JsonArray(e.map { unwrap(it) }) + is JsonObject -> JsonObject(e.mapValues { unwrap(it.value) }) + else -> e + } + + private fun firstDiff(a: JsonElement, b: JsonElement, path: String): String? { + if (a::class != b::class) return "$path (kind: oracle=${a::class.simpleName} ours=${b::class.simpleName})" + when (a) { + is JsonObject -> { + b as JsonObject + for (k in a.keys + b.keys) { + val av = a[k]; val bv = b[k] + if (av == null) return "$path.$k (extra in ours: ${short(bv)})" + if (bv == null) return "$path.$k (missing in ours; oracle=${short(av)})" + firstDiff(av, bv, "$path.$k")?.let { return it } + } + } + is JsonArray -> { + b as JsonArray + if (a.size != b.size) return "$path (size: oracle=${a.size} ours=${b.size})" + a.indices.forEach { i -> firstDiff(a[i], b[i], "$path[$i]")?.let { return it } } + } + else -> if (a != b) return "$path (oracle=${short(a)} ours=${short(b)})" + } + return null + } + + private fun short(e: JsonElement?): String = + (e?.toString() ?: "null").let { if (it.length > 60) it.take(60) + "…" else it } +} diff --git a/src/wasmJsMain/kotlin/org/litlfred/fmlrunner/Platform.kt b/src/wasmJsMain/kotlin/org/litlfred/fmlrunner/Platform.kt new file mode 100644 index 0000000..0c3bded --- /dev/null +++ b/src/wasmJsMain/kotlin/org/litlfred/fmlrunner/Platform.kt @@ -0,0 +1,13 @@ +package org.litlfred.fmlrunner + +/** + * wasmJs platform-specific implementations + */ +actual class PlatformLogger { + actual fun log(level: String, message: String, data: Any?) { + val logMessage = "${level.uppercase()}: $message" + println("$logMessage ${data ?: ""}") + } +} + +actual fun getPlatformName(): String = "wasmJs"