diff --git a/CHANGELOG.md b/CHANGELOG.md index b1589b7..070c62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Change Log +## v0.9.3 + +Date: 2026-04-15 + +### Execute CQL — RPC refactor + +Rewrites Execute CQL to use the language server's JSON-RPC command instead of the +previous CLI argument array approach. + +* Sends a structured `ExecuteCqlRequest` (library name, model URI, context, parameters) +* Receives structured `ExecuteCqlResponse` with typed expression results and server logs +* Removes CLI argument construction (`CliCommand`, picocli dependency) + +### Execute CQL — optimization and result formats + +* **Individual result files** (default) — writes + `input/tests/results/{LibraryName}/TestCaseResult-{patientId}.json` per test case; opens the + file automatically when a single test case is selected +* **Flat format** — set `"resultFormat": "flat"` in `config.json` to write a single + `input/tests/results/{LibraryName}.txt` per library (previous behavior) +* **User-defined parameters** — add a `"parameters"` block to `config.json` to pass typed + parameter overrides to the CQL engine; per-test-case overrides supported via `testCases` map +* **Select test cases** — new command `cql.editor.execute.select-test-cases` opens a quick-pick + to run a subset of test cases for a library +* **Select libraries** — `cql.execute.select-libraries` runs multiple libraries in sequence with + a progress notification showing per-library timing +* **CQL Explorer result nodes** — result files appear as child nodes under each test case in the + CQL Explorer tree; a dual watcher monitors both the test directory and results directory +* `config.json` schema registered — enables IntelliSense for `resultFormat`, `parameters`, + and `testCasesToExclude` in VS Code + +### Bug fixes + +* Fixed "file is newer" conflict when executing CQL in flat format with the output file + already open in VS Code — the output file is no longer truncated on disk before opening; + stale in-memory content is cleared via an in-memory edit instead + ## v0.9.2 (prerelease) Date: 2026-03-31 diff --git a/package-lock.json b/package-lock.json index 8ef35ac..65a08d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "cql", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cql", - "version": "0.9.2", + "version": "0.9.3", "license": "Apache-2.0", "dependencies": { "expand-tilde": "^2.0.2", "find-java-home": "1.2.2", "fs-extra": "^8.1.0", "glob": "^10.5.0", - "lodash": "^4.17.23", + "jsonc-parser": "^3.3.1", + "lodash": "^4.18.1", "markdown-to-text": "^0.1.1", "node-fetch": "2.6.7", "tslib": "^2.4.0", @@ -2615,6 +2616,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -2670,9 +2677,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.flattendeep": { @@ -6717,6 +6724,11 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -6761,9 +6773,9 @@ } }, "lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "lodash.flattendeep": { "version": "4.4.0", diff --git a/package.json b/package.json index 198e9a1..90adddb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cql", - "version": "0.9.2", + "version": "0.9.3", "displayName": "Clinical Quality Language (CQL)", "description": "Syntax highlighting, linting, and execution for the HL7 Clinical Quality Language (CQL) for VS Code", "publisher": "cqframework", @@ -63,10 +63,6 @@ "path": "./syntaxes/cql.tmLanguage.json" } ], - "configuration": { - "type": "object", - "title": "CQL" - }, "commands": [ { "command": "cql.editor.execute", @@ -96,17 +92,17 @@ }, { "command": "cql.open.server-log", - "title": "Open Server Logs", + "title": "Show CQL Language Server Logs", "category": "CQL" }, { "command": "cql.open.client-log", - "title": "Open Client Logs", + "title": "Show CQL Extension Logs", "category": "CQL" }, { "command": "cql.open.logs", - "title": "Open Logs", + "title": "Show CQL Extension and Language Server Logs", "category": "CQL" }, { @@ -147,19 +143,19 @@ }, { "command": "cql.explorer.sort-asc", - "title": "Sort A → Z", + "title": "Sort A \u2192 Z", "icon": "$(arrow-up)", "category": "CQL Explorer" }, { "command": "cql.explorer.sort-desc", - "title": "Sort Z → A", + "title": "Sort Z \u2192 A", "icon": "$(arrow-down)", "category": "CQL Explorer" }, { "command": "cql.explorer.library.execute-all", - "title": "Execute CQL - All Libraries", + "title": "Execute CQL - Filtered Libraries", "icon": "$(run-all)", "category": "CQL Explorer" }, @@ -290,38 +286,134 @@ ], "menus": { "commandPalette": [ - { "command": "cql.editor.execute.select-test-cases", "when": "false" }, - { "command": "cql.editor.view-elm.json", "when": "false" }, - { "command": "cql.editor.view-elm.xml", "when": "false" }, - { "command": "cql.explorer.refresh", "when": "false" }, - { "command": "cql.explorer.library.execute", "when": "false" }, - { "command": "cql.explorer.library.elm.json", "when": "false" }, - { "command": "cql.explorer.library.elm.xml", "when": "false" }, - { "command": "cql.explorer.test-case.execute", "when": "false" }, - { "command": "cql.explorer.test-case.open-resources", "when": "false" }, - { "command": "cql.explorer.test-case.clone", "when": "false" }, - { "command": "cql.explorer.resource.fix-references", "when": "false" }, - { "command": "cql.explorer.resource.rename", "when": "false" }, - { "command": "cql.explorer.resource.copy", "when": "false" }, - { "command": "cql.explorer.resource.cut", "when": "false" }, - { "command": "cql.explorer.resource.delete", "when": "false" }, - { "command": "cql.explorer.test-case.paste", "when": "false" }, - { "command": "cql.explorer.test-case.paste-enhanced", "when": "false" }, - { "command": "cql.explorer.test-case.delete", "when": "false" }, - { "command": "cql.explorer.library.execute-all", "when": "false" }, - { "command": "cql.explorer.hide-empty", "when": "false" }, - { "command": "cql.explorer.show-all", "when": "false" }, - { "command": "cql.explorer.sort-asc", "when": "false" }, - { "command": "cql.explorer.sort-desc", "when": "false" }, - { "command": "cql.explorer.filter-by-name", "when": "false" }, - { "command": "cql.explorer.clear-name-filter", "when": "false" }, - { "command": "cql.explorer.test-case.filter", "when": "false" }, - { "command": "cql.explorer.test-case.clear-filter", "when": "false" }, - { "command": "cql.explorer.test-case.execute-select", "when": "false" }, - { "command": "cql.explorer.test-case.execute-all", "when": "false" }, -{ "command": "cql.explorer.expand-all", "when": "false" }, - { "command": "cql.explorer.show-layout-warnings", "when": "false" }, - { "command": "cql.explorer.hide-layout-warnings", "when": "false" } + { + "command": "cql.editor.execute.select-test-cases", + "when": "false" + }, + { + "command": "cql.editor.view-elm.json", + "when": "false" + }, + { + "command": "cql.editor.view-elm.xml", + "when": "false" + }, + { + "command": "cql.explorer.refresh", + "when": "false" + }, + { + "command": "cql.explorer.library.execute", + "when": "false" + }, + { + "command": "cql.explorer.library.elm.json", + "when": "false" + }, + { + "command": "cql.explorer.library.elm.xml", + "when": "false" + }, + { + "command": "cql.explorer.test-case.execute", + "when": "false" + }, + { + "command": "cql.explorer.test-case.open-resources", + "when": "false" + }, + { + "command": "cql.explorer.test-case.clone", + "when": "false" + }, + { + "command": "cql.explorer.resource.fix-references", + "when": "false" + }, + { + "command": "cql.explorer.resource.rename", + "when": "false" + }, + { + "command": "cql.explorer.resource.copy", + "when": "false" + }, + { + "command": "cql.explorer.resource.cut", + "when": "false" + }, + { + "command": "cql.explorer.resource.delete", + "when": "false" + }, + { + "command": "cql.explorer.test-case.paste", + "when": "false" + }, + { + "command": "cql.explorer.test-case.paste-enhanced", + "when": "false" + }, + { + "command": "cql.explorer.test-case.delete", + "when": "false" + }, + { + "command": "cql.explorer.library.execute-all", + "when": "false" + }, + { + "command": "cql.explorer.hide-empty", + "when": "false" + }, + { + "command": "cql.explorer.show-all", + "when": "false" + }, + { + "command": "cql.explorer.sort-asc", + "when": "false" + }, + { + "command": "cql.explorer.sort-desc", + "when": "false" + }, + { + "command": "cql.explorer.filter-by-name", + "when": "false" + }, + { + "command": "cql.explorer.clear-name-filter", + "when": "false" + }, + { + "command": "cql.explorer.test-case.filter", + "when": "false" + }, + { + "command": "cql.explorer.test-case.clear-filter", + "when": "false" + }, + { + "command": "cql.explorer.test-case.execute-select", + "when": "false" + }, + { + "command": "cql.explorer.test-case.execute-all", + "when": "false" + }, + { + "command": "cql.explorer.expand-all", + "when": "false" + }, + { + "command": "cql.explorer.show-layout-warnings", + "when": "false" + }, + { + "command": "cql.explorer.hide-layout-warnings", + "when": "false" + } ], "editor/context": [ { @@ -522,6 +614,30 @@ "name": "CQL Explorer" } ] + }, + "jsonValidation": [ + { + "fileMatch": "**/input/tests/config.jsonc", + "url": "./schemas/cql-config.schema.json" + } + ], + "configuration": { + "title": "CQL", + "properties": { + "cql.execute.resultFormat": { + "type": "string", + "enum": [ + "individual", + "flat" + ], + "enumDescriptions": [ + "Save one JSON result file per test case to input/tests/results/{libraryName}/TestCaseResult-{patientId}.json", + "Save all test case results for a library to a single text file at input/tests/results/{libraryName}.txt" + ], + "default": "individual", + "description": "Controls how Execute CQL results are saved." + } + } } }, "resolutions": { @@ -538,8 +654,8 @@ "javaDependencies": { "cql-language-server": { "groupId": "org.opencds.cqf.cql.ls", - "artifactId": "cql-ls-service", - "version": "4.4.1" + "artifactId": "cql-ls-server", + "version": "4.5.0" } }, "devDependencies": { @@ -547,8 +663,8 @@ "@types/expand-tilde": "^2.0.0", "@types/fs-extra": "^9.0.13", "@types/lodash": "^4.14.183", - "@types/mock-fs": "^4.13.4", "@types/mocha": "^10.0.10", + "@types/mock-fs": "^4.13.4", "@types/node": "^16.18.34", "@types/node-fetch": "^2.6.2", "@types/vscode": "^1.73.0", @@ -556,8 +672,8 @@ "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.3.6", "chai": "4.5", - "mock-fs": "^5.5.0", "mocha": "^11.7.5", + "mock-fs": "^5.5.0", "nyc": "^17.1.0", "prettier": "^3.3.3", "ts-node": "^10.9.2", @@ -565,11 +681,12 @@ }, "dependencies": { "expand-tilde": "^2.0.2", - "markdown-to-text": "^0.1.1", "find-java-home": "1.2.2", "fs-extra": "^8.1.0", "glob": "^10.5.0", - "lodash": "^4.17.23", + "jsonc-parser": "^3.3.1", + "lodash": "^4.18.1", + "markdown-to-text": "^0.1.1", "node-fetch": "2.6.7", "tslib": "^2.4.0", "vscode-languageclient": "^7.0.0", diff --git a/schemas/cql-config.schema.json b/schemas/cql-config.schema.json new file mode 100644 index 0000000..e1cb3d9 --- /dev/null +++ b/schemas/cql-config.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CQL Test Configuration", + "description": "Configuration for CQL test execution: test case exclusions and runtime parameter overrides.", + "type": "object", + "additionalProperties": false, + "properties": { + "testCasesToExclude": { + "description": "Test cases to skip during execution.", + "type": "array", + "items": { + "type": "object", + "required": ["library", "testCase", "reason"], + "additionalProperties": false, + "properties": { + "library": { "type": "string", "description": "Library name." }, + "testCase": { "type": "string", "description": "Patient UUID of the test case to exclude." }, + "reason": { "type": "string", "description": "Why this test case is excluded." } + } + } + }, + "parameters": { + "description": "Runtime parameter overrides. Each entry is either a global parameter (no 'library' field) or a library-scoped block (has 'library' field). Merge order: global → library → testCase (highest priority wins).", + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/definitions/parameterEntry" }, + { "$ref": "#/definitions/libraryParameterBlock" } + ] + } + } + }, + "definitions": { + "parameterEntry": { + "type": "object", + "required": ["name", "type", "value"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The CQL parameter name as declared in the library (e.g. \"Measurement Period\")." + }, + "type": { + "type": "string", + "description": "The CQL type of the parameter.", + "enum": [ + "String", + "Integer", + "Decimal", + "Boolean", + "DateTime", + "Date", + "Time", + "Interval", + "Interval", + "Quantity" + ] + }, + "value": { + "type": "string", + "description": "The parameter value as a string. For String/Integer/Decimal/Boolean: raw value (e.g. \"HMO\", \"42\", \"true\"). For DateTime/Date/Time: CQL literal (e.g. \"@2024-01-15\"). For Interval: CQL literal (e.g. \"Interval[@2024-01-01T00:00:00.000Z, @2025-01-01T00:00:00.000Z)\")." + } + } + }, + "libraryParameterBlock": { + "type": "object", + "required": ["library"], + "additionalProperties": false, + "properties": { + "library": { + "type": "string", + "description": "The CQL library name these parameters apply to." + }, + "version": { + "type": "string", + "description": "Optional. When specified, parameters only apply to this exact library version. Omit to match any version." + }, + "parameters": { + "description": "Library-scoped parameter overrides — applied to all test cases for this library.", + "type": "array", + "items": { "$ref": "#/definitions/parameterEntry" } + }, + "testCases": { + "description": "Test-case-scoped parameter overrides. Keys are patient UUIDs (directory names under input/tests/{MeasureName}/). Highest priority — overrides both global and library entries.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "$ref": "#/definitions/parameterEntry" } + } + } + } + } + } +} diff --git a/src/__test__/resources/simple-project/input/tests/Measure/SimpleMeasure/shared/Organization-stub.json b/src/__test__/resources/simple-project/input/tests/Measure/SimpleMeasure/shared/Organization-stub.json new file mode 100644 index 0000000..d1ce3b0 --- /dev/null +++ b/src/__test__/resources/simple-project/input/tests/Measure/SimpleMeasure/shared/Organization-stub.json @@ -0,0 +1,5 @@ +{ + "resourceType": "Organization", + "id": "stub-org", + "name": "Stub Organization" +} diff --git a/src/__test__/suite/commands/execute-cql.test.ts b/src/__test__/suite/commands/execute-cql.test.ts index 3a855e6..4f5c7b0 100644 --- a/src/__test__/suite/commands/execute-cql.test.ts +++ b/src/__test__/suite/commands/execute-cql.test.ts @@ -4,11 +4,17 @@ import * as os from 'os'; import * as path from 'path'; import { Uri } from 'vscode'; import { + formatResponse, getExcludedTestCases, getFhirVersion, getLibraries, loadTestConfig, + resolveTestConfigPath, + TestCaseResult, + writeIndividualResultFiles, } from '../../../commands/execute-cql'; +import { ExecuteCqlResponse } from '../../../cql-service/cqlService.executeCql'; +import { CqlParametersConfig } from '../../../model/parameters'; import { TestCaseExclusion } from '../../../model/testCase'; suite('getFhirVersion()', () => { @@ -46,6 +52,42 @@ suite('getFhirVersion()', () => { }); }); +suite('getFhirVersion() — QICore / USCore', () => { + test('returns R4 for QICore 6.x', () => { + expect(getFhirVersion("using QICore version '6.0.0'")).to.equal('R4'); + }); + + test('returns R4 for QICore 4.x', () => { + expect(getFhirVersion("using QICore version '4.1.1'")).to.equal('R4'); + }); + + test('returns DSTU3 for QICore 3.x', () => { + expect(getFhirVersion("using QICore version '3.3.0'")).to.equal('DSTU3'); + }); + + test('handles quoted QICore keyword', () => { + expect(getFhirVersion('using "QICore" version \'6.0.0\'')).to.equal('R4'); + }); + + test('returns R4 for USCore', () => { + expect(getFhirVersion("using USCore version '6.1.0'")).to.equal('R4'); + }); + + test('handles quoted USCore keyword', () => { + expect(getFhirVersion('using "USCore" version \'6.1.0\'')).to.equal('R4'); + }); + + test('prefers FHIR declaration over QICore when both present', () => { + const content = "using FHIR version '4.0.1'\nusing QICore version '6.0.0'"; + expect(getFhirVersion(content)).to.equal('R4'); + }); + + test('extracts QICore from multi-line CQL content', () => { + const content = `library AHAOverall version '1.0.000'\n\nusing QICore version '6.0.0'\nusing USCore version '6.1.0'`; + expect(getFhirVersion(content)).to.equal('R4'); + }); +}); + suite('getExcludedTestCases()', () => { const exclusions: TestCaseExclusion[] = [ { library: 'FooLib', testCase: 'TC1', reason: 'bug #1' }, @@ -108,6 +150,174 @@ suite('loadTestConfig()', () => { const result = loadTestConfig(Uri.file(configPath)); expect(result.testCasesToExclude).to.deep.equal([]); }); + + test('returns undefined parameters when config has no parameters field', () => { + const config = { testCasesToExclude: [] }; + const configPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config)); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.parameters).to.be.undefined; + }); + + test('returns parsed parameters when config includes parameters field', () => { + const config = { + testCasesToExclude: [], + parameters: [ + { name: 'Measurement Period', type: 'Interval', value: 'Interval[@2024-01-01, @2024-12-31]' }, + ], + }; + const configPath = path.join(tmpDir, 'config.jsonc'); + fs.writeFileSync(configPath, JSON.stringify(config)); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.parameters).to.have.length(1); + expect((result.parameters![0] as { name: string }).name).to.equal('Measurement Period'); + }); + + test('returns undefined resultFormat when config has no resultFormat field', () => { + const config = { testCasesToExclude: [] }; + const configPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config)); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.resultFormat).to.be.undefined; + }); + + test('returns individual resultFormat when config specifies individual', () => { + const config = { testCasesToExclude: [], resultFormat: 'individual' }; + const configPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config)); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.resultFormat).to.equal('individual'); + }); + + test('returns flat resultFormat when config specifies flat', () => { + const config = { testCasesToExclude: [], resultFormat: 'flat' }; + const configPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config)); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.resultFormat).to.equal('flat'); + }); + + test('strips JSONC comments when parsing', () => { + const jsonc = `{ + // this is a comment + "testCasesToExclude": [], + "parameters": [ + /* block comment */ + { "name": "Measurement Period", "type": "Interval", "value": "Interval[@2024-01-01, @2024-12-31]" } + ] + }`; + const configPath = path.join(tmpDir, 'config.jsonc'); + fs.writeFileSync(configPath, jsonc); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.parameters).to.have.length(1); + }); +}); + +suite('resolveTestConfigPath()', () => { + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-cql-test-')); + }); + + teardown(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns config.jsonc when both config.jsonc and config.json exist', () => { + fs.writeFileSync(path.join(tmpDir, 'config.jsonc'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}'); + const result = resolveTestConfigPath(Uri.file(tmpDir)); + expect(result.fsPath.endsWith('config.jsonc')).to.be.true; + }); + + test('returns config.jsonc when only config.jsonc exists', () => { + fs.writeFileSync(path.join(tmpDir, 'config.jsonc'), '{}'); + const result = resolveTestConfigPath(Uri.file(tmpDir)); + expect(result.fsPath.endsWith('config.jsonc')).to.be.true; + }); + + test('falls back to config.json when config.jsonc does not exist', () => { + fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}'); + const result = resolveTestConfigPath(Uri.file(tmpDir)); + expect(result.fsPath.endsWith('config.json')).to.be.true; + }); + + test('returns config.json when neither file exists', () => { + const result = resolveTestConfigPath(Uri.file(tmpDir)); + expect(result.fsPath.endsWith('config.json')).to.be.true; + }); +}); + +suite('formatResponse()', () => { + test('formats single test case as Name=value with no header', () => { + const response: ExecuteCqlResponse = { + results: [ + { + libraryName: 'MyLib', + expressions: [ + { name: 'Initial Population', value: '[Encounter(id=abc)]' }, + { name: 'Numerator', value: '[]' }, + ], + }, + ], + logs: [], + }; + const output = formatResponse(response); + expect(output).to.equal('Initial Population=[Encounter(id=abc)]\nNumerator=[]'); + }); + + test('separates multiple test cases with a blank line', () => { + const response: ExecuteCqlResponse = { + results: [ + { + libraryName: 'MyLib', + expressions: [{ name: 'Initial Population', value: '[Encounter(id=a)]' }], + }, + { + libraryName: 'MyLib', + expressions: [{ name: 'Initial Population', value: '[Encounter(id=b)]' }], + }, + ], + logs: [], + }; + const output = formatResponse(response); + expect(output).to.equal( + 'Initial Population=[Encounter(id=a)]\n\nInitial Population=[Encounter(id=b)]', + ); + }); + + test('appends Evaluation logs section with blank line when logs are present', () => { + const response: ExecuteCqlResponse = { + results: [ + { + libraryName: 'MyLib', + expressions: [{ name: 'Numerator', value: '[]' }], + }, + ], + logs: ['INFO some log message', 'WARN another message'], + }; + const output = formatResponse(response); + expect(output).to.equal( + 'Numerator=[]\n\nEvaluation logs:\nINFO some log message\nWARN another message', + ); + }); + + test('omits Evaluation logs section when logs are empty', () => { + const response: ExecuteCqlResponse = { + results: [ + { libraryName: 'MyLib', expressions: [{ name: 'Numerator', value: '[]' }] }, + ], + logs: [], + }; + const output = formatResponse(response); + expect(output).to.not.include('Evaluation logs'); + }); + + test('returns empty string for response with no results', () => { + const response: ExecuteCqlResponse = { results: [], logs: [] }; + expect(formatResponse(response)).to.equal(''); + }); }); suite('getLibraries()', () => { @@ -152,3 +362,174 @@ suite('getLibraries()', () => { expect(path.basename(result[0].fsPath)).to.equal('LibA.cql'); }); }); + +suite('writeIndividualResultFiles()', () => { + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-cql-test-')); + }); + + teardown(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function readResult(libraryName: string, patientId: string): TestCaseResult { + const filePath = path.join(tmpDir, libraryName, `TestCaseResult-${patientId}.json`); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as TestCaseResult; + } + + test('writes one JSON file per test case', () => { + const response: ExecuteCqlResponse = { + results: [ + { libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[Encounter(id=a)]' }] }, + { libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }, + ], + logs: [], + }; + const testCases = [ + { name: 'patient-1', path: undefined }, + { name: 'patient-2', path: undefined }, + ]; + + writeIndividualResultFiles('MyLib', undefined, testCases, response, Uri.file(tmpDir), Date.now()); + + expect(fs.existsSync(path.join(tmpDir, 'MyLib', 'TestCaseResult-patient-1.json'))).to.be.true; + expect(fs.existsSync(path.join(tmpDir, 'MyLib', 'TestCaseResult-patient-2.json'))).to.be.true; + }); + + test('separates errors from results', () => { + const response: ExecuteCqlResponse = { + results: [ + { + libraryName: 'MyLib', + expressions: [ + { name: 'IPP', value: '[]' }, + { name: 'Error', value: 'Something went wrong' }, + ], + }, + ], + logs: [], + }; + + writeIndividualResultFiles('MyLib', undefined, [{ name: 'p1' }], response, Uri.file(tmpDir), Date.now()); + + const result = readResult('MyLib', 'p1'); + expect(result.results).to.deep.equal([{ name: 'IPP', value: '[]' }]); + expect(result.errors).to.deep.equal(['Something went wrong']); + }); + + test('includes libraryName, testCaseName, and executedAt', () => { + const executedAt = new Date('2026-04-07T12:00:00.000Z').getTime(); + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }], + logs: [], + }; + + writeIndividualResultFiles('MyLib', undefined, [{ name: 'p1' }], response, Uri.file(tmpDir), executedAt); + + const result = readResult('MyLib', 'p1'); + expect(result.libraryName).to.equal('MyLib'); + expect(result.testCaseName).to.equal('p1'); + expect(result.executedAt).to.equal('2026-04-07T12:00:00.000Z'); + }); + + test('uses no-context as patientId when testCase name is undefined', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: 'null' }] }], + logs: [], + }; + + writeIndividualResultFiles('MyLib', undefined, [{}], response, Uri.file(tmpDir), Date.now()); + + expect(fs.existsSync(path.join(tmpDir, 'MyLib', 'TestCaseResult-no-context.json'))).to.be.true; + const result = readResult('MyLib', 'no-context'); + expect(result.testCaseName).to.be.null; + }); + + test('default parameters are appended to parameters with source=default', () => { + const response: ExecuteCqlResponse = { + results: [ + { + libraryName: 'MyLib', + expressions: [{ name: 'IPP', value: '[]' }], + usedDefaultParameters: [{ name: 'Measurement Period', value: 'Interval[2023-01-01, 2024-01-01)', source: 'default' }], + }, + ], + logs: [], + }; + writeIndividualResultFiles('MyLib', undefined, [{ name: 'p1' }], response, Uri.file(tmpDir), Date.now()); + const result = readResult('MyLib', 'p1'); + expect(result.parameters).to.have.length(1); + expect(result.parameters[0]).to.deep.equal({ name: 'Measurement Period', value: 'Interval[2023-01-01, 2024-01-01)', source: 'default' }); + }); + + test('parameters is empty when no config and no defaults', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }], + logs: [], + }; + writeIndividualResultFiles('MyLib', undefined, [{ name: 'p1' }], response, Uri.file(tmpDir), Date.now()); + const result = readResult('MyLib', 'p1'); + expect(result.parameters).to.deep.equal([]); + }); + + test('config params appear before defaults and both have correct source', () => { + const parametersConfig: CqlParametersConfig = [ + { name: 'Measurement Period', type: 'Interval', value: 'Interval[@2024-01-01, @2024-12-31]' }, + { library: 'MyLib', parameters: [{ name: 'Product Line', type: 'String', value: 'HMO' }] }, + ]; + const response: ExecuteCqlResponse = { + results: [{ + libraryName: 'MyLib', + expressions: [{ name: 'IPP', value: '[]' }], + usedDefaultParameters: [{ name: 'Some Default', value: '42', source: 'default' }], + }], + logs: [], + }; + writeIndividualResultFiles('MyLib', undefined, [{ name: 'p1' }], response, Uri.file(tmpDir), Date.now(), parametersConfig); + const result = readResult('MyLib', 'p1'); + expect(result.parameters).to.have.length(3); + const mp = result.parameters.find((p: { name: string }) => p.name === 'Measurement Period'); + const pl = result.parameters.find((p: { name: string }) => p.name === 'Product Line'); + const sd = result.parameters.find((p: { name: string }) => p.name === 'Some Default'); + expect(mp?.source).to.equal('config-global'); + expect(pl?.source).to.equal('config-library'); + expect(sd?.source).to.equal('default'); + }); + + test('test-case-level override reflected in combined parameters', () => { + const patientId = 'patient-uuid-abc'; + const parametersConfig: CqlParametersConfig = [ + { name: 'Product Line', type: 'String', value: 'HMO' }, + { library: 'MyLib', testCases: { [patientId]: [{ name: 'Product Line', type: 'String', value: 'Medicaid' }] } }, + ]; + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }], + logs: [], + }; + writeIndividualResultFiles('MyLib', undefined, [{ name: patientId }], response, Uri.file(tmpDir), Date.now(), parametersConfig); + const result = readResult('MyLib', patientId); + const productLine = result.parameters.find((p: { name: string }) => p.name === 'Product Line'); + expect(productLine?.value).to.equal('Medicaid'); + expect(productLine?.source).to.equal('config-test-case'); + }); + + test('overwrites existing file on re-run', () => { + const response1: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[Encounter(id=a)]' }] }], + logs: [], + }; + const response2: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }], + logs: [], + }; + const testCases = [{ name: 'p1' }]; + + writeIndividualResultFiles('MyLib', undefined, testCases, response1, Uri.file(tmpDir), Date.now()); + writeIndividualResultFiles('MyLib', undefined, testCases, response2, Uri.file(tmpDir), Date.now()); + + const result = readResult('MyLib', 'p1'); + expect(result.results[0].value).to.equal('[]'); + }); +}); diff --git a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts index d900e7b..b2a7736 100644 --- a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts +++ b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts @@ -91,12 +91,12 @@ suite('CqlLibraryRootTreeItem.rebuildTestCases', () => { expect(getTestCaseNames(item)).to.have.members(['1111', '2222']); }); - test('rebuildTestCases adds Results node when resultUri is set', () => { + test('rebuildTestCases adds Results node when result is added', () => { const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); // Initially no result expect(item.children.some(c => c instanceof CqlResultsRootTreeItem)).to.be.false; - lib.setResult(Uri.joinPath( + lib.addResult(Uri.joinPath( workspace.workspaceFolders![0].uri, 'input/tests/results/SimpleMeasure.txt', )); @@ -104,15 +104,15 @@ suite('CqlLibraryRootTreeItem.rebuildTestCases', () => { expect(item.children.some(c => c instanceof CqlResultsRootTreeItem)).to.be.true; }); - test('rebuildTestCases removes Results node when resultUri is cleared', () => { - lib.setResult(Uri.joinPath( + test('rebuildTestCases removes Results node when results are cleared', () => { + lib.addResult(Uri.joinPath( workspace.workspaceFolders![0].uri, 'input/tests/results/SimpleMeasure.txt', )); const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); expect(item.children.some(c => c instanceof CqlResultsRootTreeItem)).to.be.true; - lib.setResult(undefined); + lib.clearResults(); item.rebuildTestCases(''); expect(item.children.some(c => c instanceof CqlResultsRootTreeItem)).to.be.false; }); diff --git a/src/__test__/suite/cql-service/cqlService.executeCql.test.ts b/src/__test__/suite/cql-service/cqlService.executeCql.test.ts index 84829ef..ed6b423 100644 --- a/src/__test__/suite/cql-service/cqlService.executeCql.test.ts +++ b/src/__test__/suite/cql-service/cqlService.executeCql.test.ts @@ -3,113 +3,127 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { Uri } from 'vscode'; -import { - getCqlCommandArgs, - getExecArgs, -} from '../../../cql-service/cqlService.executeCql'; - -suite('getCqlCommandArgs()', () => { +import { buildRequest } from '../../../cql-service/cqlService.executeCql'; +import { CqlParametersConfig } from '../../../model/parameters'; +import { TestCase } from '../../../model/testCase'; + +suite('buildRequest()', () => { + const cqlUri = Uri.file('/project/input/cql/MyLib.cql'); + const terminologyUri = Uri.file('/no/such/terminology'); + const rootUri = Uri.file('/project'); const noOptsUri = Uri.file('/no/such/opts.json'); - const rootUri = Uri.file('/project/root'); - - test('first arg is always "cql"', () => { - const args = getCqlCommandArgs('R4', noOptsUri, rootUri); - expect(args[0]).to.equal('cql'); - }); - test('includes fhir version flag', () => { - const args = getCqlCommandArgs('R4', noOptsUri, rootUri); - expect(args).to.include('-fv=R4'); + test('sets fhirVersion from supplied argument', () => { + const req = buildRequest(cqlUri, [], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.fhirVersion).to.equal('R4'); }); - test('includes rootDir flag', () => { - const args = getCqlCommandArgs('R4', noOptsUri, rootUri); - expect(args.some(a => a.startsWith('-rd='))).to.be.true; + test('sets rootDir from supplied URI', () => { + const req = buildRequest(cqlUri, [], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.rootDir).to.equal(rootUri.toString()); }); - test('skips options flag when file does not exist', () => { - const args = getCqlCommandArgs('R4', noOptsUri, rootUri); - expect(args.some(a => a.startsWith('-op='))).to.be.false; + test('sets optionsPath to null when file does not exist', () => { + const req = buildRequest(cqlUri, [], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.optionsPath).to.be.null; }); - test('includes options flag when file exists', () => { + test('sets optionsPath when file exists', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-cql-test-')); try { const optsPath = path.join(tmpDir, 'cql-options.json'); fs.writeFileSync(optsPath, '{}'); - const args = getCqlCommandArgs('R4', Uri.file(optsPath), rootUri); - expect(args.some(a => a.startsWith('-op='))).to.be.true; + const req = buildRequest(cqlUri, [], terminologyUri, 'R4', Uri.file(optsPath), rootUri); + expect(req.optionsPath).to.not.be.null; } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); - test('propagates the supplied fhir version string', () => { - expect(getCqlCommandArgs('DSTU3', noOptsUri, rootUri)).to.include('-fv=DSTU3'); - expect(getCqlCommandArgs('R5', noOptsUri, rootUri)).to.include('-fv=R5'); + test('produces one LibraryRequest per test case', () => { + const testCases: TestCase[] = [{ name: 'patient1' }, { name: 'patient2' }]; + const req = buildRequest(cqlUri, testCases, terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries).to.have.length(2); }); -}); -suite('getExecArgs()', () => { - const cqlUri = Uri.file('/path/to/MyLib.cql'); + test('library name is derived from cql filename', () => { + const req = buildRequest(cqlUri, [{}], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].libraryName).to.equal('MyLib'); + }); - test('includes library name derived from filename', () => { - const args = getExecArgs(cqlUri); - expect(args).to.include('-ln=MyLib'); + test('library URI points to parent directory of cql file', () => { + const req = buildRequest(cqlUri, [{}], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].libraryUri).to.include('input/cql'); }); - test('includes library URI flag', () => { - const args = getExecArgs(cqlUri); - expect(args.some(a => a.startsWith('-lu='))).to.be.true; + test('sets context from test case name when no contextValue override', () => { + const testCases: TestCase[] = [{ name: 'patient-abc' }]; + const req = buildRequest(cqlUri, testCases, terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].context?.contextName).to.equal('Patient'); + expect(req.libraries[0].context?.contextValue).to.equal('patient-abc'); }); - test('omits model and testCase flags when no testCaseUri', () => { - const args = getExecArgs(cqlUri); - expect(args.some(a => a.startsWith('-m='))).to.be.false; - expect(args.some(a => a.startsWith('-mu='))).to.be.false; + test('overrides context with contextValue argument when provided', () => { + const testCases: TestCase[] = [{ name: 'patient-abc' }]; + const req = buildRequest(cqlUri, testCases, terminologyUri, 'R4', noOptsUri, rootUri, 'override-id'); + expect(req.libraries[0].context?.contextValue).to.equal('override-id'); }); - test('includes model and testCase flags when testCaseUri provided', () => { - const args = getExecArgs(cqlUri, Uri.file('/path/to/testcase')); - expect(args).to.include('-m=FHIR'); - expect(args.some(a => a.startsWith('-mu='))).to.be.true; + test('sets context to null when no name and no contextValue', () => { + const req = buildRequest(cqlUri, [{}], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].context).to.be.null; }); - test('includes terminology flag when provided', () => { - const args = getExecArgs(cqlUri, undefined, Uri.file('/vocab/valueset')); - expect(args.some(a => a.startsWith('-t='))).to.be.true; + test('sets model from test case path when present', () => { + const testCases: TestCase[] = [{ name: 'p1', path: Uri.file('/project/input/tests/MyLib/p1') }]; + const req = buildRequest(cqlUri, testCases, terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].model?.modelName).to.equal('FHIR'); + expect(req.libraries[0].model?.modelUri).to.include('p1'); }); - test('omits terminology flag when not provided', () => { - const args = getExecArgs(cqlUri); - expect(args.some(a => a.startsWith('-t='))).to.be.false; + test('sets model to null when test case has no path', () => { + const req = buildRequest(cqlUri, [{ name: 'p1' }], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].model).to.be.null; }); - test('includes context flags when contextValue provided', () => { - const args = getExecArgs(cqlUri, undefined, undefined, 'Patient123'); - expect(args).to.include('-c=Patient'); - expect(args).to.include('-cv=Patient123'); + test('sets terminologyUri to null when file does not exist', () => { + const req = buildRequest(cqlUri, [{}], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].terminologyUri).to.be.null; }); - test('omits context flags when no contextValue', () => { - const args = getExecArgs(cqlUri); - expect(args.some(a => a.startsWith('-c='))).to.be.false; - expect(args.some(a => a.startsWith('-cv='))).to.be.false; + test('sets terminologyUri when file exists', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-cql-test-')); + try { + const termPath = Uri.file(tmpDir); + const req = buildRequest(cqlUri, [{}], termPath, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].terminologyUri).to.not.be.null; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); - test('includes measurement period flags when period provided', () => { - const args = getExecArgs(cqlUri, undefined, undefined, undefined, '2024-01-01/2024-12-31'); - expect(args.some(a => a.startsWith('-p='))).to.be.true; - expect(args).to.include('-pv=2024-01-01/2024-12-31'); + test('sends empty parameters array when no parametersConfig is provided', () => { + const req = buildRequest(cqlUri, [{ name: 'p1' }], terminologyUri, 'R4', noOptsUri, rootUri); + expect(req.libraries[0].parameters).to.deep.equal([]); }); - test('omits measurement period flags when empty string', () => { - const args = getExecArgs(cqlUri, undefined, undefined, undefined, ''); - expect(args.some(a => a.startsWith('-p='))).to.be.false; + test('sends resolved parameters when parametersConfig is provided', () => { + const config: CqlParametersConfig = [ + { name: 'Measurement Period', type: 'Interval', value: 'Interval[@2024-01-01, @2024-12-31]' }, + ]; + const req = buildRequest(cqlUri, [{ name: 'p1' }], terminologyUri, 'R4', noOptsUri, rootUri, undefined, config, '1.0.000'); + expect(req.libraries[0].parameters).to.have.length(1); + expect(req.libraries[0].parameters[0].parameterName).to.equal('Measurement Period'); + expect(req.libraries[0].parameters[0].parameterType).to.equal('Interval'); + expect(req.libraries[0].parameters[0].parameterValue).to.equal('Interval[@2024-01-01, @2024-12-31]'); }); - test('omits measurement period flags when not provided', () => { - const args = getExecArgs(cqlUri); - expect(args.some(a => a.startsWith('-p='))).to.be.false; + test('test case overrides global parameter value in sent request', () => { + const config: CqlParametersConfig = [ + { name: 'Product Line', type: 'String', value: 'HMO' }, + { library: 'MyLib', testCases: { p1: [{ name: 'Product Line', type: 'String', value: 'Medicaid' }] } }, + ]; + const req = buildRequest(cqlUri, [{ name: 'p1' }], terminologyUri, 'R4', noOptsUri, rootUri, undefined, config, '1.0.000'); + expect(req.libraries[0].parameters[0].parameterValue).to.equal('Medicaid'); }); }); diff --git a/src/__test__/suite/helpers/parametersHelper.test.ts b/src/__test__/suite/helpers/parametersHelper.test.ts new file mode 100644 index 0000000..d2dabb8 --- /dev/null +++ b/src/__test__/suite/helpers/parametersHelper.test.ts @@ -0,0 +1,156 @@ +import { expect } from 'chai'; +import { mergeParameters, resolveParameters } from '../../../helpers/parametersHelper'; +import { CqlParametersConfig, ParameterEntry } from '../../../model/parameters'; + +const p = (name: string, type: string, value: string): ParameterEntry => ({ name, type, value }); + +suite('mergeParameters()', () => { + test('returns empty array when all inputs are empty', () => { + expect(mergeParameters([], [], [])).to.deep.equal([]); + }); + + test('returns global entries when library and testCase are empty', () => { + const global = [p('Measurement Period', 'Interval', 'Interval[@2024-01-01, @2024-12-31]')]; + expect(mergeParameters(global, [], [])).to.deep.equal(global); + }); + + test('library overrides global for same parameter name', () => { + const global = [p('Product Line', 'String', 'HMO')]; + const library = [p('Product Line', 'String', 'PPO')]; + const result = mergeParameters(global, library, []); + expect(result).to.have.length(1); + expect(result[0].value).to.equal('PPO'); + }); + + test('testCase overrides library for same parameter name', () => { + const library = [p('Product Line', 'String', 'PPO')]; + const testCase = [p('Product Line', 'String', 'Medicaid')]; + const result = mergeParameters([], library, testCase); + expect(result).to.have.length(1); + expect(result[0].value).to.equal('Medicaid'); + }); + + test('testCase overrides global for same parameter name', () => { + const global = [p('Product Line', 'String', 'HMO')]; + const testCase = [p('Product Line', 'String', 'Medicaid')]; + const result = mergeParameters(global, [], testCase); + expect(result).to.have.length(1); + expect(result[0].value).to.equal('Medicaid'); + }); + + test('distinct parameter names from all levels are all included', () => { + const global = [p('Measurement Period', 'Interval', 'Interval[@2024-01-01, @2024-12-31]')]; + const library = [p('Product Line', 'String', 'HMO')]; + const testCase = [p('Custom Flag', 'Boolean', 'true')]; + const result = mergeParameters(global, library, testCase); + expect(result).to.have.length(3); + const names = result.map(r => r.name); + expect(names).to.include('Measurement Period'); + expect(names).to.include('Product Line'); + expect(names).to.include('Custom Flag'); + }); +}); + +suite('resolveParameters()', () => { + const config: CqlParametersConfig = [ + // global: no library field + p('Measurement Period', 'Interval', 'Interval[@2024-01-01, @2024-12-31]'), + // library block for MyLib (unversioned) + { + library: 'MyLib', + parameters: [p('Product Line', 'String', 'HMO')], + testCases: { + 'patient-uuid-abc': [p('Product Line', 'String', 'Medicaid')], + }, + }, + ]; + + test('returns global params when library has no matching block', () => { + const result = resolveParameters(config, 'OtherLib', undefined, undefined); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('Measurement Period'); + expect(result[0].source).to.equal('config-global'); + }); + + test('merges global + library params', () => { + const result = resolveParameters(config, 'MyLib', undefined, undefined); + expect(result).to.have.length(2); + const map = Object.fromEntries(result.map(r => [r.name, r])); + expect(map['Measurement Period'].value).to.equal('Interval[@2024-01-01, @2024-12-31]'); + expect(map['Measurement Period'].source).to.equal('config-global'); + expect(map['Product Line'].value).to.equal('HMO'); + expect(map['Product Line'].source).to.equal('config-library'); + }); + + test('test case overrides library param', () => { + const result = resolveParameters(config, 'MyLib', undefined, 'patient-uuid-abc'); + const productLine = result.find(r => r.name === 'Product Line'); + expect(productLine?.value).to.equal('Medicaid'); + expect(productLine?.source).to.equal('config-test-case'); + }); + + test('unknown patientId falls back to global + library', () => { + const result = resolveParameters(config, 'MyLib', undefined, 'unknown-patient'); + const productLine = result.find(r => r.name === 'Product Line'); + expect(productLine?.value).to.equal('HMO'); + expect(productLine?.source).to.equal('config-library'); + }); + + test('returns empty array when config is empty', () => { + expect(resolveParameters([], 'MyLib', undefined, 'some-patient')).to.deep.equal([]); + }); + + test('global param not overridden retains source config-global', () => { + const result = resolveParameters(config, 'MyLib', undefined, 'patient-uuid-abc'); + const mp = result.find(r => r.name === 'Measurement Period'); + expect(mp?.source).to.equal('config-global'); + }); + + suite('version matching', () => { + const versionedConfig: CqlParametersConfig = [ + p('Global Param', 'String', 'global'), + { + library: 'MyLib', + version: '1.0.000', + parameters: [p('Versioned Param', 'String', 'v1')], + }, + { + library: 'MyLib', + version: '2.0.000', + parameters: [p('Versioned Param', 'String', 'v2')], + }, + { + library: 'MyLib', + // no version — matches any version + parameters: [p('Unversioned Param', 'String', 'any')], + }, + ]; + + test('versioned block applies only when version matches exactly', () => { + const result = resolveParameters(versionedConfig, 'MyLib', '1.0.000', undefined); + const named = Object.fromEntries(result.map(r => [r.name, r.value])); + expect(named['Versioned Param']).to.equal('v1'); + expect(named['Unversioned Param']).to.equal('any'); + expect(named['Global Param']).to.equal('global'); + }); + + test('versioned block for different version is excluded', () => { + const result = resolveParameters(versionedConfig, 'MyLib', '1.0.000', undefined); + expect(result.find(r => r.value === 'v2')).to.be.undefined; + }); + + test('unversioned block always applies regardless of library version', () => { + const result = resolveParameters(versionedConfig, 'MyLib', '99.0.000', undefined); + const names = result.map(r => r.name); + expect(names).to.include('Unversioned Param'); + expect(names).not.to.include('Versioned Param'); + }); + + test('undefined libraryVersion skips versioned blocks', () => { + const result = resolveParameters(versionedConfig, 'MyLib', undefined, undefined); + const names = result.map(r => r.name); + expect(names).to.include('Unversioned Param'); + expect(names).not.to.include('Versioned Param'); + }); + }); +}); diff --git a/src/__test__/suite/model/testCase.getTestCases.test.ts b/src/__test__/suite/model/testCase.getTestCases.test.ts index b04ea75..2e8bb2a 100644 --- a/src/__test__/suite/model/testCase.getTestCases.test.ts +++ b/src/__test__/suite/model/testCase.getTestCases.test.ts @@ -38,4 +38,11 @@ suite('testCase.getTestCases()', () => { } }); + test('does not return shared/ as a patient test case', () => { + const testPath = Uri.joinPath(workspace.workspaceFolders![0].uri, 'input/tests/Measure'); + const result = getTestCases(testPath, 'SimpleMeasure', []); + const names = result.map(tc => tc.name); + expect(names).not.to.include('shared'); + }); + }); diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 33cd3a2..3333836 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -43,10 +43,9 @@ export namespace Commands { /* * Execute CQL - * TODO: Deprecate once full debugging support exists */ export const EXECUTE_CQL_COMMAND = 'cql.editor.execute'; - export const EXECUTE_CQL = 'org.opencds.cqf.cql.ls.plugin.debug.startDebugSession'; + export const EXECUTE_CQL = 'org.opencds.cqf.cql.ls.executeCql'; export const EXECUTE_CQL_COMMAND_SELECT_LIBRARIES = 'cql.execute.select-libraries'; export const EXECUTE_CQL_COMMAND_SELECT_TEST_CASES = 'cql.editor.execute.select-test-cases'; diff --git a/src/commands/execute-cql.ts b/src/commands/execute-cql.ts index aad0f9a..72bf231 100644 --- a/src/commands/execute-cql.ts +++ b/src/commands/execute-cql.ts @@ -6,6 +6,7 @@ import { ExtensionContext, Position, ProgressLocation, + Range, TextEditor, Uri, window, @@ -13,13 +14,27 @@ import { } from 'vscode'; import { Utils } from 'vscode-uri'; import { Commands } from '../commands/commands'; -import { executeCql } from '../cql-service/cqlService.executeCql'; +import { ExecuteCqlResponse, ExpressionResult, executeCql } from '../cql-service/cqlService.executeCql'; import * as log from '../log-services/logger'; -import { toGlobPath } from '../helpers/fileHelper'; +import { extractLibraryVersion, toGlobPath } from '../helpers/fileHelper'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { CqlParametersConfig, ParameterEntry, ResultParameterEntry } from '../model/parameters'; +import { resolveParameters } from '../helpers/parametersHelper'; import { getTestCases, getMeasureReportData, TestCase, TestCaseExclusion } from '../model/testCase'; let _context: ExtensionContext | undefined; +export interface TestCaseResult { + executedAt: string; + libraryName: string; + testCaseName: string | null; + testCaseDescription: string | null; + /** All parameters for this evaluation — config-supplied and CQL-declared defaults — ordered config first, defaults appended. Differentiate by the `source` field. */ + parameters: ResultParameterEntry[]; + results: ExpressionResult[]; + errors: string[]; +} + interface CqlPaths { libraryDirectoryPath: Uri; projectDirectoryPath: Uri; @@ -32,6 +47,8 @@ interface CqlPaths { export interface TestConfig { testCasesToExclude: TestCaseExclusion[]; + parameters?: CqlParametersConfig; + resultFormat?: 'individual' | 'flat'; } export function register(context: ExtensionContext): void { @@ -89,6 +106,8 @@ export async function selectLibraries(): Promise { }, async (progress, token) => { const total = selected.length; + const batchStart = Date.now(); + let completed = 0; for (let i = 0; i < total; i++) { if (token.isCancellationRequested) { break; @@ -98,9 +117,26 @@ export async function selectLibraries(): Promise { message: `(${i + 1}/${total}) ${item.label}`, increment: (1 / total) * 100, }); - await executeCQLFile(item.uri, undefined, false); + const libStart = Date.now(); + try { + await executeCQLFile(item.uri, undefined, false, undefined, false); + log.info(`[PERF] ${item.label}: ${((Date.now() - libStart) / 1000).toFixed(1)}s`); + completed++; + } catch (e) { + log.error(`Error executing CQL for ${item.label}`, e); + window.showErrorMessage( + `Failed to execute ${item.label}: ${e instanceof Error ? e.message : String(e)}`, + ); + } await new Promise(resolve => setTimeout(resolve, 500)); } + const batchElapsed = ((Date.now() - batchStart) / 1000).toFixed(1); + log.info(`[PERF] selectLibraries total (${total} libraries): ${batchElapsed}s`); + const msg = + completed === total + ? `CQL execution complete — ${total} ${total === 1 ? 'library' : 'libraries'} (${batchElapsed}s)` + : `CQL execution cancelled — ${completed}/${total} libraries (${batchElapsed}s)`; + window.showInformationMessage(msg); }, ); }); @@ -118,10 +154,6 @@ export async function selectTestCases(cqlFileUri: Uri): Promise { const quickPick = window.createQuickPick(); const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; - const outputPath = Utils.resolvePath(cqlPaths.resultDirectoryPath, `${libraryName}.txt`); - fse.ensureFileSync(outputPath.fsPath); - const textDocument = await workspace.openTextDocument(outputPath); - await window.showTextDocument(textDocument); const testConfig = loadTestConfig(cqlPaths.testConfigPath); const excludedTestCases = getExcludedTestCases(libraryName, testConfig.testCasesToExclude); const testCases = getTestCases( @@ -169,6 +201,8 @@ export async function executeCQLFile( cqlFileUri: Uri, testCases: Array | undefined = undefined, showProgress: boolean = true, + resultFormatOverride?: string, + showCompletion: boolean = true, ): Promise { if (!fs.existsSync(cqlFileUri.fsPath)) { window.showInformationMessage('No library content found. Please save before executing.'); @@ -182,62 +216,31 @@ export async function executeCQLFile( } const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; - const outputPath = Utils.resolvePath(cqlPaths.resultDirectoryPath, `${libraryName}.txt`); - fse.ensureFileSync(outputPath.fsPath); - const textDocument = await workspace.openTextDocument(outputPath); - const textEditor = await window.showTextDocument(textDocument); + const libraryDisplayName = Utils.basename(cqlFileUri).replace('.cql', ''); const testConfig = loadTestConfig(cqlPaths.testConfigPath); const excludedTestCases = getExcludedTestCases(libraryName, testConfig.testCasesToExclude); const effectiveTestCases: Array = testCases ?? - getTestCases( - cqlPaths.testDirectoryPath, libraryName, Array.from(excludedTestCases.keys()) - ) + getTestCases(cqlPaths.testDirectoryPath, libraryName, Array.from(excludedTestCases.keys())); // We didn't find any test cases, so we'll just execute an empty one if (effectiveTestCases.length === 0) { effectiveTestCases.push({}); } - const cqlMessage = `CQL: ${cqlPaths.libraryDirectoryPath.fsPath}`; - const terminologyMessage = fs.existsSync(cqlPaths.terminologyDirectoryPath.fsPath) - ? `Terminology: ${cqlPaths.terminologyDirectoryPath.fsPath}` - : `No terminology found at ${cqlPaths.terminologyDirectoryPath.fsPath}. Evaluation may fail if terminology is required.`; - - let testMessage = []; - if (effectiveTestCases.length == 1 && !effectiveTestCases[0].name) { - testMessage.push( - `No data found at ${cqlPaths.testDirectoryPath.fsPath}. Evaluation may fail if data is required.`, - ); - } else { - testMessage.push(`Test cases:`); - for (let p of effectiveTestCases) { - testMessage.push(`${p.name} - ${p.path?.fsPath}`); - } - } - - if (excludedTestCases.size > 0) { - testMessage.push('\nExcluded test cases:'); - for (const [testCase, reason] of excludedTestCases.entries()) { - testMessage.push(`${testCase} - ${reason}`); - } - } - - await insertLineAtEnd(textEditor, `${cqlMessage}`); - await insertLineAtEnd(textEditor, `${terminologyMessage}`); - await insertLineAtEnd(textEditor, `${testMessage.join('\n')}\n`); - - const startExecution = Date.now(); - - const determinedFhirVersion = getFhirVersion(fs.readFileSync(cqlFileUri.fsPath, 'utf-8')); + const cqlSource = fs.readFileSync(cqlFileUri.fsPath, 'utf-8'); + const determinedFhirVersion = getFhirVersion(cqlSource); const fhirVersion: string = determinedFhirVersion ?? 'R4'; if (!determinedFhirVersion) { window.showInformationMessage('Unable to determine version of FHIR used. Defaulting to R4.'); } + const libraryVersion = extractLibraryVersion(cqlSource); - const libraryDisplayName = Utils.basename(cqlFileUri).replace('.cql', ''); + const resultFormat = resultFormatOverride + ?? testConfig.resultFormat + ?? workspace.getConfiguration('cql').get('execute.resultFormat', 'individual'); const doExecute = () => executeCql( @@ -247,11 +250,17 @@ export async function executeCQLFile( fhirVersion, cqlPaths.optionsPath, cqlPaths.projectDirectoryPath, + undefined, + testConfig.parameters, + libraryVersion, ); - let result: string | undefined; + const startExecution = Date.now(); + + + let response: ExecuteCqlResponse | undefined; if (showProgress) { - result = await window.withProgress( + response = await window.withProgress( { location: ProgressLocation.Notification, title: `Executing CQL: ${libraryDisplayName}`, @@ -263,16 +272,188 @@ export async function executeCQLFile( }, ); } else { - result = await doExecute(); + response = await doExecute(); } const endExecution = Date.now(); + const elapsedSeconds = (endExecution - startExecution) / 1000; + + if (resultFormat === 'individual') { + if (response) { + writeIndividualResultFiles( + libraryName, + libraryVersion, + effectiveTestCases, + response, + cqlPaths.resultDirectoryPath, + startExecution, + testConfig.parameters, + ); + } + if (showCompletion && response) { + if (effectiveTestCases.length === 1) { + const patientId = effectiveTestCases[0].name ?? 'no-context'; + const outputPath = Utils.resolvePath( + cqlPaths.resultDirectoryPath, + libraryName, + `TestCaseResult-${patientId}.json`, + ); + const textDocument = await workspace.openTextDocument(outputPath); + await window.showTextDocument(textDocument); + } else { + const count = effectiveTestCases.length; + window.showInformationMessage( + `CQL execution complete — ${count} test cases written (${elapsedSeconds.toFixed(1)}s)`, + ); + } + } + } else { + const outputPath = Utils.resolvePath(cqlPaths.resultDirectoryPath, `${libraryName}.txt`); + // Ensure the result directory and file exist for first-run cases. + // Do NOT truncate here if the file is already open in VS Code — that would bump the on-disk + // mtime and trigger a "file is newer" conflict when we later call textDocument.save(). + // The textEditor.edit() delete below clears stale in-memory content on every run. + fse.ensureDirSync(cqlPaths.resultDirectoryPath.fsPath); + if (!fs.existsSync(outputPath.fsPath)) { + fs.writeFileSync(outputPath.fsPath, ''); + } + const textDocument = await workspace.openTextDocument(outputPath); + const textEditor = await window.showTextDocument(textDocument); + // Clear any existing in-memory content before inserting new results. + // This handles repeated runs where VS Code still holds the previous run's content. + await textEditor.edit( + editBuilder => { + editBuilder.delete( + new Range( + new Position(0, 0), + textDocument.lineAt(Math.max(0, textDocument.lineCount - 1)).range.end, + ), + ); + }, + { undoStopBefore: false, undoStopAfter: false }, + ); - await insertLineAtEnd(textEditor, result!); - await insertLineAtEnd( - textEditor, - `elapsed: ${((endExecution - startExecution) / 1000).toString()} seconds`, - ); + const cqlMessage = `CQL: ${cqlPaths.libraryDirectoryPath.fsPath}`; + const terminologyMessage = fs.existsSync(cqlPaths.terminologyDirectoryPath.fsPath) + ? `Terminology: ${cqlPaths.terminologyDirectoryPath.fsPath}` + : `No terminology found at ${cqlPaths.terminologyDirectoryPath.fsPath}. Evaluation may fail if terminology is required.`; + + const testMessage: string[] = []; + if (effectiveTestCases.length === 1 && !effectiveTestCases[0].name) { + testMessage.push( + `No data found at ${cqlPaths.testDirectoryPath.fsPath}. Evaluation may fail if data is required.`, + ); + } else { + testMessage.push(`Test cases:`); + for (const p of effectiveTestCases) { + testMessage.push(`${p.name} - ${p.path?.fsPath}`); + } + } + + if (excludedTestCases.size > 0) { + testMessage.push('\nExcluded test cases:'); + for (const [testCase, reason] of excludedTestCases.entries()) { + testMessage.push(`${testCase} - ${reason}`); + } + } + + await insertLineAtEnd(textEditor, cqlMessage); + await insertLineAtEnd(textEditor, terminologyMessage); + await insertLineAtEnd(textEditor, `${testMessage.join('\n')}\n`); + + if (response) { + await insertLineAtEnd(textEditor, formatResponse(response)); + } + await insertLineAtEnd( + textEditor, + `\nelapsed: ${elapsedSeconds.toString()} seconds\n`, + ); + await textDocument.save(); + } +} + +export function writeIndividualResultFiles( + libraryName: string, + libraryVersion: string | undefined, + testCases: Array, + response: ExecuteCqlResponse, + resultDirectoryPath: Uri, + executedAt: number, + parametersConfig?: CqlParametersConfig, +): void { + const executedAtStr = new Date(executedAt).toISOString(); + + for (let i = 0; i < response.results.length; i++) { + const libraryResult = response.results[i]; + const testCase = testCases[i]; + const testCaseName = testCase?.name ?? null; + const testCaseDescription = testCase?.path + ? (getMeasureReportData(testCase.path)?.description ?? null) + : null; + + const results: ExpressionResult[] = []; + const errors: string[] = []; + for (const expr of libraryResult.expressions) { + if (expr.name === 'Error') { + errors.push(expr.value); + } else { + results.push(expr); + } + } + + const resolvedParams: ParameterEntry[] = parametersConfig + ? resolveParameters(parametersConfig, libraryName, libraryVersion, testCaseName ?? undefined) + : []; + + const parameters: ResultParameterEntry[] = [ + ...resolvedParams.map(p => ({ name: p.name, type: p.type, value: p.value, source: p.source! })), + ...(libraryResult.usedDefaultParameters ?? []).map(p => ({ name: p.name, value: p.value, source: 'default' as const })), + ]; + + const result: TestCaseResult = { + executedAt: executedAtStr, + libraryName, + testCaseName, + testCaseDescription, + parameters, + results, + errors, + }; + + const patientId = testCaseName ?? 'no-context'; + const outputPath = Utils.resolvePath( + resultDirectoryPath, + libraryName, + `TestCaseResult-${patientId}.json`, + ); + fse.outputFileSync(outputPath.fsPath, JSON.stringify(result, null, 2)); + } +} + +export function formatResponse(response: ExecuteCqlResponse): string { + const lines: string[] = []; + for (let i = 0; i < response.results.length; i++) { + if (i > 0) { + lines.push(''); + } + for (const expr of response.results[i].expressions) { + lines.push(`${expr.name}=${expr.value}`); + } + } + if (response.logs.length > 0) { + lines.push(''); + lines.push('Evaluation logs:'); + lines.push(...response.logs); + } + return lines.join('\n'); +} + +export function resolveTestConfigPath(testDirectoryPath: Uri): Uri { + const jsoncPath = Utils.resolvePath(testDirectoryPath, 'config.jsonc'); + if (fs.existsSync(jsoncPath.fsPath)) { + return jsoncPath; + } + return Utils.resolvePath(testDirectoryPath, 'config.json'); } function getCqlPaths(): CqlPaths | undefined { @@ -294,7 +475,7 @@ function getCqlPaths(): CqlPaths | undefined { 'vocabulary', 'valueset', ), - testConfigPath: Utils.resolvePath(testDirectoryPath, 'config.json'), + testConfigPath: resolveTestConfigPath(testDirectoryPath), testDirectoryPath: testDirectoryPath, }; } @@ -313,19 +494,26 @@ export function getExcludedTestCases( } export function getFhirVersion(cqlContent: string): string | null { - const fhirVersionRegex = /using (FHIR|"FHIR") version '(\d(.|\d)*)'/; - const matches = cqlContent.match(fhirVersionRegex); - if (matches && matches.length > 2) { - const version = matches[2]; - if (version.startsWith('2')) { - return 'DSTU2'; - } else if (version.startsWith('3')) { - return 'DSTU3'; - } else if (version.startsWith('4')) { - return 'R4'; - } else if (version.startsWith('5')) { - return 'R5'; - } + // Direct FHIR model declaration: using FHIR version 'x.y.z' + const fhirMatch = cqlContent.match(/using\s+(?:FHIR|"FHIR")\s+version\s+'(\d[^']*)'/); + if (fhirMatch) { + const v = fhirMatch[1]; + if (v.startsWith('2')) return 'DSTU2'; + if (v.startsWith('3')) return 'DSTU3'; + if (v.startsWith('4')) return 'R4'; + if (v.startsWith('5')) return 'R5'; + } + + // QICore model declaration: using QICore version 'x.y.z' + // QICore 3.x targets FHIR DSTU3; 4.x and above target FHIR R4. + const qicoreMatch = cqlContent.match(/using\s+(?:QICore|"QICore")\s+version\s+'(\d[^']*)'/); + if (qicoreMatch) { + return qicoreMatch[1].startsWith('3') ? 'DSTU3' : 'R4'; + } + + // USCore model declaration: all versions target FHIR R4. + if (/using\s+(?:USCore|"USCore")\s+version\s+'/.test(cqlContent)) { + return 'R4'; } return null; @@ -351,18 +539,21 @@ function getWorkspacePath(): Uri | undefined { async function insertLineAtEnd(textEditor: TextEditor, text: string) { const document = textEditor.document; - await textEditor.edit(editBuilder => { - editBuilder.insert(new Position(document.lineCount, 0), text + '\n'); - }); + await textEditor.edit( + editBuilder => { + editBuilder.insert(new Position(document.lineCount, 0), text + '\n'); + }, + { undoStopBefore: false, undoStopAfter: false }, + ); } export function loadTestConfig(testConfigPath: Uri): TestConfig { try { const jsonString = fs.readFileSync(testConfigPath.fsPath, 'utf-8'); - // Cast the parsed object to the User interface - return JSON.parse(jsonString) as TestConfig; + return parseJsonc(jsonString) as TestConfig; } catch (error) { log.error('Error reading/parsing config file', error); return { testCasesToExclude: [] }; } } + diff --git a/src/commands/log-files.ts b/src/commands/log-files.ts index a469e7e..711235d 100644 --- a/src/commands/log-files.ts +++ b/src/commands/log-files.ts @@ -5,12 +5,10 @@ import { commands, ExtensionContext, Uri, ViewColumn, window, workspace } from ' import { Commands } from '../commands/commands'; import * as log from '../log-services/logger'; -export function register(context: ExtensionContext, storageUri: Uri): void { - const workspacePath = path.resolve(storageUri.fsPath + '/cql_ls_ws'); - +export function register(context: ExtensionContext): void { context.subscriptions.push( commands.registerCommand(Commands.OPEN_SERVER_LOG, (column: ViewColumn) => - openServerLogFile(workspacePath, column), + openServerLogFile(context.logUri, column), ), commands.registerCommand(Commands.OPEN_CLIENT_LOG, (column: ViewColumn) => openClientLogFile(column), @@ -82,9 +80,9 @@ function openLogFile( } function openServerLogFile( - workspacePath: string, + logUri: Uri, column: ViewColumn = ViewColumn.Active, ): Thenable { - const serverLogFile = path.join(workspacePath, '.metadata', '.log'); + const serverLogFile = path.join(logUri.fsPath, 'cql-ls.log'); return openLogFile(serverLogFile, 'Could not open CQL Language Server log file', column); } diff --git a/src/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index f4edb2d..0dc932e 100644 --- a/src/cql-explorer/cqlExplorer.ts +++ b/src/cql-explorer/cqlExplorer.ts @@ -103,9 +103,30 @@ export class CqlExplorer { // execute all visible libraries vscode.commands.registerCommand('cql.explorer.library.execute-all', async () => { logger.debug(`Command cql.explorer.library.execute-all selected`); - for (const lib of this.cqlProjects.flatMap(p => p.Libraries).filter(l => l.visible.value)) { - await executeCQLFile(lib.uri); + const filter = this.nameFilter.toLowerCase(); + const libs = this.cqlProjects + .flatMap(p => p.Libraries) + .filter(l => filter === '' || l.name.toLowerCase().includes(filter)); + if (libs.length === 0) { + return; } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Execute CQL', + cancellable: true, + }, + async (progress, token) => { + for (let i = 0; i < libs.length; i++) { + if (token.isCancellationRequested) { + break; + } + const lib = libs[i]; + progress.report({ message: `${lib.name} (${i + 1} of ${libs.length})` }); + await executeCQLFile(lib.uri, undefined, false); + } + }, + ); }), // execute all test cases for a library diff --git a/src/cql-explorer/cqlProject.ts b/src/cql-explorer/cqlProject.ts index 6a90f31..0ea68dc 100644 --- a/src/cql-explorer/cqlProject.ts +++ b/src/cql-explorer/cqlProject.ts @@ -69,7 +69,7 @@ export class CqlLibrary extends RobustEmitter { static readonly Events = CqlLibraryEvents; private readonly testCases: Array = []; - private _resultUri: Uri | undefined; + private _resultUris: Uri[] = []; public readonly name: string; public readonly visible: ObservableProperty; public readonly uri: Uri; @@ -93,12 +93,24 @@ export class CqlLibrary extends RobustEmitter { } } - get resultUri(): Uri | undefined { - return this._resultUri; + get resultUris(): Uri[] { + return [...this._resultUris]; } - setResult(uri: Uri | undefined) { - this._resultUri = uri; + addResult(uri: Uri): void { + if (!this._resultUris.some(u => u.fsPath === uri.fsPath)) { + this._resultUris.push(uri); + } + this.emit(CqlLibraryEvents.RESULT_CHANGED); + } + + removeResult(uri: Uri): void { + this._resultUris = this._resultUris.filter(u => u.fsPath !== uri.fsPath); + this.emit(CqlLibraryEvents.RESULT_CHANGED); + } + + clearResults(): void { + this._resultUris = []; this.emit(CqlLibraryEvents.RESULT_CHANGED); } @@ -156,6 +168,7 @@ export class CqlProject extends EventEmitter { private testFolderWatcher: FileSystemWatcher | undefined; private testCaseResourceWatcher: FileSystemWatcher | undefined; private resultFolderWatcher: FileSystemWatcher | undefined; + private individualResultFolderWatcher: FileSystemWatcher | undefined; public static getInstances(): CqlProject[] { if (!CqlProject._instances) { @@ -230,27 +243,35 @@ export class CqlProject extends EventEmitter { } private configureResultFolderWatcher() { - this.resultFolderWatcher!.onDidCreate(async (uri: Uri) => { + this.resultFolderWatcher!.onDidCreate((uri: Uri) => { const name = path.basename(uri.fsPath, path.extname(uri.fsPath)); - const lib = this.findLibraryByName(name); - if (lib) { - lib.setResult(uri); - } + this.findLibraryByName(name)?.addResult(uri); }); - this.resultFolderWatcher!.onDidDelete(async (uri: Uri) => { + this.resultFolderWatcher!.onDidDelete((uri: Uri) => { const name = path.basename(uri.fsPath, path.extname(uri.fsPath)); - const lib = this.findLibraryByName(name); - if (lib) { - lib.setResult(undefined); - } + this.findLibraryByName(name)?.removeResult(uri); }); - this.resultFolderWatcher!.onDidChange(async (uri: Uri) => { + this.resultFolderWatcher!.onDidChange((uri: Uri) => { const name = path.basename(uri.fsPath, path.extname(uri.fsPath)); - const lib = this.findLibraryByName(name); - if (lib) { - lib.setResult(uri); - } + this.findLibraryByName(name)?.addResult(uri); + }); + } + + private configureIndividualResultFolderWatcher() { + this.individualResultFolderWatcher!.onDidCreate((uri: Uri) => { + this.findLibraryFromResultUri(uri)?.addResult(uri); }); + this.individualResultFolderWatcher!.onDidDelete((uri: Uri) => { + this.findLibraryFromResultUri(uri)?.removeResult(uri); + }); + this.individualResultFolderWatcher!.onDidChange((uri: Uri) => { + this.findLibraryFromResultUri(uri)?.addResult(uri); + }); + } + + private findLibraryFromResultUri(uri: Uri): CqlLibrary | undefined { + const dirName = path.basename(path.dirname(uri.fsPath)); + return this.findLibraryByName(dirName); } private configureTestCaseResourceWatcher() { @@ -466,6 +487,11 @@ export class CqlProject extends EventEmitter { new RelativePattern(this.resultFolder, '*.txt'), ); this.configureResultFolderWatcher(); + + this.individualResultFolderWatcher = workspace.createFileSystemWatcher( + new RelativePattern(this.resultFolder, '*/TestCaseResult-*.json'), + ); + this.configureIndividualResultFolderWatcher(); } private disposeTestWatchers(): void { @@ -475,6 +501,8 @@ export class CqlProject extends EventEmitter { this.testCaseResourceWatcher = undefined; this.resultFolderWatcher?.dispose(); this.resultFolderWatcher = undefined; + this.individualResultFolderWatcher?.dispose(); + this.individualResultFolderWatcher = undefined; } private async loadResults(): Promise { @@ -488,14 +516,39 @@ export class CqlProject extends EventEmitter { return; } for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.txt')) { - continue; - } - const name = path.basename(entry.name, path.extname(entry.name)); - const lib = this.findLibraryByName(name); - if (lib) { - lib.setResult(Uri.file(path.join(this.resultFolder, entry.name))); - matched++; + if (entry.isFile() && entry.name.endsWith('.txt')) { + // Flat format: {LibraryName}.txt + const name = path.basename(entry.name, '.txt'); + const lib = this.findLibraryByName(name); + if (lib) { + lib.addResult(Uri.file(path.join(this.resultFolder, entry.name))); + matched++; + } + } else if (entry.isDirectory()) { + // Individual format: {LibraryName}/TestCaseResult-{patientId}.json + const lib = this.findLibraryByName(entry.name); + if (lib) { + try { + const subEntries = await fs.promises.readdir( + path.join(this.resultFolder, entry.name), + { withFileTypes: true }, + ); + for (const sub of subEntries) { + if ( + sub.isFile() && + sub.name.startsWith('TestCaseResult-') && + sub.name.endsWith('.json') + ) { + lib.addResult( + Uri.file(path.join(this.resultFolder, entry.name, sub.name)), + ); + matched++; + } + } + } catch { + // subdir not readable — skip + } + } } } } catch (e) { diff --git a/src/cql-explorer/cqlProjectTreeDataProvider.ts b/src/cql-explorer/cqlProjectTreeDataProvider.ts index 528e7da..4543657 100644 --- a/src/cql-explorer/cqlProjectTreeDataProvider.ts +++ b/src/cql-explorer/cqlProjectTreeDataProvider.ts @@ -122,19 +122,21 @@ export class CqlTestCasesLoadingTreeItem extends vscode.TreeItem { export class CqlResultsRootTreeItem extends vscode.TreeItem { private readonly _children: vscode.TreeItem[] = []; - constructor(resultUri: vscode.Uri) { + constructor(resultUris: vscode.Uri[]) { super('Results', vscode.TreeItemCollapsibleState.Collapsed); this.contextValue = 'cql-results-root'; this.iconPath = new vscode.ThemeIcon('output'); - const leaf = new vscode.TreeItem( - path.basename(resultUri.fsPath), - vscode.TreeItemCollapsibleState.None, - ); - leaf.iconPath = new vscode.ThemeIcon('file-text'); - leaf.contextValue = 'cql-result'; - leaf.command = { command: 'vscode.open', title: 'Open', arguments: [resultUri] }; - this._children.push(leaf); + for (const uri of resultUris) { + const leaf = new vscode.TreeItem( + path.basename(uri.fsPath), + vscode.TreeItemCollapsibleState.None, + ); + leaf.iconPath = new vscode.ThemeIcon('file-text'); + leaf.contextValue = 'cql-result'; + leaf.command = { command: 'vscode.open', title: 'Open', arguments: [uri] }; + this._children.push(leaf); + } } public get children(): vscode.TreeItem[] { @@ -175,8 +177,8 @@ export class CqlLibraryRootTreeItem extends vscode.TreeItem { cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); cqlLibrary.TestCases.forEach(tc => this.addTestCase(tc)); - if (cqlLibrary.resultUri) { - this._children.push(new CqlResultsRootTreeItem(cqlLibrary.resultUri)); + if (cqlLibrary.resultUris.length > 0) { + this._children.push(new CqlResultsRootTreeItem(cqlLibrary.resultUris)); } } @@ -220,8 +222,8 @@ export class CqlLibraryRootTreeItem extends vscode.TreeItem { this.cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); this.cqlLibrary.TestCases.forEach(tc => this.addTestCase(tc)); - if (this.cqlLibrary.resultUri) { - this._children.push(new CqlResultsRootTreeItem(this.cqlLibrary.resultUri)); + if (this.cqlLibrary.resultUris.length > 0) { + this._children.push(new CqlResultsRootTreeItem(this.cqlLibrary.resultUris)); } } diff --git a/src/cql-language-server/languageServerStarter.ts b/src/cql-language-server/languageServerStarter.ts index 3578011..ab01b0f 100644 --- a/src/cql-language-server/languageServerStarter.ts +++ b/src/cql-language-server/languageServerStarter.ts @@ -68,6 +68,13 @@ function prepareParams( workspacePath: string, ): string[] { const params: string[] = []; + params.push('-Xmx4g'); + const logFile = path.join(context.logUri.fsPath, 'cql-ls.log'); + const gcLogFile = path.join(context.logUri.fsPath, 'gc.log'); + params.push(`-DLOG_FILE=${logFile}`); + params.push(`-Xlog:gc*:file=${gcLogFile}:time,uptime,level,tags:filecount=3,filesize=10m`); + log.info(`CQL language server log file: ${logFile}`); + if (DEBUG) { const port = 1044; params.push('-Dlog.level=ALL'); diff --git a/src/cql-service/cqlService.executeCql.ts b/src/cql-service/cqlService.executeCql.ts index b8bd7f4..9f7e771 100644 --- a/src/cql-service/cqlService.executeCql.ts +++ b/src/cql-service/cqlService.executeCql.ts @@ -3,87 +3,127 @@ import path from 'node:path'; import { Uri } from 'vscode'; import { Commands } from '../commands/commands'; import { sendRequest } from '../cql-language-server/cqlLanguageClient'; +import { CqlParametersConfig } from '../model/parameters'; +import { resolveParameters } from '../helpers/parametersHelper'; +import { extractLibraryVersion } from '../helpers/fileHelper'; import { TestCase } from '../model/testCase'; -export async function executeCql( - cqlFileUri: Uri, - testCases: Array, - terminologyUri: Uri, - fhirVersion: string, - optionsPath: Uri, - rootDir: Uri, - contextValue?: string, - measurementPeriod?: string, -): Promise { - let testCasesArgs: string[] = []; - for (let testCase of testCases) { - testCasesArgs.push( - ...getExecArgs( - cqlFileUri, - testCase.path, - terminologyUri, - contextValue ?? testCase.name, - measurementPeriod, - ), - ); - } - - let operationArgs = getCqlCommandArgs(fhirVersion, optionsPath, rootDir); - operationArgs.push(...testCasesArgs); - return await sendRequest(Commands.EXECUTE_CQL, operationArgs); +export interface ExecuteCqlResponse { + results: LibraryResult[]; + logs: string[]; } -export function getCqlCommandArgs(fhirVersion: string, optionsPath: Uri, rootDir: Uri): string[] { - const args = ['cql']; +export interface LibraryResult { + libraryName: string; + expressions: ExpressionResult[]; + usedDefaultParameters?: Array<{ name: string; value: string; source: string }>; +} - args.push(`-fv=${fhirVersion}`); +export interface ExpressionResult { + name: string; + value: string; +} - if (optionsPath && fs.existsSync(optionsPath.fsPath)) { - args.push(`-op=${optionsPath}`); - } +interface ExecuteCqlRequest { + fhirVersion: string; + rootDir: string | null; + optionsPath: string | null; + libraries: LibraryRequest[]; +} - if (rootDir) { - args.push(`-rd=${rootDir}`); - } +interface ParameterRequest { + parameterName: string; + parameterType: string; + parameterValue: string; +} - return args; +interface LibraryRequest { + libraryName: string; + libraryUri: string; + libraryVersion: string | null; + terminologyUri: string | null; + model: { modelName: string; modelUri: string } | null; + context: { contextName: string; contextValue: string } | null; + parameters: ParameterRequest[]; } -export function getExecArgs( +export async function executeCql( cqlFileUri: Uri, - testCaseUri?: Uri, - terminologyUri?: Uri, + testCases: Array, + terminologyUri: Uri, + fhirVersion: string, + optionsPath: Uri, + rootDir: Uri, contextValue?: string, - measurementPeriod?: string, -): string[] { - // TODO: One day we might support other models and contexts - const modelType = 'FHIR'; - const contextType = 'Patient'; - - let args: string[] = []; - args.push( - `-ln=${path.basename(cqlFileUri.fsPath, '.cql')}`, - `-lu=${Uri.file(path.dirname(cqlFileUri.fsPath))}`, + parametersConfig?: CqlParametersConfig, + libraryVersion?: string, +): Promise { + const request = buildRequest( + cqlFileUri, + testCases, + terminologyUri, + fhirVersion, + optionsPath, + rootDir, + contextValue, + parametersConfig, + libraryVersion, ); + return await sendRequest(Commands.EXECUTE_CQL, [request]); +} - if (testCaseUri) { - args.push(`-m=${modelType}`, `-mu=${Uri.file(testCaseUri.fsPath)}`); - } +export function buildRequest( + cqlFileUri: Uri, + testCases: Array, + terminologyUri: Uri, + fhirVersion: string, + optionsPath: Uri, + rootDir: Uri, + contextValue?: string, + parametersConfig?: CqlParametersConfig, + libraryVersion?: string, +): ExecuteCqlRequest { + const libraryName = path.basename(cqlFileUri.fsPath, '.cql'); + const libraryUri = Uri.file(path.dirname(cqlFileUri.fsPath)).toString(); + // Only read the CQL file when parametersConfig is present and no explicit version is given. + const resolvedLibraryVersion = parametersConfig + ? (libraryVersion ?? extractLibraryVersion(fs.readFileSync(cqlFileUri.fsPath, 'utf-8'))) + : undefined; - if (terminologyUri) { - args.push(`-t=${terminologyUri}`); - } + const optionsPathStr = + optionsPath && fs.existsSync(optionsPath.fsPath) ? optionsPath.toString() : null; + const rootDirStr = rootDir ? rootDir.toString() : null; + const terminologyUriStr = + terminologyUri && fs.existsSync(terminologyUri.fsPath) ? terminologyUri.toString() : null; - if (contextValue) { - args.push(`-c=${contextType}`, `-cv=${contextValue}`); - } + const libraries: LibraryRequest[] = testCases.map(testCase => { + const cv = contextValue ?? testCase.name; + const resolved = parametersConfig + ? resolveParameters(parametersConfig, libraryName, resolvedLibraryVersion, cv) + : []; + const parameters: ParameterRequest[] = resolved.map(p => ({ + parameterName: p.name, + parameterType: p.type, + parameterValue: p.value, + })); - if (measurementPeriod && measurementPeriod !== '') { - args.push( - `-p=${path.basename(cqlFileUri.fsPath)}."Measurement Period"`, - `-pv=${measurementPeriod}`, - ); - } + return { + libraryName, + libraryUri, + libraryVersion: null, + terminologyUri: terminologyUriStr, + model: testCase.path + ? { modelName: 'FHIR', modelUri: Uri.file(testCase.path.fsPath).toString() } + : null, + context: cv ? { contextName: 'Patient', contextValue: cv } : null, + parameters, + }; + }); - return args; + return { + fhirVersion, + rootDir: rootDirStr, + optionsPath: optionsPathStr, + libraries, + }; } diff --git a/src/extension.ts b/src/extension.ts index 28c1dbe..f6c994e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -118,7 +118,7 @@ export function activate(context: ExtensionContext): Promise { }; registerExecuteCql(context); - registerLogCommands(context, storageUri); + registerLogCommands(context); registerViewElmCommand(context); context.subscriptions.push(statusBar); diff --git a/src/helpers/fileHelper.ts b/src/helpers/fileHelper.ts index 3174bad..ba4043f 100644 --- a/src/helpers/fileHelper.ts +++ b/src/helpers/fileHelper.ts @@ -43,6 +43,11 @@ export function toGlobPath(fsPath: string): string { return fsPath.replace(/\\/g, '/'); } +/** Extracts the version string from a CQL library declaration, e.g. `library Foo version '1.0.0'` → `'1.0.0'`. */ +export function extractLibraryVersion(cqlSource: string): string | undefined { + return cqlSource.match(/^library\s+\S+\s+version\s+'([^']+)'/m)?.[1]; +} + export function findSubFolderByName(folderPath: string, folderName: string): vscode.Uri | null { try { logger.info(`looking in ${folderPath} for ${folderName}`); diff --git a/src/helpers/parametersHelper.ts b/src/helpers/parametersHelper.ts new file mode 100644 index 0000000..8a635eb --- /dev/null +++ b/src/helpers/parametersHelper.ts @@ -0,0 +1,64 @@ +import { + CqlParametersConfig, + LibraryParameterBlock, + ParameterEntry, + ParameterSource, +} from '../model/parameters'; + +/** + * Merges three parameter lists with increasing priority: global < library < testCase. + * When multiple entries share the same name, the highest-priority entry wins (last-write-wins). + */ +export function mergeParameters( + global: ParameterEntry[], + libraryParams: ParameterEntry[], + testCaseParams: ParameterEntry[], +): ParameterEntry[] { + const merged = new Map(); + for (const p of [...global, ...libraryParams, ...testCaseParams]) { + merged.set(p.name, p); + } + return Array.from(merged.values()); +} + +/** + * Resolves the effective parameters for a specific library + version + patient by applying + * the merge priority: global → library → testCase. + * + * Library blocks are matched by name. If a block specifies `version`, it only applies when + * `libraryVersion` matches exactly. Blocks without `version` apply to any version. + */ +export function resolveParameters( + config: CqlParametersConfig, + libraryName: string, + libraryVersion: string | undefined, + patientId: string | undefined, +): ParameterEntry[] { + const tag = (entries: ParameterEntry[], source: ParameterSource): ParameterEntry[] => + entries.map(p => ({ ...p, source })); + + const global = tag( + config.filter((e): e is ParameterEntry => !('library' in e)), + 'config-global', + ); + + const matchingBlocks = config + .filter((e): e is LibraryParameterBlock => 'library' in e) + .filter( + b => b.library === libraryName && (b.version == null || b.version === libraryVersion), + ); + + const library = tag( + matchingBlocks.flatMap(b => b.parameters ?? []), + 'config-library', + ); + + const testCase = patientId + ? tag( + matchingBlocks.flatMap(b => b.testCases?.[patientId] ?? []), + 'config-test-case', + ) + : []; + + return mergeParameters(global, library, testCase); +} diff --git a/src/model/parameters.ts b/src/model/parameters.ts new file mode 100644 index 0000000..765d9b9 --- /dev/null +++ b/src/model/parameters.ts @@ -0,0 +1,45 @@ +export type ParameterSource = 'config-global' | 'config-library' | 'config-test-case' | 'default'; + +export interface ParameterEntry { + name: string; + type: string; + value: string; + /** Populated on output only — indicates which config tier supplied this parameter. Absent on raw config.json entries. */ + source?: ParameterSource; +} + +/** + * A library-scoped parameter block in config.jsonc. + * `version` is optional — when present, parameters only apply to the library at that exact version. + * `parameters` are library-scoped overrides; `testCases` are per-patient overrides. + */ +export interface LibraryParameterBlock { + library: string; + version?: string; + parameters?: ParameterEntry[]; + testCases?: Record; +} + +/** + * A single entry in the top-level `parameters` array of config.jsonc. + * Discriminated by the presence of `library`: entries without it are global-scoped. + */ +export type ParameterConfigEntry = ParameterEntry | LibraryParameterBlock; + +/** + * The flat `parameters` array from config.jsonc. Entries without a `library` field are + * global-scoped; entries with a `library` field are library-scoped blocks. + */ +export type CqlParametersConfig = ParameterConfigEntry[]; + +/** + * Used in result files — combines config-supplied parameters + * (source: config-*) and CQL-declared defaults (source: default) into a single list. + * `type` is absent for default parameters — the CQL type is not returned by the engine. + */ +export interface ResultParameterEntry { + name: string; + type?: string; + value: string; + source: ParameterSource; +} diff --git a/src/model/testCase.ts b/src/model/testCase.ts index cb42406..c7ae75e 100644 --- a/src/model/testCase.ts +++ b/src/model/testCase.ts @@ -38,7 +38,7 @@ export function getTestCases( for (let dir of directories) { let cases = fs .readdirSync(dir) - .filter(d => fs.statSync(path.join(dir, d)).isDirectory() && !testCasesToExclude.includes(d)); + .filter(d => fs.statSync(path.join(dir, d)).isDirectory() && d !== 'shared' && !testCasesToExclude.includes(d)); for (let c of cases) { testCases.push({ name: c,