diff --git a/.gitignore b/.gitignore
index 84746631..e25a829e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ docpath.txt
*.log
*.log.*
+LOG_FILE_IS_UNDEFINED
.idea
*.iml
*.vsix
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 138587e7..bdbfe7fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,30 @@
## v4.5.0
-Date: 2026-04-08
+Date: 2026-04-15
+
+### Execute CQL
+
+Adds a JSON-RPC–based Execute CQL command (`org.opencds.cqf.cql.ls.executeCql`) that
+replaces the previous CLI-based approach.
+
+* Accepts a structured `ExecuteCqlRequest` with per-library FHIR model paths, terminology URI,
+ context, and user-defined parameters
+* Evaluates all test cases for a library in a single batch (compile once, evaluate per patient)
+* `CqlEvaluator` — new evaluator that builds a shared engine per batch and runs each patient context
+* `DelegatingRepository` — mutable repository wrapper that swaps the patient bundle between evaluations
+* `ExecuteCqlCommandContribution` — registers the command and dispatches to `CqlEvaluator`
+* Removed `CliCommand` and `CqlCommand` (picocli CLI) — no longer needed
+* Returns structured JSON: expression results, server-side logs, and used-default-parameter metadata
+
+### Compilation Cache + Surgical Invalidation
+
+* `CqlCompilationManager` now caches compiled `CqlCompiler` results per source URI
+* Reverse dependency index tracks which URIs depend on each library identifier
+* `invalidate(uri)` evicts the URI and all dependents from the cache
+* `DiagnosticsService.didChangeWatchedFiles` triggers surgical invalidation on `.cql` file changes
+* `IgStandardRepository` adds a `typeResourceCache` — caches directory scans per resource
+ type/compartment combination, eliminating redundant filesystem walks across patients in a batch
### Module Consolidation
diff --git a/ls/server/pom.xml b/ls/server/pom.xml
index 33574115..8d9dc588 100644
--- a/ls/server/pom.xml
+++ b/ls/server/pom.xml
@@ -12,7 +12,7 @@
org.opencds.cqf.cql.ls
cql-ls
- 4.5.0-SNAPSHOT
+ 4.5.0
../../pom.xml
@@ -88,11 +88,6 @@
eventbus-java
-
- info.picocli
- picocli
-
-
jakarta.annotation
@@ -153,31 +148,17 @@
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+
+ true
+
+
-
- org.jetbrains.kotlin
- kotlin-maven-plugin
-
-
- kapt
- kapt
-
-
- ${project.basedir}/src/main/kotlin
-
-
-
- info.picocli
- picocli-codegen
- ${picocli.version}
-
-
-
-
-
-
org.apache.maven.plugins
maven-compiler-plugin
@@ -192,13 +173,6 @@
-Werror
-implicit:none
-
-
- info.picocli
- picocli-codegen
- ${picocli.version}
-
-
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CliCommand.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CliCommand.kt
deleted file mode 100644
index b7c08de8..00000000
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CliCommand.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.opencds.cqf.cql.ls.server.command
-
-import org.opencds.cqf.cql.ls.server.manager.IgContextManager
-import picocli.CommandLine.Command
-
-@Command(subcommands = [CqlCommand::class])
-class CliCommand(val igContextManager: IgContextManager)
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommand.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommand.kt
deleted file mode 100644
index 9d3f9ea9..00000000
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommand.kt
+++ /dev/null
@@ -1,319 +0,0 @@
-package org.opencds.cqf.cql.ls.server.command
-
-import ca.uhn.fhir.context.FhirContext
-import ca.uhn.fhir.context.FhirVersionEnum
-import ca.uhn.fhir.repository.IRepository
-import kotlinx.io.files.Path
-import org.cqframework.cql.cql2elm.CqlTranslatorOptions
-import org.cqframework.cql.cql2elm.DefaultLibrarySourceProvider
-import org.cqframework.cql.cql2elm.DefaultModelInfoProvider
-import org.cqframework.fhir.npm.NpmProcessor
-import org.cqframework.fhir.utilities.IGContext
-import org.hl7.elm.r1.VersionedIdentifier
-import org.hl7.fhir.instance.model.api.IBase
-import org.hl7.fhir.instance.model.api.IBaseDatatype
-import org.hl7.fhir.instance.model.api.IBaseResource
-import org.hl7.fhir.r5.context.ILoggingService
-import org.opencds.cqf.cql.ls.core.utility.Uris
-import org.opencds.cqf.cql.ls.server.repository.ig.standard.IgStandardRepository
-import org.opencds.cqf.fhir.cql.CqlOptions
-import org.opencds.cqf.fhir.cql.Engines
-import org.opencds.cqf.fhir.cql.EvaluationSettings
-import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings
-import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.PROFILE_MODE
-import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.SEARCH_FILTER_MODE
-import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.TERMINOLOGY_FILTER_MODE
-import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings
-import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.CODE_LOOKUP_MODE
-import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_EXPANSION_MODE
-import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_MEMBERSHIP_MODE
-import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_PRE_EXPANSION_MODE
-import org.opencds.cqf.fhir.utility.repository.ProxyRepository
-import org.slf4j.LoggerFactory
-import picocli.CommandLine
-import picocli.CommandLine.ArgGroup
-import picocli.CommandLine.Command
-import picocli.CommandLine.Option
-import java.nio.file.Paths
-import java.util.concurrent.Callable
-
-@Command(name = "cql", mixinStandardHelpOptions = true)
-class CqlCommand : Callable {
- companion object {
- private val log = LoggerFactory.getLogger(CqlCommand::class.java)
- }
-
- @Option(names = ["-fv", "--fhir-version"], required = true)
- var fhirVersion: String = ""
-
- @Option(names = ["-op", "--options-path"])
- var optionsPath: String? = null
-
- @ArgGroup(multiplicity = "0..1", exclusive = false)
- var namespace: NamespaceParameter? = null
-
- class NamespaceParameter {
- @Option(names = ["-nn", "--namespace-name"])
- var namespaceName: String? = null
-
- @Option(names = ["-nu", "--namespace-uri"])
- var namespaceUri: String? = null
- }
-
- @Option(names = ["-rd", "--root-dir"])
- var rootDir: String? = null
-
- @Option(names = ["-ig", "--ig-path"])
- var igPath: String? = null
-
- @ArgGroup(multiplicity = "1..*", exclusive = false)
- var libraries: MutableList = mutableListOf()
-
- class LibraryParameter {
- @Option(names = ["-lu", "--library-url"], required = true)
- var libraryUrl: String? = null
-
- @Option(names = ["-ln", "--library-name"], required = true)
- var libraryName: String = ""
-
- @Option(names = ["-lv", "--library-version"])
- var libraryVersion: String? = null
-
- @Option(names = ["-t", "--terminology-url"])
- var terminologyUrl: String? = null
-
- @ArgGroup(multiplicity = "0..1", exclusive = false)
- var model: ModelParameter? = null
-
- @ArgGroup(multiplicity = "0..*", exclusive = false)
- var parameters: MutableList = mutableListOf()
-
- @Option(names = ["-e", "--expression"])
- var expression: Array? = null
-
- @ArgGroup(multiplicity = "0..1", exclusive = false)
- var context: ContextParameter? = null
-
- class ContextParameter {
- @Option(names = ["-c", "--context"])
- var contextName: String? = null
-
- @Option(names = ["-cv", "--context-value"])
- var contextValue: String? = null
- }
-
- class ModelParameter {
- @Option(names = ["-m", "--model"])
- var modelName: String? = null
-
- @Option(names = ["-mu", "--model-url"])
- var modelUrl: String? = null
- }
-
- class ParameterParameter {
- @Option(names = ["-p", "--parameter"])
- var parameterName: String? = null
-
- @Option(names = ["-pv", "--parameter-value"])
- var parameterValue: String? = null
- }
- }
-
- @Suppress("removal") // TODO: Missed a spot upstream in the CQL library
- private class Logger : ILoggingService {
- private val log = LoggerFactory.getLogger(Logger::class.java)
-
- override fun logMessage(s: String) {
- log.warn(s)
- }
-
- override fun logDebugMessage(
- logCategory: ILoggingService.LogCategory,
- s: String,
- ) {
- log.debug("{}: {}", logCategory, s)
- }
-
- @Suppress("OVERRIDE_DEPRECATION")
- override fun isDebugLogging(): Boolean = log.isDebugEnabled
- }
-
- private fun toVersionNumber(fhirVersion: FhirVersionEnum): String {
- return when (fhirVersion) {
- FhirVersionEnum.R4 -> "4.0.1"
- FhirVersionEnum.R5 -> "5.0.0-ballot"
- FhirVersionEnum.DSTU3 -> "3.0.2"
- else -> throw IllegalArgumentException("Unsupported FHIR version $fhirVersion")
- }
- }
-
- @CommandLine.ParentCommand
- private var parentCommand: CliCommand? = null
-
- override fun call(): Int {
- val fhirVersionEnum = FhirVersionEnum.valueOf(fhirVersion)
- val fhirContext = FhirContext.forCached(fhirVersionEnum)
-
- var igContext: IGContext? = null
- var npmProcessor: NpmProcessor? = null
- if (rootDir != null && igPath != null) {
- igContext = IGContext(Logger())
- igContext.initializeFromIg(rootDir, igPath, toVersionNumber(fhirVersionEnum))
- } else if (parentCommand != null && rootDir != null) {
- val pc = parentCommand
- val rd = rootDir
- if (pc != null && rd != null) {
- val rootUri = Uris.parseOrNull(rd)
- val inputUri = rootUri?.let { Uris.addPath(it, "input") }
- val cqlUri = inputUri?.let { Uris.addPath(it, "cql") }
- npmProcessor = cqlUri?.let { pc.igContextManager.getContext(it) }
- }
- if (npmProcessor != null) {
- igContext = npmProcessor.igContext
- }
- }
-
- if (npmProcessor == null) {
- npmProcessor = NpmProcessor(igContext)
- }
-
- val cqlOptions = CqlOptions.defaultOptions()
-
- val optionsPathVal = optionsPath
- if (optionsPathVal != null) {
- val optUri =
- Uris.parseOrNull(optionsPathVal)
- ?: run {
- log.warn("Could not parse options path: $optionsPathVal")
- return 1
- }
- val op = Path(Paths.get(optUri).toString())
- val options = CqlTranslatorOptions.fromFile(Path(op))
- cqlOptions.setCqlCompilerOptions(options.cqlCompilerOptions)
- }
-
- val terminologySettings =
- TerminologySettings().apply {
- setValuesetExpansionMode(VALUESET_EXPANSION_MODE.PERFORM_NAIVE_EXPANSION)
- setValuesetPreExpansionMode(VALUESET_PRE_EXPANSION_MODE.USE_IF_PRESENT)
- setValuesetMembershipMode(VALUESET_MEMBERSHIP_MODE.USE_EXPANSION)
- setCodeLookupMode(CODE_LOOKUP_MODE.USE_CODESYSTEM_URL)
- }
-
- val retrieveSettings =
- RetrieveSettings().apply {
- setTerminologyParameterMode(TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY)
- setSearchParameterMode(SEARCH_FILTER_MODE.FILTER_IN_MEMORY)
- setProfileMode(PROFILE_MODE.DECLARED)
- }
-
- val evaluationSettings =
- EvaluationSettings.getDefault().apply {
- setCqlOptions(cqlOptions)
- setTerminologySettings(terminologySettings)
- setRetrieveSettings(retrieveSettings)
- setNpmProcessor(npmProcessor)
- }
-
- for (library in libraries) {
- // Paths are mixed types
- // IgStandardRepository uses java nio path objects
- // DefaultLibraryServiceProvider uses kotlin path objects
- // Until the language server can be ported to kotlin, the differences will exist
- val libraryUrlVal = library.libraryUrl
- val libraryUri = if (libraryUrlVal != null) Uris.parseOrNull(libraryUrlVal) else null
-
- val libraryKotlinPath = if (libraryUri != null) Path(Paths.get(libraryUri).toString()) else null
-
- val modelPath = library.model?.modelUrl?.let { Uris.parseOrNull(it)?.let { u -> Paths.get(u) } }
-
- val terminologyUrl = library.terminologyUrl
- val terminologyPath = terminologyUrl?.let { Uris.parseOrNull(it)?.let { u -> Paths.get(u) } }
-
- val repository = createRepository(fhirContext, terminologyPath, modelPath)
-
- val engine = Engines.forRepository(repository, evaluationSettings)
-
- val kPath = libraryKotlinPath
- if (library.libraryUrl != null && kPath != null) {
- val provider = DefaultLibrarySourceProvider(kPath)
- engine.environment
- .libraryManager?.librarySourceLoader
- ?.registerProvider(provider)
-
- val modelProvider = DefaultModelInfoProvider(kPath)
- engine.environment
- .libraryManager?.modelManager
- ?.modelInfoLoader
- ?.registerModelInfoProvider(modelProvider)
- }
-
- val identifier = VersionedIdentifier().withId(library.libraryName)
-
- val contextParameter: org.apache.commons.lang3.tuple.Pair? =
- library.context?.let { ctx ->
- org.apache.commons.lang3.tuple.Pair.of(ctx.contextName, ctx.contextValue)
- }
-
- val expressions = library.expression?.toSet()
- val result =
- if (expressions != null) {
- engine.evaluate(identifier, expressions, contextParameter)
- } else {
- engine.evaluate(identifier, contextParameter)
- }
-
- writeResult(result)
- }
-
- return 0
- }
-
- private fun createRepository(
- fhirContext: FhirContext,
- terminologyPath: java.nio.file.Path?,
- modelPath: java.nio.file.Path?,
- ): IRepository {
- if (terminologyPath == null && modelPath == null) {
- return NoOpRepository(fhirContext)
- }
-
- val data: IRepository = if (modelPath != null) IgStandardRepository(fhirContext, modelPath) else NoOpRepository(fhirContext)
- val terminology: IRepository =
- if (terminologyPath != null) {
- IgStandardRepository(
- fhirContext,
- terminologyPath,
- )
- } else {
- NoOpRepository(fhirContext)
- }
-
- return ProxyRepository(data, data, terminology)
- }
-
- @Suppress("java:S106") // We are intending to output to the console here as a CLI tool
- private fun writeResult(result: org.opencds.cqf.cql.engine.execution.EvaluationResult) {
- for ((key, value) in result.expressionResults) {
- println("$key=${tempConvert(value?.value())}")
- }
- println()
- }
-
- private fun tempConvert(value: Any?): String {
- if (value == null) return "null"
-
- return when (value) {
- is Iterable<*> -> {
- val items = value.joinToString(", ") { tempConvert(it) }
- "[$items]"
- }
- is IBaseResource ->
- value.fhirType() +
- if (value.idElement != null && value.idElement.hasIdPart()) "(id=${value.idElement.idPart})" else ""
- is IBase -> value.fhirType()
- is IBaseDatatype -> value.fhirType()
- else -> value.toString()
- }
- }
-}
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt
new file mode 100644
index 00000000..fea7682c
--- /dev/null
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt
@@ -0,0 +1,546 @@
+package org.opencds.cqf.cql.ls.server.command
+
+import ca.uhn.fhir.context.FhirContext
+import ca.uhn.fhir.context.FhirVersionEnum
+import ca.uhn.fhir.repository.IRepository
+import kotlinx.io.files.Path
+import org.cqframework.cql.cql2elm.CqlTranslatorOptions
+import org.cqframework.cql.cql2elm.DefaultLibrarySourceProvider
+import org.cqframework.cql.cql2elm.DefaultModelInfoProvider
+import org.cqframework.cql.cql2elm.model.CompiledLibrary
+import org.cqframework.fhir.npm.NpmProcessor
+import org.cqframework.fhir.utilities.IGContext
+import org.hl7.elm.r1.VersionedIdentifier
+import org.hl7.fhir.instance.model.api.IBase
+import org.hl7.fhir.instance.model.api.IBaseDatatype
+import org.hl7.fhir.instance.model.api.IBaseResource
+import org.hl7.fhir.r5.context.ILoggingService
+import org.opencds.cqf.cql.ls.core.ContentService
+import org.opencds.cqf.cql.ls.core.utility.Uris
+import org.opencds.cqf.cql.ls.server.manager.IgContextManager
+import org.opencds.cqf.cql.ls.server.provider.ContentServiceModelInfoProvider
+import org.opencds.cqf.cql.ls.server.provider.ContentServiceSourceProvider
+import org.opencds.cqf.cql.ls.server.repository.ig.standard.IgStandardRepository
+import org.opencds.cqf.fhir.cql.CqlOptions
+import org.opencds.cqf.fhir.cql.Engines
+import org.opencds.cqf.fhir.cql.EvaluationSettings
+import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings
+import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.PROFILE_MODE
+import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.SEARCH_FILTER_MODE
+import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.TERMINOLOGY_FILTER_MODE
+import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings
+import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.CODE_LOOKUP_MODE
+import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_EXPANSION_MODE
+import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_MEMBERSHIP_MODE
+import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_PRE_EXPANSION_MODE
+import org.opencds.cqf.fhir.utility.repository.FederatedRepository
+import org.opencds.cqf.fhir.utility.repository.ProxyRepository
+import org.opencds.cqf.cql.engine.runtime.Date as CqlDate
+import org.opencds.cqf.cql.engine.runtime.DateTime as CqlDateTime
+import org.opencds.cqf.cql.engine.runtime.Interval as CqlInterval
+import org.opencds.cqf.cql.engine.runtime.Quantity as CqlQuantity
+import org.opencds.cqf.cql.engine.runtime.Time as CqlTime
+import org.slf4j.LoggerFactory
+import java.math.BigDecimal
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.time.ZoneOffset
+import java.util.concurrent.ConcurrentHashMap
+
+object CqlEvaluator {
+ private val log = LoggerFactory.getLogger(CqlEvaluator::class.java)
+
+ private fun heapStats(): String {
+ val rt = Runtime.getRuntime()
+ val usedMB = (rt.totalMemory() - rt.freeMemory()) / 1_048_576
+ val maxMB = rt.maxMemory() / 1_048_576
+ return "heap=${usedMB}MB/${maxMB}MB"
+ }
+
+ @Suppress("removal") // TODO: Missed a spot upstream in the CQL library
+ private class Logger : ILoggingService {
+ private val log = LoggerFactory.getLogger(Logger::class.java)
+
+ override fun logMessage(s: String) {
+ log.warn(s)
+ }
+
+ override fun logDebugMessage(
+ logCategory: ILoggingService.LogCategory,
+ s: String,
+ ) {
+ log.debug("{}: {}", logCategory, s)
+ }
+
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun isDebugLogging(): Boolean = log.isDebugEnabled
+ }
+
+ // Matches CQL interval literals: Interval[/( low, high ]/). Non-greedy to handle null endpoints.
+ private val intervalLiteralRegex = Regex("""^Interval([\[(])\s*(.*?)\s*,\s*(.*?)\s*([\])])$""")
+
+ // Matches CQL quantity literals: e.g. 5 'mg', 1.5 'd'
+ private val quantityLiteralRegex = Regex("""^(\d+(?:\.\d+)?)\s+'([^']*)'$""")
+
+ /** Strips the leading {@code @} from a CQL temporal literal and constructs a [CqlDateTime]. */
+ private fun parseCqlDateTimeValue(literal: String): CqlDateTime =
+ CqlDateTime(literal.trimStart('@'), ZoneOffset.UTC)
+
+ /** Strips the leading {@code @} from a CQL date literal and constructs a [CqlDate]. */
+ private fun parseCqlDateValue(literal: String): CqlDate =
+ CqlDate(literal.trimStart('@'))
+
+ /**
+ * Strips the leading {@code @} from a CQL time literal and constructs a [CqlTime].
+ * The engine's [CqlTime] string constructor handles the {@code T} prefix.
+ */
+ private fun parseCqlTimeValue(literal: String): CqlTime =
+ CqlTime(literal.trimStart('@'))
+
+ /**
+ * Parses a CQL quantity literal ({@code 5 'mg'}) into a [CqlQuantity].
+ * Throws [IllegalArgumentException] if the format does not match.
+ */
+ private fun parseCqlQuantityValue(literal: String): CqlQuantity {
+ val match =
+ quantityLiteralRegex.find(literal.trim())
+ ?: throw IllegalArgumentException("Expected format: '', got '$literal'")
+ return CqlQuantity().withValue(BigDecimal(match.groupValues[1])).withUnit(match.groupValues[2])
+ }
+
+ /**
+ * Parses a CQL interval literal (e.g. {@code Interval[@2024-01-01, @2024-12-31)}) into a
+ * [CqlInterval] whose endpoints are native CQL runtime objects. Falls back to the raw string
+ * and logs a warning if the literal cannot be parsed.
+ *
+ * @param pointType either {@code "datetime"} or {@code "date"} — used to pick the endpoint parser
+ */
+ private fun parseCqlIntervalValue(
+ paramName: String,
+ value: String,
+ pointType: String,
+ ): Any {
+ val match =
+ intervalLiteralRegex.find(value.trim())
+ ?: run {
+ log.warn(
+ "Parameter '$paramName': could not parse interval literal '$value'. Passing as String.",
+ )
+ return value
+ }
+ val lowClosed = match.groupValues[1] == "["
+ val highClosed = match.groupValues[4] == "]"
+
+ fun parseEndpoint(s: String): Any? {
+ if (s.equals("null", ignoreCase = true)) return null
+ return try {
+ when (pointType) {
+ "datetime" -> parseCqlDateTimeValue(s)
+ "date" -> parseCqlDateValue(s)
+ else -> s
+ }
+ } catch (e: Exception) {
+ log.warn(
+ "Parameter '$paramName': could not parse interval endpoint '$s' as $pointType: ${e.message}. Passing as String.",
+ )
+ s
+ }
+ }
+
+ return CqlInterval(parseEndpoint(match.groupValues[2]), lowClosed, parseEndpoint(match.groupValues[3]), highClosed)
+ }
+
+ private fun coerceParameters(parameters: List): MutableMap {
+ val result = mutableMapOf()
+ for (param in parameters) {
+ val value: Any? =
+ when (param.parameterType?.lowercase()) {
+ "integer" ->
+ param.parameterValue.toIntOrNull()
+ ?: run {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Integer. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "decimal" ->
+ param.parameterValue.toBigDecimalOrNull()
+ ?: run {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Decimal. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "boolean" ->
+ param.parameterValue.toBooleanStrictOrNull()
+ ?: run {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Boolean. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "datetime" ->
+ try {
+ parseCqlDateTimeValue(param.parameterValue)
+ } catch (e: Exception) {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as DateTime: ${e.message}. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "date" ->
+ try {
+ parseCqlDateValue(param.parameterValue)
+ } catch (e: Exception) {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Date: ${e.message}. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "time" ->
+ try {
+ parseCqlTimeValue(param.parameterValue)
+ } catch (e: Exception) {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Time: ${e.message}. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "quantity" ->
+ try {
+ parseCqlQuantityValue(param.parameterValue)
+ } catch (e: Exception) {
+ log.warn(
+ "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Quantity: ${e.message}. Passing as String.",
+ )
+ param.parameterValue
+ }
+ "interval" -> parseCqlIntervalValue(param.parameterName ?: "", param.parameterValue, "datetime")
+ "interval" -> parseCqlIntervalValue(param.parameterName ?: "", param.parameterValue, "date")
+ "string", null -> param.parameterValue
+ else -> {
+ log.warn(
+ "Parameter '${param.parameterName}': type '${param.parameterType}' is not a recognised CQL type. Passing as String.",
+ )
+ param.parameterValue
+ }
+ }
+ result[param.parameterName] = value
+ }
+ return result
+ }
+
+ private fun tempConvert(value: Any?): String {
+ if (value == null) return "null"
+
+ return when (value) {
+ is Iterable<*> -> {
+ val items = value.joinToString(", ") { tempConvert(it) }
+ "[$items]"
+ }
+ is IBaseResource ->
+ value.fhirType() +
+ if (value.idElement != null && value.idElement.hasIdPart()) "(id=${value.idElement.idPart})" else ""
+ is IBase -> value.fhirType()
+ is IBaseDatatype -> value.fhirType()
+ else -> value.toString()
+ }
+ }
+
+ private fun buildCqlOptions(optionsPath: String?): CqlOptions {
+ val cqlOptions = CqlOptions.defaultOptions()
+ if (optionsPath != null) {
+ val op = Path(Paths.get(Uris.parseOrNull(optionsPath)!!).toString())
+ val options = CqlTranslatorOptions.fromFile(Path(op))
+ cqlOptions.setCqlCompilerOptions(options.cqlCompilerOptions)
+ }
+ return cqlOptions
+ }
+
+ private fun buildEvaluationSettings(
+ cqlOptions: CqlOptions,
+ npmProcessor: NpmProcessor,
+ ): EvaluationSettings {
+ val terminologySettings =
+ TerminologySettings().apply {
+ setValuesetExpansionMode(VALUESET_EXPANSION_MODE.PERFORM_NAIVE_EXPANSION)
+ setValuesetPreExpansionMode(VALUESET_PRE_EXPANSION_MODE.USE_IF_PRESENT)
+ setValuesetMembershipMode(VALUESET_MEMBERSHIP_MODE.USE_EXPANSION)
+ setCodeLookupMode(CODE_LOOKUP_MODE.USE_CODESYSTEM_URL)
+ }
+
+ val retrieveSettings =
+ RetrieveSettings().apply {
+ setTerminologyParameterMode(TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY)
+ setSearchParameterMode(SEARCH_FILTER_MODE.FILTER_IN_MEMORY)
+ setProfileMode(PROFILE_MODE.DECLARED)
+ }
+
+ return EvaluationSettings.getDefault().apply {
+ setCqlOptions(cqlOptions)
+ setTerminologySettings(terminologySettings)
+ setRetrieveSettings(retrieveSettings)
+ setNpmProcessor(npmProcessor)
+ }
+ }
+
+ /**
+ * Evaluates all libraries in the request. Libraries sharing the same libraryName,
+ * libraryUri, terminologyUri, and optionsPath are evaluated in a single batch using
+ * one engine — CQL is compiled once and the [LibraryManager] cache is reused for all
+ * subsequent patients in the batch.
+ *
+ * Across batches, two resources are shared when their discriminating key matches:
+ * - [IgStandardRepository] (terminology): keyed by terminologyUri — the ValueSet
+ * directory is scanned at most once per unique path for the entire request.
+ * - [EvaluationSettings] (libraryCache): keyed by optionsPath — compiled ELM for
+ * helper libraries (FHIRHelpers, QICore) produced by batch N is reused by batch N+1
+ * without recompilation. Safe because [VersionedIdentifier] includes the library's
+ * own namespace URI, so libraries with the same name+version always share the same
+ * compiled ELM regardless of which source path they were loaded from.
+ */
+ fun evaluate(
+ request: ExecuteCqlRequest,
+ contentService: ContentService,
+ igContextManager: IgContextManager,
+ ): ExecuteCqlResponse {
+ val fhirContext = FhirContext.forCached(FhirVersionEnum.valueOf(request.fhirVersion))
+
+ var igContext: IGContext? = null
+ var npmProcessor: NpmProcessor? = null
+ val rootDir = request.rootDir
+ if (rootDir != null) {
+ npmProcessor =
+ igContextManager.getContext(
+ Uris.addPath(Uris.addPath(Uris.parseOrNull(rootDir)!!, "input")!!, "cql")!!,
+ )
+ if (npmProcessor != null) {
+ igContext = npmProcessor.igContext
+ }
+ }
+
+ if (npmProcessor == null) {
+ npmProcessor = NpmProcessor(igContext)
+ }
+
+ // Group by batch key — preserves insertion order via LinkedHashMap so results come
+ // back in the same order they were sent.
+ val grouped = LinkedHashMap>()
+ for (lib in request.libraries) {
+ val key = "${lib.libraryName}|${lib.libraryUri}|${lib.terminologyUri}|${request.optionsPath}"
+ grouped.getOrPut(key) { mutableListOf() }.add(lib)
+ }
+
+ // Shared across batches — created once per unique key for the lifetime of this request.
+ // terminologyRepos: the IgStandardRepository.typeResourceCache on a shared instance
+ // means the ValueSet directory is scanned at most once per unique path.
+ // libraryCaches: compiled ELM (FHIRHelpers, QICore, etc.) produced by one batch is
+ // reused by subsequent batches without recompilation. Safe because VersionedIdentifier
+ // includes the library's own namespace URI, so same name+version always means same ELM.
+ // Each batch gets its own fresh EvaluationSettings; only the libraryCache map is injected
+ // as shared so that valueSetCache, modelCache, etc. remain independent per batch.
+ val terminologyRepos = mutableMapOf()
+ val libraryCaches = mutableMapOf>()
+
+ val allResults =
+ grouped.values.flatMap { batch ->
+ val terminologyUri = batch.first().terminologyUri
+ val terminologyRepo =
+ terminologyRepos.getOrPut(terminologyUri) {
+ val path = terminologyUri?.let { Paths.get(Uris.parseOrNull(it)!!) }
+ if (path != null) {
+ IgStandardRepository(fhirContext, path)
+ } else {
+ NoOpRepository(fhirContext)
+ }
+ }
+ val sharedLibraryCache = libraryCaches.getOrPut(request.optionsPath) { ConcurrentHashMap() }
+ val evaluationSettings =
+ buildEvaluationSettings(
+ buildCqlOptions(request.optionsPath),
+ NpmProcessor(igContext),
+ ).withLibraryCache(sharedLibraryCache)
+ evaluateBatch(batch, fhirContext, npmProcessor, contentService, terminologyRepo, evaluationSettings, sharedLibraryCache)
+ }
+
+ return ExecuteCqlResponse(allResults, emptyList())
+ }
+
+ /**
+ * Evaluates a batch of [LibraryRequest]s that all refer to the same CQL library.
+ * One engine is created for the batch; CQL is compiled on the first call and the
+ * [LibraryManager] cache serves all subsequent patients without recompilation.
+ *
+ * Terminology is loaded once and shared across all patients. Per-patient data is
+ * isolated by swapping [DelegatingRepository.current] before each evaluate call.
+ * If a `shared/` sibling directory exists alongside the patient UUID directories,
+ * its resources are made available as a fallback via [FederatedRepository].
+ */
+ private fun evaluateBatch(
+ batch: List,
+ fhirContext: FhirContext,
+ npmProcessor: NpmProcessor,
+ contentService: ContentService,
+ terminologyRepo: IRepository,
+ evaluationSettings: EvaluationSettings,
+ libraryCache: ConcurrentHashMap,
+ ): List {
+ val first = batch.first()
+ val batchStart = System.currentTimeMillis()
+
+ // Shared data directory — sibling of patient UUID dirs, e.g. "shared/".
+ // Loaded once; individual patient repos fall back to it via FederatedRepository.
+ val firstModelPath = first.model?.modelUri?.let { Paths.get(Uris.parseOrNull(it)!!) }
+ val sharedDataPath =
+ firstModelPath?.parent?.resolve("shared")
+ ?.takeIf { Files.isDirectory(it) }
+ val sharedDataRepo: IRepository? = sharedDataPath?.let { IgStandardRepository(fhirContext, it) }
+
+ // Mutable data slot — swapped per patient before each engine.evaluate() call.
+ val delegatingData = DelegatingRepository(NoOpRepository(fhirContext))
+ val compositeRepo = ProxyRepository(delegatingData, delegatingData, terminologyRepo)
+
+ val engineStart = System.currentTimeMillis()
+ val engine = Engines.forRepository(compositeRepo, evaluationSettings)
+ val setupMs = System.currentTimeMillis() - batchStart
+ val engineMs = System.currentTimeMillis() - engineStart
+ log.debug(
+ "[PERF] ${first.libraryName} batch setup (options+settings+repos+engine): ${setupMs}ms" +
+ " (engine only: ${engineMs}ms) ${heapStats()}",
+ )
+
+ // Register source / model providers once for the entire batch.
+ val libraryUri = first.libraryUri?.let { Uris.parseOrNull(it) }
+ val libraryKotlinPath = libraryUri?.let { Path(Paths.get(it).toString()) }
+
+ if (libraryUri != null) {
+ engine.environment.libraryManager!!.librarySourceLoader.registerProvider(
+ ContentServiceSourceProvider(libraryUri, contentService),
+ )
+ engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider(
+ ContentServiceModelInfoProvider(libraryUri, contentService),
+ )
+ } else if (libraryKotlinPath != null) {
+ engine.environment.libraryManager!!.librarySourceLoader.registerProvider(
+ DefaultLibrarySourceProvider(libraryKotlinPath),
+ )
+ engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider(
+ DefaultModelInfoProvider(libraryKotlinPath),
+ )
+ }
+
+ val identifier = VersionedIdentifier().withId(first.libraryName)
+ val results = mutableListOf()
+ var patientIndex = 0
+
+ for (library in batch) {
+ val patientStart = System.currentTimeMillis()
+ patientIndex++
+ try {
+ // Per-patient data repo — isolated to prevent resource ID collisions across patients.
+ val modelPath = library.model?.modelUri?.let { Paths.get(Uris.parseOrNull(it)!!) }
+ val patientRepo: IRepository =
+ if (modelPath != null) {
+ IgStandardRepository(fhirContext, modelPath)
+ } else {
+ NoOpRepository(fhirContext)
+ }
+ val repoCreatedAt = System.currentTimeMillis()
+
+ // Swap in the patient repo (with shared fallback if available).
+ delegatingData.current =
+ if (sharedDataRepo != null) {
+ FederatedRepository(patientRepo, sharedDataRepo)
+ } else {
+ patientRepo
+ }
+
+ val contextParameter: org.apache.commons.lang3.tuple.Pair? =
+ if (library.context != null) {
+ org.apache.commons.lang3.tuple.Pair.of(
+ library.context.contextName,
+ library.context.contextValue as Any?,
+ )
+ } else {
+ null
+ }
+
+ val coercedParams = coerceParameters(library.parameters)
+ val result =
+ engine.evaluate(
+ identifier,
+ expressions = null,
+ contextParameter = contextParameter,
+ parameters = coercedParams,
+ )
+ val evaluatedAt = System.currentTimeMillis()
+ val repoMs = repoCreatedAt - patientStart
+ val evalMs = evaluatedAt - repoCreatedAt
+ val totalMs = evaluatedAt - patientStart
+ log.debug(
+ "[PERF] patient $patientIndex/${batch.size} (${library.context?.contextValue}): " +
+ "repoCreate=${repoMs}ms evaluate=${evalMs}ms total=${totalMs}ms ${heapStats()}",
+ )
+
+ val expressions =
+ result.expressionResults.map { (key, value) ->
+ ExpressionResult(key ?: "", tempConvert(value?.value()))
+ }
+
+ // Detect parameters declared in the CQL library that were not supplied via config
+ // and therefore fell back to their CQL default value. After engine.evaluate()
+ // the resolved value is cached in engine.state.parameters under the key
+ // "${libraryId}.${paramName}" (set by ParameterRefEvaluator on first access).
+ //
+ // We look up the compiled library from the shared cache by name rather than
+ // calling resolveLibrary(identifier), because the engine stores the compiled library
+ // under its actual versioned identifier (e.g. id="MyLib", version="1.0.0"). An
+ // unversioned VersionedIdentifier lookup would miss the cached entry, potentially
+ // triggering a recompile that silently fails — causing usedDefaultParameters to be
+ // empty for any CQL library that declares a version.
+ val usedDefaultParameters: List =
+ try {
+ val compiledLib =
+ libraryCache.entries
+ .firstOrNull { it.key.id == identifier.id }
+ ?.value
+ compiledLib
+ ?.library
+ ?.parameters
+ ?.def
+ ?.filter { paramDef ->
+ paramDef.name != null &&
+ paramDef.default != null &&
+ !coercedParams.containsKey(paramDef.name)
+ }
+ ?.map { paramDef ->
+ val resolvedValue =
+ engine.state.parameters["${identifier.id}.${paramDef.name}"]
+ ?: engine.state.parameters[paramDef.name]
+ DefaultParameterResult(paramDef.name!!, tempConvert(resolvedValue))
+ }
+ ?: emptyList()
+ } catch (e: Exception) {
+ log.debug("Could not inspect default parameters for ${library.libraryName}: ${e.message}")
+ emptyList()
+ }
+
+ results.add(LibraryResult(library.libraryName, expressions, usedDefaultParameters))
+ } catch (e: Exception) {
+ log.error("Error evaluating library ${library.libraryName} for context ${library.context?.contextValue}", e)
+ results.add(
+ LibraryResult(
+ library.libraryName,
+ listOf(ExpressionResult("Error", e.message ?: e.javaClass.simpleName)),
+ ),
+ )
+ }
+ }
+ val batchTotalMs = System.currentTimeMillis() - batchStart
+ log.debug(
+ "[PERF] ${first.libraryName} batch total: ${batchTotalMs}ms (${batch.size} patients) ${heapStats()}",
+ )
+
+ return results
+ }
+}
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContribution.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContribution.kt
deleted file mode 100644
index 28c361d2..00000000
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContribution.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package org.opencds.cqf.cql.ls.server.command
-
-import com.google.gson.JsonElement
-import org.eclipse.lsp4j.ExecuteCommandParams
-import org.opencds.cqf.cql.ls.server.manager.IgContextManager
-import org.opencds.cqf.cql.ls.server.plugin.CommandContribution
-import picocli.CommandLine
-import java.io.ByteArrayOutputStream
-import java.io.FileDescriptor
-import java.io.FileOutputStream
-import java.io.PrintStream
-import java.util.concurrent.CompletableFuture
-
-// TODO: This will be moved to the debug plugin once that's more fully baked..
-class DebugCqlCommandContribution(private val igContextManager: IgContextManager) : CommandContribution {
- companion object {
- // TODO: Delete once the plugin is fully supported
- const val START_DEBUG_COMMAND = "org.opencds.cqf.cql.ls.plugin.debug.startDebugSession"
- }
-
- override fun getCommands(): Set = setOf(START_DEBUG_COMMAND)
-
- override fun executeCommand(params: ExecuteCommandParams): CompletableFuture =
- when (params.command) {
- START_DEBUG_COMMAND -> executeCql(params)
- else -> super.executeCommand(params)
- }
-
- private fun executeCql(params: ExecuteCommandParams): CompletableFuture {
- try {
- val arguments =
- params.arguments
- .mapNotNull { it as? JsonElement }
- .map { it.asString }
-
- // Temporarily redirect std out, because uh... I didn't do that very smart.
- val baosOut = ByteArrayOutputStream()
- System.setOut(PrintStream(baosOut))
-
- val baosErr = ByteArrayOutputStream()
- System.setErr(PrintStream(baosErr))
-
- try {
- val cli = CommandLine(CliCommand(igContextManager))
- cli.execute(*arguments.toTypedArray())
- } catch (e: Exception) {
- System.err.println("Exception occurred attempting to evaluate:")
- System.err.println(e.message)
- }
-
- var out = baosOut.toString()
- val err = baosErr.toString()
-
- if (err.isNotEmpty()) {
- out += "\nEvaluation logs:\n"
- out += err
- }
-
- return CompletableFuture.completedFuture(out)
- } finally {
- System.setOut(PrintStream(FileOutputStream(FileDescriptor.out)))
- System.setErr(PrintStream(FileOutputStream(FileDescriptor.err)))
- }
- }
-}
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepository.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepository.kt
new file mode 100644
index 00000000..3e122c5a
--- /dev/null
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepository.kt
@@ -0,0 +1,69 @@
+package org.opencds.cqf.cql.ls.server.command
+
+import ca.uhn.fhir.context.FhirContext
+import ca.uhn.fhir.model.api.IQueryParameterType
+import ca.uhn.fhir.repository.IRepository
+import ca.uhn.fhir.rest.api.MethodOutcome
+import com.google.common.collect.Multimap
+import org.hl7.fhir.instance.model.api.IBaseBundle
+import org.hl7.fhir.instance.model.api.IBaseParameters
+import org.hl7.fhir.instance.model.api.IBaseResource
+import org.hl7.fhir.instance.model.api.IIdType
+
+/**
+ * Mutable [IRepository] wrapper. The engine is created once holding this wrapper; before each
+ * patient's [evaluate][org.opencds.cqf.fhir.cql.CqlEngine.evaluate] call, [current] is
+ * reassigned to that patient's repository. This allows a single engine — and its cached
+ * [LibraryManager][org.cqframework.cql.cql2elm.LibraryManager] — to serve all patients in a
+ * batch without recompiling CQL on every iteration.
+ */
+class DelegatingRepository(initial: IRepository) : IRepository {
+ var current: IRepository = initial
+
+ override fun fhirContext(): FhirContext = current.fhirContext()
+
+ override fun read(
+ aClass: Class,
+ i: I,
+ map: Map,
+ ): T = current.read(aClass, i, map)
+
+ override fun create(
+ t: T,
+ map: Map,
+ ): MethodOutcome = current.create(t, map)
+
+ override fun update(
+ t: T,
+ map: Map,
+ ): MethodOutcome = current.update(t, map)
+
+ override fun delete(
+ aClass: Class,
+ i: I,
+ map: Map,
+ ): MethodOutcome = current.delete(aClass, i, map)
+
+ override fun search(
+ aClass: Class,
+ aClass1: Class,
+ multimap: Multimap>,
+ map: Map,
+ ): B = current.search(aClass, aClass1, multimap, map)
+
+ override fun invoke(
+ aClass: Class,
+ s: String,
+ p: P,
+ aClass1: Class,
+ map: Map,
+ ): R = current.invoke(aClass, s, p, aClass1, map)
+
+ override fun invoke(
+ i: I,
+ s: String,
+ p: P,
+ aClass: Class,
+ map: Map,
+ ): R = current.invoke(i, s, p, aClass, map)
+}
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContribution.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContribution.kt
new file mode 100644
index 00000000..5160f2a7
--- /dev/null
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContribution.kt
@@ -0,0 +1,30 @@
+package org.opencds.cqf.cql.ls.server.command
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+import org.eclipse.lsp4j.ExecuteCommandParams
+import org.opencds.cqf.cql.ls.core.ContentService
+import org.opencds.cqf.cql.ls.server.manager.IgContextManager
+import org.opencds.cqf.cql.ls.server.plugin.CommandContribution
+import java.util.concurrent.CompletableFuture
+
+class ExecuteCqlCommandContribution(
+ private val igContextManager: IgContextManager,
+ private val contentService: ContentService,
+) : CommandContribution {
+ companion object {
+ const val EXECUTE_CQL_COMMAND = "org.opencds.cqf.cql.ls.executeCql"
+ }
+
+ override fun getCommands(): Set = setOf(EXECUTE_CQL_COMMAND)
+
+ override fun executeCommand(params: ExecuteCommandParams): CompletableFuture {
+ return if (EXECUTE_CQL_COMMAND == params.command) {
+ val element = params.arguments[0] as JsonElement
+ val request = Gson().fromJson(element, ExecuteCqlRequest::class.java)
+ CompletableFuture.completedFuture(CqlEvaluator.evaluate(request, contentService, igContextManager))
+ } else {
+ super.executeCommand(params)
+ }
+ }
+}
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlRequest.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlRequest.kt
new file mode 100644
index 00000000..cf69693c
--- /dev/null
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlRequest.kt
@@ -0,0 +1,43 @@
+package org.opencds.cqf.cql.ls.server.command
+
+data class ExecuteCqlRequest(
+ val fhirVersion: String,
+ val rootDir: String?,
+ val optionsPath: String?,
+ val libraries: List,
+)
+
+data class LibraryRequest(
+ val libraryName: String,
+ val libraryUri: String,
+ val libraryVersion: String?,
+ val terminologyUri: String?,
+ val model: ModelRequest?,
+ val context: ContextRequest?,
+ // TODO: parameter passing deferred to PR #6 (Execute CQL optimization)
+ val parameters: List,
+)
+
+data class ModelRequest(val modelName: String, val modelUri: String)
+
+data class ContextRequest(val contextName: String, val contextValue: String)
+
+data class ParameterRequest(val parameterName: String, val parameterType: String?, val parameterValue: String)
+
+data class ExecuteCqlResponse(val results: List, val logs: List)
+
+/**
+ * A CQL parameter that was not supplied via config and fell back to its CQL-declared default
+ * expression. [value] is the string representation of the resolved runtime value.
+ * [source] is always `"default"` — present for consistency with the config-sourced parameter shape.
+ */
+data class DefaultParameterResult(val name: String, val value: String, val source: String = "default")
+
+data class LibraryResult(
+ val libraryName: String,
+ val expressions: List,
+ /** Parameters declared in the CQL library that were not supplied via config and fell back to their CQL default, with the resolved value. */
+ val usedDefaultParameters: List = emptyList(),
+)
+
+data class ExpressionResult(val name: String, val value: String)
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt
index 29b40916..6981850b 100644
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt
@@ -8,6 +8,7 @@ import org.cqframework.cql.cql2elm.quick.FhirLibrarySourceProvider
import org.fhir.ucum.UcumEssenceService
import org.fhir.ucum.UcumService
import org.hl7.cql.model.ModelIdentifier
+import org.hl7.elm.r1.VersionedIdentifier
import org.opencds.cqf.cql.ls.core.ContentService
import org.opencds.cqf.cql.ls.core.utility.Converters
import org.opencds.cqf.cql.ls.core.utility.Uris
@@ -16,6 +17,8 @@ import org.opencds.cqf.cql.ls.server.provider.ContentServiceSourceProvider
import org.slf4j.LoggerFactory
import java.io.InputStream
import java.net.URI
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.locks.ReentrantReadWriteLock
class CqlCompilationManager(
private val contentService: ContentService,
@@ -40,7 +43,20 @@ class CqlCompilationManager(
private val globalCache = HashMap()
+ // Cache: compiled result per source URI
+ private val compilationCache = ConcurrentHashMap()
+
+ // Forward index: URI → what library identifier it compiled to
+ private val uriToIdentifier = ConcurrentHashMap()
+
+ // Reverse index: library identifier → which URIs depend on it
+ private val reverseDeps = ConcurrentHashMap>()
+
+ // Protects atomic reads/writes across all three maps
+ private val indexLock = ReentrantReadWriteLock()
+
fun compile(uri: URI): CqlCompiler? {
+ compilationCache[uri]?.let { return it }
val input = contentService.read(uri) ?: return null
return compile(uri, input)
}
@@ -53,9 +69,56 @@ class CqlCompilationManager(
val libraryManager = createLibraryManager(Uris.getHead(uri), modelManager)
val compiler = CqlCompiler(null, null, libraryManager)
compiler.run(Converters.inputStreamToString(stream))
+ compilationCache[uri] = compiler
+ updateIndex(uri, compiler)
return compiler
}
+ fun invalidate(uri: URI) {
+ indexLock.writeLock().lock()
+ try {
+ compilationCache.remove(uri)
+ val id = uriToIdentifier[uri]
+ if (id != null) {
+ reverseDeps[id]?.forEach { compilationCache.remove(it) }
+ }
+ } finally {
+ indexLock.writeLock().unlock()
+ }
+ }
+
+ fun getDependentUris(identifier: VersionedIdentifier): Set {
+ indexLock.readLock().lock()
+ return try {
+ reverseDeps[identifier]?.toSet() ?: emptySet()
+ } finally {
+ indexLock.readLock().unlock()
+ }
+ }
+
+ private fun updateIndex(
+ uri: URI,
+ compiler: CqlCompiler,
+ ) {
+ val library = compiler.compiledLibrary?.library ?: return
+ val identifier = library.identifier ?: return
+ indexLock.writeLock().lock()
+ try {
+ val oldId = uriToIdentifier[uri]
+ if (oldId != null) reverseDeps[oldId]?.remove(uri)
+ uriToIdentifier[uri] = identifier
+ library.includes?.def?.forEach { includeDef ->
+ val depId =
+ VersionedIdentifier()
+ .withId(includeDef.path)
+ .withVersion(includeDef.version)
+ reverseDeps.getOrPut(depId) { ConcurrentHashMap.newKeySet() }.add(uri)
+ }
+ } finally {
+ indexLock.writeLock().unlock()
+ }
+ }
+
private fun createModelManager() = ModelManager(globalCache)
private fun createLibraryManager(
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt
index e3234aab..8bc94a09 100644
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt
@@ -81,6 +81,17 @@ open class IgStandardRepository : IRepository {
.maximumSize(500)
.build()
+ /**
+ * Directory-level cache: caches the full result of scanning a directory for a given
+ * resource type + compartment combination. Keyed by "${resourceType}|${compartmentPath}".
+ *
+ * Without this cache, every `search()` call (e.g. ValueSet lookup during terminology
+ * expansion) re-walks the entire directory (600+ files for a terminology bundle), even
+ * when the result has already been computed for a prior patient in the same batch.
+ * This cache makes the scan happen at most once per resource type per repository instance.
+ */
+ private val typeResourceCache = ConcurrentHashMap>()
+
/**
* Creates a new IgRepository with auto-detected conventions and default encoding behavior.
*/
@@ -108,6 +119,7 @@ open class IgStandardRepository : IRepository {
fun clearCache() {
resourceCache.invalidateAll()
+ typeResourceCache.clear()
}
private fun isExternalPath(path: Path): Boolean = path.parent != null && path.parent.toString().lowercase().endsWith(EXTERNAL_DIRECTORY)
@@ -193,16 +205,16 @@ open class IgStandardRepository : IRepository {
}
protected open fun readResource(path: Path): IBaseResource? {
- log.info("IgStandardRepository.readResource - Attempting to read resource from path: {}", path)
+ log.debug("IgStandardRepository.readResource - Attempting to read resource from path: {}", path)
val file = path.toFile()
if (!file.exists()) {
- log.info("IgStandardRepository.readResource - Didn't find file")
+ log.debug("IgStandardRepository.readResource - Didn't find file")
return null
}
val extension =
fileExtension(path) ?: run {
- log.info("IgStandardRepository.readResource - Extension check failed")
+ log.debug("IgStandardRepository.readResource - Extension check failed")
return null
}
@@ -213,7 +225,7 @@ open class IgStandardRepository : IRepository {
val resource = parserForEncoding(fhirContext, encoding).parseResource(s)
resource.setUserData(SOURCE_PATH_TAG, path)
IgStandardCqlContent.loadCqlContent(resource, path.parent)
- log.info("IgStandardRepository.readResource - Returning resource: {}", resource)
+ log.debug("IgStandardRepository.readResource - Returning resource: {}", resource)
resource
} catch (e: FileNotFoundException) {
null
@@ -227,14 +239,14 @@ open class IgStandardRepository : IRepository {
protected open fun cachedReadResource(path: Path): IBaseResource? {
val cached = resourceCache.getIfPresent(path)
if (cached != null) {
- log.info("IgStandardRepository.cachedReadResource - Returning cached resource: {}", cached)
+ log.debug("IgStandardRepository.cachedReadResource - Returning cached resource: {}", cached)
return cached
}
val resource = readResource(path)
if (resource != null) {
resourceCache.put(path, resource)
}
- log.info("IgStandardRepository.cachedReadResource - Returning freshly loaded resource: {}", resource)
+ log.debug("IgStandardRepository.cachedReadResource - Returning freshly loaded resource: {}", resource)
return resource
}
@@ -285,14 +297,25 @@ open class IgStandardRepository : IRepository {
return path.fileName.toString().lowercase().startsWith(prefix.lowercase() + "-")
}
+ @Suppress("UNCHECKED_CAST")
protected open fun readDirectoryForResourceType(
resourceClass: Class,
igRepositoryCompartment: IgStandardRepositoryCompartment,
): Map {
+ val cacheKey = "${resourceClass.simpleName}|${pathForCompartment(igRepositoryCompartment)}"
+ return typeResourceCache.computeIfAbsent(cacheKey) {
+ readDirectoryForResourceTypeUncached(resourceClass, igRepositoryCompartment)
+ } as Map
+ }
+
+ private fun readDirectoryForResourceTypeUncached(
+ resourceClass: Class,
+ igRepositoryCompartment: IgStandardRepositoryCompartment,
+ ): Map {
val path = directoryForResource(resourceClass, igRepositoryCompartment)
if (!path.toFile().exists()) return emptyMap()
- val resources = ConcurrentHashMap()
+ val resources = ConcurrentHashMap()
val resourceFileFilter: (Path) -> Boolean =
when (conventions.filenameMode) {
IgStandardConventions.FilenameMode.ID_ONLY -> ::acceptByFileExtension
@@ -308,8 +331,8 @@ open class IgStandardRepository : IRepository {
.map { it!! }
.forEach { r ->
if (r.fhirType() != resourceClass.simpleName) return@forEach
- val validated = validateResource(resourceClass, r, r.idElement)
- resources[r.idElement.toUnqualifiedVersionless()] = validated
+ if (!r.idElement.hasIdPart()) return@forEach
+ resources[r.idElement.toUnqualifiedVersionless()] = r
}
}
} catch (e: IOException) {
@@ -328,26 +351,26 @@ open class IgStandardRepository : IRepository {
): T {
requireNotNull(resourceType) { "resourceType cannot be null" }
requireNotNull(id) { "id cannot be null" }
- log.info("IgStandardRepository.read - Attempting to read resource [{}].", id)
- log.info("IgStandardRepository.read - headers: {}", headers)
+ log.debug("IgStandardRepository.read - Attempting to read resource [{}].", id)
+ log.debug("IgStandardRepository.read - headers: {}", headers)
val compartment = compartmentFrom(headers)
val paths = potentialPathsForResource(resourceType, id, compartment)
for (path in paths) {
- log.info("IgStandardRepository.read - potentialPathsForResource path: {}", path)
+ log.debug("IgStandardRepository.read - potentialPathsForResource path: {}", path)
if (!Files.exists(path)) {
- log.info("IgStandardRepository.read - File doesn't exist at [{}]. Continuing loop.", path)
+ log.debug("IgStandardRepository.read - File doesn't exist at [{}]. Continuing loop.", path)
continue
}
val resource = cachedReadResource(path)
if (resource != null) {
- log.info("IgStandardRepository.read - Found resource [{}].", id)
+ log.debug("IgStandardRepository.read - Found resource [{}].", id)
return validateResource(resourceType, resource, id)
}
}
- log.info("IgStandardRepository.read - Unable to find resource [{}]. Throwing Exception", id)
+ log.debug("IgStandardRepository.read - Unable to find resource [{}]. Throwing Exception", id)
throw ResourceNotFoundException(id)
}
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt
index 347f3bc9..8e129f58 100644
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt
@@ -13,6 +13,7 @@ import org.hl7.elm.r1.VersionedIdentifier
import org.opencds.cqf.cql.ls.core.ContentService
import org.opencds.cqf.cql.ls.core.utility.Uris
import org.opencds.cqf.cql.ls.server.event.DidChangeTextDocumentEvent
+import org.opencds.cqf.cql.ls.server.event.DidChangeWatchedFilesEvent
import org.opencds.cqf.cql.ls.server.event.DidCloseTextDocumentEvent
import org.opencds.cqf.cql.ls.server.event.DidOpenTextDocumentEvent
import org.opencds.cqf.cql.ls.server.manager.CqlCompilationManager
@@ -165,11 +166,23 @@ class DiagnosticsService(
@Subscribe
fun didChange(e: DidChangeTextDocumentEvent) {
log.debug("didChange: {}", e.params().textDocument.uri)
+ Uris.parseOrNull(e.params().textDocument.uri)?.let { cqlCompilationManager.invalidate(it) }
debounce(BOUNCE_DELAY) {
doLint(listOfNotNull(Uris.parseOrNull(e.params().textDocument.uri)))
}
}
+ @Subscribe
+ fun didChangeWatchedFiles(e: DidChangeWatchedFilesEvent) {
+ val uris =
+ e.params().changes
+ .mapNotNull { Uris.parseOrNull(it.uri) }
+ .filter { it.toString().endsWith(".cql") }
+ uris.forEach { cqlCompilationManager.invalidate(it) }
+ doLint(uris)
+ }
+
+
internal fun debounce(
delay: Long,
task: Runnable,
diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt
index 88f5c6f5..7b5202f1 100644
--- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt
+++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt
@@ -8,7 +8,7 @@ import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Logger.JavaLogger
import org.opencds.cqf.cql.engine.execution.CqlEngine
import org.opencds.cqf.cql.ls.server.CqlLanguageServer
-import org.opencds.cqf.cql.ls.server.command.DebugCqlCommandContribution
+import org.opencds.cqf.cql.ls.server.command.ExecuteCqlCommandContribution
import org.opencds.cqf.cql.ls.server.command.ViewElmCommandContribution
import org.opencds.cqf.cql.ls.server.manager.CompilerOptionsManager
import org.opencds.cqf.cql.ls.server.manager.CqlCompilationManager
@@ -57,7 +57,7 @@ fun main(args: Array) {
val contributions = mutableListOf()
contributions.add(ViewElmCommandContribution(compilationManager))
- contributions.add(DebugCqlCommandContribution(igContextManager))
+ contributions.add(ExecuteCqlCommandContribution(igContextManager, federatedContentService))
commandsFuture.complete(contributions)
val server = CqlLanguageServer(languageClientFuture, workspaceService, textDocumentService)
diff --git a/ls/server/src/main/resources/logback.xml b/ls/server/src/main/resources/logback.xml
index e7b7598d..fc5e6440 100644
--- a/ls/server/src/main/resources/logback.xml
+++ b/ls/server/src/main/resources/logback.xml
@@ -1,16 +1,27 @@
-
System.err
%-4relative [%thread] %-5level %logger{35} %msg %n
-
+
+ ${java.io.tmpdir}/cql-ls.log
+
+ ${java.io.tmpdir}/cql-ls.%d{yyyy-MM-dd}.%i.log
+ 10MB
+ 7
+ 50MB
+
+
+ %-4relative [%thread] %-5level %logger{35} %msg %n
+
+
+
+
-
+
-
\ No newline at end of file
+
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommandTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommandTest.kt
deleted file mode 100644
index 4bb1b9c2..00000000
--- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommandTest.kt
+++ /dev/null
@@ -1,273 +0,0 @@
-package org.opencds.cqf.cql.ls.server.command
-
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Assertions.assertFalse
-import org.junit.jupiter.api.Assertions.assertNotNull
-import org.junit.jupiter.api.Assertions.assertNull
-import org.junit.jupiter.api.Assertions.assertTrue
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.MethodOrderer
-import org.junit.jupiter.api.Order
-import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.TestMethodOrder
-import org.junit.jupiter.api.assertThrows
-import org.opencds.cqf.cql.ls.core.utility.Uris
-import java.io.ByteArrayOutputStream
-import java.io.PrintStream
-
-@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
-class CqlCommandTest {
- private lateinit var originalOut: PrintStream
- private lateinit var capturedOut: ByteArrayOutputStream
-
- @BeforeEach
- fun captureStdout() {
- originalOut = System.out
- capturedOut = ByteArrayOutputStream()
- System.setOut(PrintStream(capturedOut))
- }
-
- @AfterEach
- fun restoreStdout() {
- System.setOut(originalOut)
- }
-
- private fun output(): String = capturedOut.toString()
-
- /**
- * Returns a file: URI pointing to the directory containing the CQL test fixtures.
- *
- * Uses .toURI() (not URI.create()) so the path is correct on all platforms:
- * macOS/Linux: file:///home/user/...
- * Windows: file:///C:/Users/...
- */
- private fun cqlFixtureDirectoryUrl(): String =
- CqlCommandTest::class.java
- .getResource("/org/opencds/cqf/cql/ls/server/One.cql")!!
- .toURI()
- .resolve(".")
- .toString()
-
- private fun buildCommand(
- libraryName: String,
- libraryVersion: String? = null,
- expressions: Array? = null,
- fhirVersion: String = "R4",
- ): CqlCommand {
- val cmd = CqlCommand()
- cmd.fhirVersion = fhirVersion
- val lib = CqlCommand.LibraryParameter()
- lib.libraryUrl = cqlFixtureDirectoryUrl()
- lib.libraryName = libraryName
- lib.libraryVersion = libraryVersion
- lib.expression = expressions
- cmd.libraries = mutableListOf(lib)
- return cmd
- }
-
- // -------------------------------------------------------------------------
- // Tests 1–12: execution paths, output formatting, and error cases
- // -------------------------------------------------------------------------
-
- @Test
- @Order(1)
- fun `happy path - One evaluates One=1`() {
- val cmd = buildCommand("One")
- val result = cmd.call()
- assertEquals(0, result)
- assertTrue(output().contains("One=1"))
- }
-
- @Test
- @Order(2)
- fun `happy path - Two with version 1-0-0 evaluates Two=2`() {
- val cmd = buildCommand("Two", libraryVersion = "1.0.0")
- val result = cmd.call()
- assertEquals(0, result)
- assertTrue(output().contains("Two=2"))
- }
-
- @Test
- @Order(3)
- fun `expression filter - only requested expression appears`() {
- val cmd = buildCommand("Two", expressions = arrayOf("Two"))
- cmd.call()
- val out = output()
- assertTrue(out.contains("Two=2"), "Expected 'Two=2' in output")
- assertFalse(out.contains("Two List"), "Expected 'Two List' to be filtered out when -e Two is set")
- }
-
- @Test
- @Order(4)
- fun `no expression filter - all defines appear`() {
- val cmd = buildCommand("Two")
- cmd.call()
- val out = output()
- assertTrue(out.contains("Two=2"))
- assertTrue(out.contains("Two List"))
- }
-
- @Test
- @Order(5)
- fun `Two List is formatted as bracket list`() {
- val cmd = buildCommand("Two")
- cmd.call()
- assertTrue(output().contains("Two List=[1, 2, 3]"))
- }
-
- @Test
- @Order(6)
- fun `blank line appears after expression results`() {
- val cmd = buildCommand("One")
- cmd.call()
- // writeResult() calls println() after iterating results, producing a trailing blank line.
- // Use System.lineSeparator() so the check works on Windows (\r\n) and Unix (\n).
- val out = output()
- val sep = System.lineSeparator()
- assertTrue(out.contains("$sep$sep") || out.endsWith("$sep$sep"))
- }
-
- @Test
- @Order(7)
- fun `null value is rendered as string null`() {
- val cmd = buildCommand("NullResult")
- cmd.call()
- assertTrue(output().contains("NullDef=null"))
- }
-
- @Test
- @Order(8)
- fun `call returns 0 on success`() {
- val cmd = buildCommand("One")
- assertEquals(0, cmd.call())
- }
-
- @Test
- @Order(9)
- fun `NoOpRepository path - evaluation succeeds without terminology or model`() {
- // buildCommand sets no terminologyUrl or modelUrl, so createRepository returns NoOpRepository
- val cmd = buildCommand("One")
- val result = cmd.call()
- assertEquals(0, result)
- assertTrue(output().contains("One=1"))
- }
-
- @Test
- @Order(10)
- fun `R5 fhir version - evaluation succeeds`() {
- val cmd = buildCommand("One", fhirVersion = "R5")
- val result = cmd.call()
- assertEquals(0, result)
- assertTrue(output().contains("One=1"))
- }
-
- @Test
- @Order(11)
- fun `DSTU3 fhir version - evaluation succeeds`() {
- val cmd = buildCommand("One", fhirVersion = "DSTU3")
- val result = cmd.call()
- assertEquals(0, result)
- assertTrue(output().contains("One=1"))
- }
-
- @Test
- @Order(12)
- fun `invalid fhir version throws IllegalArgumentException`() {
- val cmd = buildCommand("One", fhirVersion = "INVALID")
- assertThrows { cmd.call() }
- }
-
- // -------------------------------------------------------------------------
- // Tests 16–17: additional execution paths
- // -------------------------------------------------------------------------
-
- @Test
- @Order(16)
- fun `expression filter with multiple expressions includes all specified`() {
- // Both "Two" and "Two List" are declared in Two.cql; requesting both explicitly
- // should keep both — the filter is an inclusion list, not an exclusion list.
- val cmd = buildCommand("Two", expressions = arrayOf("Two", "Two List"))
- cmd.call()
- val out = output()
- assertTrue(out.contains("Two=2"), "Expected 'Two=2' in filtered output")
- assertTrue(out.contains("Two List=[1, 2, 3]"), "Expected 'Two List' in filtered output")
- }
-
- @Test
- @Order(17)
- fun `multiple libraries - each is evaluated in the same call`() {
- // cmd.libraries holds two LibraryParameters; the for-loop should evaluate both
- // and print results for each, separated by a blank line.
- val cmd = CqlCommand()
- cmd.fhirVersion = "R4"
- val dir = cqlFixtureDirectoryUrl()
- val lib1 = CqlCommand.LibraryParameter()
- lib1.libraryUrl = dir
- lib1.libraryName = "One"
- val lib2 = CqlCommand.LibraryParameter()
- lib2.libraryUrl = dir
- lib2.libraryName = "Two"
- cmd.libraries = mutableListOf(lib1, lib2)
- assertEquals(0, cmd.call())
- val out = output()
- assertTrue(out.contains("One=1"), "Expected 'One=1' from first library")
- assertTrue(out.contains("Two=2"), "Expected 'Two=2' from second library")
- }
-
- // -------------------------------------------------------------------------
- // Tests 13–15: cross-platform path handling via Uris.parseOrNull()
- // No CQL engine involvement — pure unit tests of URI parsing behaviour.
- //
- // Platform behaviour matrix:
- // Format | Example | parseOrNull result
- // ------------------------------|-------------------------------|-------------------
- // Unix / macOS | file:///tmp/cql/ | non-null URI
- // Windows forward-slash | file:///C:/Users/cql/ | non-null URI (any platform)
- // Windows backslash | file:///C:\Users\cql\ | null (URISyntaxException)
- //
- // The backslash case documents a latent NPE in CqlCommand: libraryKotlinPath!! will
- // throw NullPointerException if a backslash path reaches libraryUrl.
- // -------------------------------------------------------------------------
-
- @Test
- @Order(13)
- fun `unix style file URI parses to valid path`() {
- val uri = Uris.parseOrNull("file:///tmp/cql/")
- assertNotNull(uri)
- assertEquals("file", uri!!.scheme)
- }
-
- @Test
- @Order(14)
- fun `windows forward slash URI parses to valid URI`() {
- val uri = Uris.parseOrNull("file:///C:/Users/cql/")
- assertNotNull(uri)
- assertEquals("file", uri!!.scheme)
- }
-
- @Test
- @Order(15)
- fun `windows backslash URI returns null from parseOrNull`() {
- // Backslashes are illegal in URIs per RFC 3986; URI(String) throws URISyntaxException,
- // which parseOrNull catches and converts to null. This documents the latent NPE risk:
- // CqlCommand line `libraryKotlinPath!!` will throw if this reaches libraryUrl.
- val uri = Uris.parseOrNull("file:///C:\\Users\\cql\\")
- assertNull(uri, "Backslash paths are not valid URIs; parseOrNull should return null")
- }
-
- // -----------------------------------------------------------------------
- // Tests 18–19: optionsPath and context parameter paths
- // -----------------------------------------------------------------------
-
- @Test
- @Order(18)
- fun `optionsPath with invalid URI returns 1`() {
- // Uris.parseOrNull returns null for an invalid URI (spaces, etc.),
- // causing CqlCommand.call() to log a warning and return 1.
- val cmd = buildCommand("One")
- cmd.optionsPath = "not a valid uri has spaces and %%% invalid chars"
- val result = cmd.call()
- assertEquals(1, result)
- }
-}
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContributionTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContributionTest.kt
deleted file mode 100644
index a75d17a7..00000000
--- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContributionTest.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-package org.opencds.cqf.cql.ls.server.command
-
-import com.google.gson.JsonElement
-import com.google.gson.JsonPrimitive
-import org.eclipse.lsp4j.ExecuteCommandParams
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Assertions.assertNotSame
-import org.junit.jupiter.api.Assertions.assertTrue
-import org.junit.jupiter.api.BeforeAll
-import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.assertDoesNotThrow
-import org.junit.jupiter.api.assertThrows
-import org.opencds.cqf.cql.ls.server.command.DebugCqlCommandContribution.Companion.START_DEBUG_COMMAND
-import org.opencds.cqf.cql.ls.server.manager.IgContextManager
-import org.opencds.cqf.cql.ls.server.service.TestContentService
-import java.io.ByteArrayOutputStream
-import java.io.PrintStream
-
-class DebugCqlCommandContributionTest {
- companion object {
- private lateinit var contribution: DebugCqlCommandContribution
-
- @BeforeAll
- @JvmStatic
- fun beforeAll() {
- val cs = TestContentService()
- contribution = DebugCqlCommandContribution(IgContextManager(cs))
- }
- }
-
- /**
- * Returns a file: URI pointing to the directory containing the CQL test fixtures.
- * Uses .toURI() so the path is correct on macOS/Linux and Windows.
- */
- private fun cqlFixtureDirectoryUrl(): String =
- DebugCqlCommandContributionTest::class.java
- .getResource("/org/opencds/cqf/cql/ls/server/One.cql")!!
- .toURI()
- .resolve(".")
- .toString()
-
- /** Wraps each string as a JsonPrimitive, matching the format executeCql() expects. */
- private fun jsonArgs(vararg args: String): List = args.map { JsonPrimitive(it) }
-
- private fun debugParams(vararg args: String): ExecuteCommandParams = ExecuteCommandParams(START_DEBUG_COMMAND, jsonArgs(*args))
-
- // -------------------------------------------------------------------------
- // Command registration
- // -------------------------------------------------------------------------
-
- @Test
- fun `getCommands returns the start debug session command`() {
- assertEquals(setOf(START_DEBUG_COMMAND), contribution.getCommands())
- }
-
- // -------------------------------------------------------------------------
- // Dispatch
- // -------------------------------------------------------------------------
-
- @Test
- fun `executeCommand with unknown command throws RuntimeException`() {
- val params = ExecuteCommandParams("org.unknown.command", emptyList())
- assertThrows { contribution.executeCommand(params) }
- }
-
- // -------------------------------------------------------------------------
- // Successful execution
- // -------------------------------------------------------------------------
-
- @Test
- fun `successful evaluation returns CQL output in the future result`() {
- val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "One")
- val result = contribution.executeCommand(params).join() as String
- assertTrue(result.contains("One=1"))
- }
-
- @Test
- fun `CQL output does not bleed to caller System dot out`() {
- // executeCql() redirects System.out internally; output should go to the future result,
- // not to whatever System.out the caller had set.
- val callerCapture = ByteArrayOutputStream()
- val originalOut = System.out
- System.setOut(PrintStream(callerCapture))
- try {
- val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "One")
- val result = contribution.executeCommand(params).join() as String
- assertEquals("", callerCapture.toString(), "CQL output should not appear in caller's stdout")
- assertTrue(result.contains("One=1"), "CQL output should be in the future result")
- } finally {
- System.setOut(originalOut)
- }
- }
-
- @Test
- fun `stdout and stderr are restored to console streams after successful execution`() {
- val preCallOut = System.out
- val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "One")
- contribution.executeCommand(params).join()
- // finally block always replaces System.out with new PrintStream(FileDescriptor.out)
- assertNotSame(preCallOut, System.out, "stdout should be replaced with a new console stream")
- assertDoesNotThrow { System.out.println("stdout is functional after execution") }
- }
-
- // -------------------------------------------------------------------------
- // Failed execution
- // -------------------------------------------------------------------------
-
- @Test
- fun `failed evaluation includes Evaluation logs section in result`() {
- // "NonExistentLibrary" is not present in the fixture directory; the engine will throw,
- // picocli will write the error to the redirected System.err, and executeCql() appends
- // it to the result under "Evaluation logs:".
- val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "NonExistentLibrary")
- val result = contribution.executeCommand(params).join() as String
- assertTrue(result.contains("Evaluation logs:"), "Expected 'Evaluation logs:' section when CQL evaluation fails")
- }
-
- @Test
- fun `stdout and stderr are restored after failed evaluation`() {
- val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "NonExistentLibrary")
- contribution.executeCommand(params).join()
- // finally block runs even on error paths
- assertDoesNotThrow { System.out.println("stdout still functional after failure") }
- assertDoesNotThrow { System.err.println("stderr still functional after failure") }
- }
-}
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepositoryTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepositoryTest.kt
new file mode 100644
index 00000000..00bcbe34
--- /dev/null
+++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepositoryTest.kt
@@ -0,0 +1,79 @@
+package org.opencds.cqf.cql.ls.server.command
+
+import ca.uhn.fhir.context.FhirContext
+import com.google.common.collect.ArrayListMultimap
+import org.hl7.fhir.r4.model.Bundle
+import org.hl7.fhir.r4.model.Patient
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.opencds.cqf.fhir.utility.repository.ProxyRepository
+
+class DelegatingRepositoryTest {
+ private val fhirContext = FhirContext.forR4Cached()
+
+ @Test
+ fun `fhirContext delegates to current`() {
+ val repo = DelegatingRepository(NoOpRepository(fhirContext))
+ assertEquals(fhirContext, repo.fhirContext())
+ }
+
+ @Test
+ fun `read delegates to current and throws when current is NoOpRepository`() {
+ val repo = DelegatingRepository(NoOpRepository(fhirContext))
+ assertThrows(UnsupportedOperationException::class.java) {
+ repo.read(Patient::class.java, Patient().apply { id = "1" }.idElement, emptyMap())
+ }
+ }
+
+ @Test
+ fun `search delegates to current and returns empty bundle from NoOpRepository`() {
+ val repo = DelegatingRepository(NoOpRepository(fhirContext))
+ val result = repo.search(Bundle::class.java, Patient::class.java, ArrayListMultimap.create(), emptyMap())
+ // NoOpRepository returns an empty bundle
+ assertTrue(result.entry.isEmpty())
+ }
+
+ @Test
+ fun `swapping current changes fhirContext`() {
+ val ctx1 = FhirContext.forR4Cached()
+ val ctx2 = FhirContext.forR5Cached()
+ val repo = DelegatingRepository(NoOpRepository(ctx1))
+ assertEquals(ctx1, repo.fhirContext())
+
+ repo.current = NoOpRepository(ctx2)
+ assertEquals(ctx2, repo.fhirContext())
+ }
+
+ @Test
+ fun `swapping current changes read behavior`() {
+ val repo = DelegatingRepository(NoOpRepository(fhirContext))
+ // First repo throws on read
+ assertThrows(UnsupportedOperationException::class.java) {
+ repo.read(Patient::class.java, Patient().apply { id = "1" }.idElement, emptyMap())
+ }
+
+ // Swap to a second NoOpRepository — still throws, but via the new delegate
+ val noOp2 = NoOpRepository(fhirContext)
+ repo.current = noOp2
+ assertThrows(UnsupportedOperationException::class.java) {
+ repo.read(Patient::class.java, Patient().apply { id = "1" }.idElement, emptyMap())
+ }
+ }
+
+ @Test
+ fun `ProxyRepository backed by DelegatingRepository reflects current swap`() {
+ val delegating = DelegatingRepository(NoOpRepository(fhirContext))
+ val proxy = ProxyRepository(delegating, delegating, delegating)
+
+ // Search via the proxy should pass through the delegate to NoOp (empty bundle)
+ val result = proxy.search(Bundle::class.java, Patient::class.java, ArrayListMultimap.create(), emptyMap())
+ assertTrue(result.entry.isEmpty())
+
+ // Swap to another NoOp — proxy still delegates correctly
+ delegating.current = NoOpRepository(fhirContext)
+ val result2 = proxy.search(Bundle::class.java, Patient::class.java, ArrayListMultimap.create(), emptyMap())
+ assertTrue(result2.entry.isEmpty())
+ }
+}
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContributionTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContributionTest.kt
new file mode 100644
index 00000000..4d5f26cc
--- /dev/null
+++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContributionTest.kt
@@ -0,0 +1,400 @@
+package org.opencds.cqf.cql.ls.server.command
+
+import com.google.gson.Gson
+import org.eclipse.lsp4j.ExecuteCommandParams
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+import org.opencds.cqf.cql.ls.server.manager.IgContextManager
+import org.opencds.cqf.cql.ls.server.service.TestContentService
+
+class ExecuteCqlCommandContributionTest {
+ companion object {
+ private lateinit var contribution: ExecuteCqlCommandContribution
+
+ @BeforeAll
+ @JvmStatic
+ fun beforeAll() {
+ val cs = TestContentService()
+ contribution = ExecuteCqlCommandContribution(IgContextManager(cs), cs)
+ }
+ }
+
+ @Test
+ fun `getCommands returns executeCql command`() {
+ assertEquals(setOf("org.opencds.cqf.cql.ls.executeCql"), contribution.getCommands())
+ }
+
+ @Test
+ fun `executeCommand returns structured response for One library`() {
+ // TestContentService resolves "One" from classpath regardless of the libraryUri root
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters = emptyList(),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(1, response.results.size)
+ assertEquals("One", response.results[0].libraryName)
+ assertTrue(
+ response.results[0].expressions.any { it.name == "One" },
+ "Expected expression 'One' in results",
+ )
+ assertTrue(response.logs.isEmpty())
+ }
+
+ @Test
+ fun `evaluates two patients for same library using one batch`() {
+ // Both entries share libraryName + libraryUri — they must land in the same batch and
+ // both results must be returned in order.
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters = emptyList(),
+ ),
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters = emptyList(),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(2, response.results.size)
+ assertEquals("One", response.results[0].libraryName)
+ assertEquals("One", response.results[1].libraryName)
+ assertTrue(response.results[0].expressions.any { it.name == "One" })
+ assertTrue(response.results[1].expressions.any { it.name == "One" })
+ }
+
+ @Test
+ fun `String parameter is passed to engine without type coercion error`() {
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters = listOf(ParameterRequest(parameterName = "Unused", parameterType = "String", parameterValue = "hello")),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ // The "One" library does not declare a "Unused" parameter, so the engine ignores it.
+ // The important invariant is that no exception is thrown during coercion or evaluation.
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+ assertEquals(1, response.results.size)
+ assertEquals("One", response.results[0].libraryName)
+ }
+
+ @Test
+ fun `null parameterType is accepted and treated as String`() {
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters = listOf(ParameterRequest(parameterName = "Unused", parameterType = null, parameterValue = "value")),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+ assertEquals(1, response.results.size)
+ }
+
+ @Test
+ fun `Interval_DateTime parameter is coerced to native Interval and evaluated correctly`() {
+ // WithParam.cql declares `parameter "Measurement Period" Interval` and
+ // defines `"Period Start": start of "Measurement Period"`. If the parameter is passed
+ // as a plain String instead of a native CqlInterval, the engine throws
+ // "Expected Start(Interval), Found Start(java.lang.String)".
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "WithParam",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters =
+ listOf(
+ ParameterRequest(
+ parameterName = "Measurement Period",
+ parameterType = "Interval",
+ parameterValue = "Interval[@2024-01-01T00:00:00.000Z, @2025-01-01T00:00:00.000Z)",
+ ),
+ ),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(1, response.results.size)
+ val expressions = response.results[0].expressions
+ val periodStart = expressions.find { it.name == "Period Start" }
+ assertNotNull(periodStart, "Expected 'Period Start' expression in results")
+ assertFalse(
+ expressions.any { it.name == "Error" },
+ "Unexpected Error expression in results: $expressions",
+ )
+ }
+
+ @Test
+ fun `parameter not in config is reported as usedDefaultParameter when CQL declares a default`() {
+ // WithParam.cql declares "Measurement Period" with a default expression.
+ // When no parameter is supplied via config, usedDefaultParameters should contain it.
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "WithParam",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters = emptyList(),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(1, response.results.size)
+ val defaultParam = response.results[0].usedDefaultParameters.find { it.name == "Measurement Period" }
+ assertNotNull(defaultParam, "Expected 'Measurement Period' in usedDefaultParameters, got: ${response.results[0].usedDefaultParameters}")
+ // The CQL default is Interval[@2023-01-01T00:00:00.000, @2024-01-01T00:00:00.000) — value must be non-null/non-empty
+ assertFalse(defaultParam!!.value.isBlank(), "Expected non-blank resolved value for default parameter, got: '${defaultParam.value}'")
+ assertEquals("default", defaultParam.source, "Expected source 'default' for a CQL-declared default parameter")
+ }
+
+ @Test
+ fun `parameter supplied via config is not reported as usedDefaultParameter`() {
+ // When the parameter IS supplied, it must NOT appear in usedDefaultParameters.
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "WithParam",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters =
+ listOf(
+ ParameterRequest(
+ parameterName = "Measurement Period",
+ parameterType = "Interval",
+ parameterValue = "Interval[@2024-01-01T00:00:00.000Z, @2025-01-01T00:00:00.000Z)",
+ ),
+ ),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(1, response.results.size)
+ assertFalse(
+ response.results[0].usedDefaultParameters.any { it.name == "Measurement Period" },
+ "Expected 'Measurement Period' NOT in usedDefaultParameters when explicitly provided",
+ )
+ }
+
+ @Test
+ fun `unrelated config param does not suppress usedDefaultParameter detection`() {
+ // Regression test: when config.json contains a parameter that the CQL library does NOT
+ // declare (e.g. "Measurement Period Sample"), the CQL-declared default ("Measurement Period")
+ // must still appear in usedDefaultParameters.
+ //
+ // Root cause: previously we used LibraryManager.resolveLibrary(unversionedIdentifier) to
+ // inspect ParameterDef entries. For libraries with a version declaration the engine stores
+ // the compiled library under a versioned VersionedIdentifier, so the unversioned lookup
+ // misses the cache and the inner try/catch silently returns emptyList().
+ // WithParam.cql has no version so the bug was masked in other tests.
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "WithParam",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ // Unrelated parameter not declared in WithParam.cql
+ parameters = listOf(ParameterRequest(parameterName = "Measurement Period Sample", parameterType = "String", parameterValue = "some-value")),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(1, response.results.size)
+ assertTrue(
+ response.results[0].usedDefaultParameters.any { it.name == "Measurement Period" },
+ "Expected 'Measurement Period' in usedDefaultParameters even when an unrelated param is in config, got: ${response.results[0].usedDefaultParameters}",
+ )
+ }
+
+ @Test
+ fun `malformed Interval_DateTime literal falls back gracefully without throwing`() {
+ // The coercion logs a warning and passes the raw string to the engine.
+ // The engine then produces an Error expression result — but no exception must propagate.
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "WithParam",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = null,
+ parameters =
+ listOf(
+ ParameterRequest(
+ parameterName = "Measurement Period",
+ parameterType = "Interval",
+ parameterValue = "NOT_AN_INTERVAL",
+ ),
+ ),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ // Must not throw
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+ assertEquals(1, response.results.size)
+ }
+
+ @Test
+ fun `error in one patient does not prevent evaluation of other patients`() {
+ // First entry has an invalid libraryUri — the evaluator catches the exception and
+ // returns an Error expression. The second (valid) entry must still be evaluated.
+ val request =
+ ExecuteCqlRequest(
+ fhirVersion = "R4",
+ rootDir = null,
+ optionsPath = null,
+ libraries =
+ listOf(
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = ModelRequest(modelName = "FHIR", modelUri = "file:///nonexistent/path/that/does/not/exist"),
+ context = ContextRequest(contextName = "Patient", contextValue = "bad-patient"),
+ parameters = emptyList(),
+ ),
+ LibraryRequest(
+ libraryName = "One",
+ libraryUri = "file:///any/path",
+ libraryVersion = null,
+ terminologyUri = null,
+ model = null,
+ context = ContextRequest(contextName = "Patient", contextValue = "good-patient"),
+ parameters = emptyList(),
+ ),
+ ),
+ )
+ val element = Gson().toJsonTree(request)
+ val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element))
+ val response = contribution.executeCommand(params).join() as ExecuteCqlResponse
+
+ assertEquals(2, response.results.size)
+ // Second patient — same library + libraryUri, so it shares the batch with the first.
+ // After the first patient errors out (bad model path), the engine may be in an
+ // indeterminate state; the important invariant is that both results are returned and
+ // neither call throws out of the command.
+ assertEquals("One", response.results[0].libraryName)
+ assertEquals("One", response.results[1].libraryName)
+ }
+}
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt
index c9998655..0cc9406b 100644
--- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt
+++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt
@@ -4,7 +4,9 @@ import org.cqframework.cql.cql2elm.CqlCompilerException
import org.hl7.elm.r1.VersionedIdentifier
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertNotSame
import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
@@ -23,6 +25,8 @@ class CqlCompilationManagerTest {
private val TWO_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/Two.cql")!!
private val SYNTAX_ERROR_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/SyntaxError.cql")!!
private val MISSING_INCLUDE_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/MissingInclude.cql")!!
+ private val FUNCTION_LIB_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/FunctionLib.cql")!!
+ private val FUNCTION_CALLER_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/FunctionCaller.cql")!!
@BeforeAll
@JvmStatic
@@ -131,4 +135,75 @@ class CqlCompilationManagerTest {
}
assertTrue(errors.isEmpty(), "Expected no errors when compiling One.cql from stream")
}
+
+ // -----------------------------------------------------------------------
+ // Compilation cache — cache hits and surgical invalidation
+ // Each test uses its own manager instance to avoid inter-test cache bleed.
+ // -----------------------------------------------------------------------
+
+ @Test
+ fun compile_cachedResult_returnsSameInstance() {
+ val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs))
+ val first = localManager.compile(ONE_URI)
+ val second = localManager.compile(ONE_URI)
+ assertSame(first, second, "Expected cache hit to return the same CqlCompiler instance")
+ }
+
+ @Test
+ fun invalidate_evictsChangedFile() {
+ val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs))
+ val first = localManager.compile(ONE_URI)
+ localManager.invalidate(ONE_URI)
+ val second = localManager.compile(ONE_URI)
+ assertNotSame(first, second, "Expected a new compiler instance after invalidation")
+ }
+
+ @Test
+ fun invalidate_evictsDependentFile_butNotUnrelated() {
+ val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs))
+ localManager.compile(FUNCTION_LIB_URI)
+ val caller = localManager.compile(FUNCTION_CALLER_URI)
+ val unrelated = localManager.compile(ONE_URI)
+
+ localManager.invalidate(FUNCTION_LIB_URI)
+
+ // FunctionCaller depends on FunctionLib → evicted → new instance
+ val callerAfter = localManager.compile(FUNCTION_CALLER_URI)
+ assertNotSame(caller, callerAfter, "Dependent file should be evicted when its dependency is invalidated")
+
+ // One.cql is unrelated → still cached → same instance
+ val unrelatedAfter = localManager.compile(ONE_URI)
+ assertSame(unrelated, unrelatedAfter, "Unrelated file should remain in cache")
+ }
+
+ @Test
+ fun compile_stream_populatesCache() {
+ val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs))
+ val stream = cs.read(ONE_URI)!!
+ val fromStream = localManager.compile(ONE_URI, stream)
+ val fromCache = localManager.compile(ONE_URI)
+ assertSame(fromStream, fromCache, "compile(uri) should return the instance cached by compile(uri, stream)")
+ }
+
+ @Test
+ fun invalidate_onUncachedUri_isNoOp() {
+ val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs))
+ // Should not throw — URI was never compiled
+ localManager.invalidate(ONE_URI)
+ // Cache miss → fresh compile
+ assertNotNull(localManager.compile(ONE_URI))
+ }
+
+ @Test
+ fun getDependentUris_returnsCallers() {
+ val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs))
+
+ val libIdentifier = localManager.compile(FUNCTION_LIB_URI)!!.compiledLibrary!!.library!!.identifier!!
+ localManager.compile(FUNCTION_CALLER_URI)
+
+ val deps = localManager.getDependentUris(libIdentifier)
+
+ assertTrue(FUNCTION_CALLER_URI in deps, "FunctionCaller should be listed as a dependent of FunctionLib")
+ assertFalse(ONE_URI in deps, "One.cql should not appear as a dependent of FunctionLib")
+ }
}
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/TypeResourceCacheTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/TypeResourceCacheTest.kt
new file mode 100644
index 00000000..65b9310b
--- /dev/null
+++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/TypeResourceCacheTest.kt
@@ -0,0 +1,83 @@
+package org.opencds.cqf.cql.ls.server.repository.ig.standard
+
+import ca.uhn.fhir.context.FhirContext
+import org.hl7.fhir.r4.model.Bundle
+import org.hl7.fhir.r4.model.Library
+import org.hl7.fhir.r4.model.ValueSet
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import org.opencds.cqf.fhir.test.Resources
+import org.opencds.cqf.fhir.utility.search.Searches
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Tests for the directory-level [IgStandardRepository.typeResourceCache].
+ *
+ * The cache prevents repeated [Files.walk] scans when [IgStandardRepository.search]
+ * is called multiple times for the same resource type (e.g., ValueSet lookups during
+ * terminology expansion across many patients).
+ */
+class TypeResourceCacheTest {
+ private val fhirContext = FhirContext.forR4Cached()
+
+ @TempDir
+ lateinit var tempDir: Path
+
+ private fun createRepository(): IgStandardRepository {
+ Resources.copyFromJar("/sampleIgs/ig/standard/directoryPerType/standard", tempDir)
+ return IgStandardRepository(fhirContext, tempDir)
+ }
+
+ @Test
+ fun `repeated search returns same results from cache`() {
+ val repo = createRepository()
+
+ val first = repo.search(Bundle::class.java, Library::class.java, Searches.ALL)
+ val second = repo.search(Bundle::class.java, Library::class.java, Searches.ALL)
+
+ assertEquals(first.entry.size, second.entry.size)
+ }
+
+ @Test
+ fun `new resource on disk is not visible until clearCache is called`() {
+ val repo = createRepository()
+
+ // Warm the cache for Library.
+ val beforeCount = repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size
+
+ // Write a new Library file directly to disk, bypassing the repo API so resourceCache
+ // is NOT updated — only the filesystem changes.
+ val libDir = tempDir.resolve("resources/library")
+ val newFile = libDir.resolve("Library-extra.json")
+ newFile.toFile().writeText("""{"resourceType":"Library","id":"extra","type":{"coding":[{"code":"logic-library"}]},"content":[]}""")
+
+ // Cache is still warm — new file is invisible to search().
+ val cachedCount = repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size
+ assertEquals(beforeCount, cachedCount, "typeResourceCache should hide the new file")
+
+ // After clearCache(), the directory is re-scanned and the new file is visible.
+ repo.clearCache()
+ val afterCount = repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size
+ assertEquals(beforeCount + 1, afterCount, "clearCache() should expose the newly written file")
+ }
+
+ @Test
+ fun `cache is independent per resource type`() {
+ val repo = createRepository()
+
+ val libraries = repo.search(Bundle::class.java, Library::class.java, Searches.ALL)
+ val valueSets = repo.search(Bundle::class.java, ValueSet::class.java, Searches.ALL)
+
+ // Both types cached independently — counts are not mixed.
+ assertEquals(
+ repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size,
+ libraries.entry.size,
+ )
+ assertEquals(
+ repo.search(Bundle::class.java, ValueSet::class.java, Searches.ALL).entry.size,
+ valueSets.entry.size,
+ )
+ }
+}
diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt
index efacc77b..58803979 100644
--- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt
+++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt
@@ -2,8 +2,11 @@ package org.opencds.cqf.cql.ls.server.service
import org.eclipse.lsp4j.Diagnostic
import org.eclipse.lsp4j.DiagnosticSeverity
+import org.eclipse.lsp4j.DidChangeWatchedFilesParams
import org.eclipse.lsp4j.DidCloseTextDocumentParams
import org.eclipse.lsp4j.DidOpenTextDocumentParams
+import org.eclipse.lsp4j.FileChangeType
+import org.eclipse.lsp4j.FileEvent
import org.eclipse.lsp4j.Position
import org.eclipse.lsp4j.PublishDiagnosticsParams
import org.eclipse.lsp4j.Range
@@ -20,6 +23,7 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.opencds.cqf.cql.ls.core.utility.Uris
+import org.opencds.cqf.cql.ls.server.event.DidChangeWatchedFilesEvent
import org.opencds.cqf.cql.ls.server.event.DidCloseTextDocumentEvent
import org.opencds.cqf.cql.ls.server.event.DidOpenTextDocumentEvent
import org.opencds.cqf.cql.ls.server.manager.CompilerOptionsManager
@@ -214,6 +218,34 @@ class DiagnosticsServiceTest {
// debounce() — task execution and cancellation
// -------------------------------------------------------------------------
+ @Test
+ fun didChangeWatchedFiles_publishesDiagnosticsForCqlFiles() {
+ val mockClient = Mockito.mock(LanguageClient::class.java)
+ val svc = buildService(mockClient)
+
+ val fileEvent = FileEvent("/org/opencds/cqf/cql/ls/server/One.cql", FileChangeType.Changed)
+ val params = DidChangeWatchedFilesParams(listOf(fileEvent))
+ svc.didChangeWatchedFiles(DidChangeWatchedFilesEvent(params))
+
+ Mockito.verify(mockClient, Mockito.atLeastOnce()).publishDiagnostics(Mockito.any())
+ }
+
+ @Test
+ fun didChangeWatchedFiles_ignoresNonCqlFiles() {
+ val mockClient = Mockito.mock(LanguageClient::class.java)
+ val svc = buildService(mockClient)
+
+ val fileEvent = FileEvent("/some/path/options.json", FileChangeType.Changed)
+ val params = DidChangeWatchedFilesParams(listOf(fileEvent))
+ svc.didChangeWatchedFiles(DidChangeWatchedFilesEvent(params))
+
+ Mockito.verify(mockClient, Mockito.never()).publishDiagnostics(Mockito.any())
+ }
+
+ // -------------------------------------------------------------------------
+ // debounce() — task execution and cancellation
+ // -------------------------------------------------------------------------
+
@Test
fun debounce_taskExecutesAfterDelay() {
val svc = buildService()
diff --git a/ls/server/src/test/resources/logback-test.xml b/ls/server/src/test/resources/logback-test.xml
new file mode 100644
index 00000000..f0aa031a
--- /dev/null
+++ b/ls/server/src/test/resources/logback-test.xml
@@ -0,0 +1,13 @@
+
+
+
+ System.err
+
+ %-4relative [%thread] %-5level %logger{35} %msg %n
+
+
+
+
+
+
+
diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionCaller.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionCaller.cql
new file mode 100644
index 00000000..1e9c201a
--- /dev/null
+++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionCaller.cql
@@ -0,0 +1,9 @@
+library FunctionCaller version '1.0.0'
+
+include FunctionLib version '1.0.0' called FL
+
+define "UseValue":
+ FL."MyValue" + 1
+
+define "UseFunction":
+ FL."Double"(3)
diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionLib.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionLib.cql
new file mode 100644
index 00000000..bddff9e7
--- /dev/null
+++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionLib.cql
@@ -0,0 +1,7 @@
+library FunctionLib version '1.0.0'
+
+define "MyValue":
+ 42
+
+define function "Double"(x Integer):
+ x * 2
diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/WithParam.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/WithParam.cql
new file mode 100644
index 00000000..da1573cd
--- /dev/null
+++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/WithParam.cql
@@ -0,0 +1,6 @@
+library WithParam
+
+parameter "Measurement Period" Interval
+ default Interval[@2023-01-01T00:00:00.000, @2024-01-01T00:00:00.000)
+
+define "Period Start": start of "Measurement Period"
diff --git a/pom.xml b/pom.xml
index b2f08076..d7ea7b3a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.opencds.cqf.cql.ls
cql-ls
pom
- 4.5.0-SNAPSHOT
+ 4.5.0
CQL Language Server
A Language Server for CQL implementing the LSP
@@ -24,7 +24,6 @@
4.2.0
1.2.13
1.7.36
- 4.6.1
2.18.6
2.18.6
2.24.1