diff --git a/.gitignore b/.gitignore index 9f49338..da3ca81 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ target dist bin .vscode-test +coverage src/__test__/resources/simple-project/input/tests/results \ No newline at end of file diff --git a/.vscode-test.js b/.vscode-test.js index b7252c8..dbe1ab7 100644 --- a/.vscode-test.js +++ b/.vscode-test.js @@ -12,5 +12,6 @@ module.exports = defineConfig({ '--skip-welcome', '--skip-release-notes', '--disable-workspace-trust', + '--user-data-dir=/tmp/vscode-test-userdata', ], }); diff --git a/.vscodeignore b/.vscodeignore index a54c81a..1596831 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -5,6 +5,7 @@ src/ .gitignore tsconfig.json dist/jars +dist/java-support/jars dist/test .vscode-test diff --git a/.vscodeignore.local b/.vscodeignore.local new file mode 100644 index 0000000..848f24a --- /dev/null +++ b/.vscodeignore.local @@ -0,0 +1,18 @@ +.vscode +.idea +.github +.claude +src/ +*.vsix +.gitignore +tsconfig.json +dist/jars +dist/test + +.vscode-test +.vscode-test.js +.vscode-test.mjs +tests +.eslintrc.js +test-workspaces +scripts diff --git a/CHANGELOG.md b/CHANGELOG.md index fec71bb..dce786a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ + +## v0.9.6 (prerelease) + +Date: 2026-06-13 + +* add cql deugging support +* debug hover enhancements +* add webview config editor +* fix CQL Explorer library sorting +* fix issue with execute filtered libraries with no test cases +* add version info to test case result output +* add timestamp to server logs +* fix issue with initial debug test case quickpick race condition +* catch and surface DAP session startup errors to the user +* fix issue with cql-ls jar getting deleted when running vscode-cql unit tests +* remove old execute cql (dead-code) +* add convert CQL to ELM as an AST +* bump version to 0.9.6 + + ## v0.9.5 (prerelease) Date: 2026-05-28 diff --git a/language-configuration-ast.json b/language-configuration-ast.json new file mode 100644 index 0000000..33a4959 --- /dev/null +++ b/language-configuration-ast.json @@ -0,0 +1,10 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "indentationRules": { + "increaseIndentPattern": "^.*└── .*:$", + "decreaseIndentPattern": "^ └── " + } +} diff --git a/package-lock.json b/package-lock.json index cdf9d4d..5868dca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cql", - "version": "0.9.4", + "version": "0.9.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cql", - "version": "0.9.4", + "version": "0.9.6", "license": "Apache-2.0", "dependencies": { "expand-tilde": "^2.0.2", @@ -34,6 +34,7 @@ "@types/mock-fs": "^4.13.4", "@types/node": "^16.18.34", "@types/node-fetch": "^2.6.2", + "@types/sinon": "^21.0.1", "@types/vscode": "^1.73.0", "@types/winreg": "^1.2.31", "@vscode/test-cli": "^0.0.12", @@ -43,6 +44,7 @@ "mock-fs": "^5.5.0", "nyc": "^17.1.0", "prettier": "^3.3.3", + "sinon": "^22.0.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" }, @@ -608,6 +610,47 @@ "node": ">=14" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -731,6 +774,23 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/sinon": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -3940,6 +4000,33 @@ "dev": true, "license": "ISC" }, + "node_modules/sinon": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5381,6 +5468,42 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "optional": true }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + }, + "dependencies": { + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + } + } + }, + "@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, "@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -5489,6 +5612,21 @@ "form-data": "^4.0.4" } }, + "@types/sinon": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true + }, "@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -7642,6 +7780,26 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "sinon": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" + }, + "dependencies": { + "diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true + } + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 4565290..3d41525 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cql", - "version": "0.9.5", + "version": "0.9.6", "displayName": "Clinical Quality Language (CQL)", "description": "Syntax highlighting, linting, and execution for the HL7 Clinical Quality Language (CQL) for VS Code", "publisher": "cqframework", @@ -51,6 +51,16 @@ "CQL", "Cql" ] + }, + { + "id": "ast", + "extensions": [ + ".ast" + ], + "configuration": "./language-configuration-ast.json", + "aliases": [ + "CQL ELM AST" + ] } ], "snippets": [ @@ -64,6 +74,11 @@ "language": "cql", "scopeName": "source.cql", "path": "./syntaxes/cql.tmLanguage.json" + }, + { + "language": "ast", + "scopeName": "source.ast", + "path": "./syntaxes/ast.tmLanguage.json" } ], "commands": [ @@ -77,6 +92,11 @@ "title": "Execute CQL - Select Test Cases", "category": "CQL" }, + { + "command": "cql.editor.debug-test-case", + "title": "Debug Test Case", + "category": "CQL" + }, { "command": "cql.editor.view-elm.json", "title": "View ELM - JSON", @@ -87,6 +107,21 @@ "title": "View ELM - XML", "category": "CQL" }, + { + "command": "cql.editor.view-elm.ast", + "title": "View ELM - AST", + "category": "CQL" + }, + { + "command": "cql.editor.view-elm.ast.split", + "title": "View ELM - AST (Split)", + "category": "CQL" + }, + { + "command": "cql.debug.ast.reveal-cql", + "title": "Reveal in CQL Editor", + "category": "CQL" + }, { "command": "cql.execute.select-libraries", "title": "Execute CQL - Select Libraries", @@ -146,13 +181,13 @@ }, { "command": "cql.explorer.sort-asc", - "title": "Sort A \u2192 Z", + "title": "Sort A → Z", "icon": "$(arrow-up)", "category": "CQL Explorer" }, { "command": "cql.explorer.sort-desc", - "title": "Sort Z \u2192 A", + "title": "Sort Z → A", "icon": "$(arrow-down)", "category": "CQL Explorer" }, @@ -180,6 +215,18 @@ "category": "CQL Explorer", "icon": "$(symbol-structure)" }, + { + "command": "cql.explorer.library.ast", + "title": "View ELM - AST", + "category": "CQL Explorer", + "icon": "$(symbol-structure)" + }, + { + "command": "cql.explorer.test-case.debug", + "title": "Debug Test Case", + "category": "CQL Explorer", + "icon": "$(debug-alt)" + }, { "command": "cql.explorer.test-case.execute", "title": "Execute Test Case", @@ -287,16 +334,15 @@ "category": "CQL Explorer" }, { - "command": "cql.explorer.show-layout-warnings", - "title": "Show Layout Warnings", - "icon": "$(warning)", + "command": "cql.explorer.edit-config", + "title": "Edit Configuration", + "icon": "$(gear)", "category": "CQL Explorer" }, { - "command": "cql.explorer.hide-layout-warnings", - "title": "Hide Layout Warnings", - "icon": "$(warning)", - "category": "CQL Explorer" + "command": "cql.debug.toggle-step-granularity", + "title": "CQL: Toggle Step Granularity (CQL ↔ AST)", + "category": "CQL" } ], "menus": { @@ -305,6 +351,10 @@ "command": "cql.editor.execute.select-test-cases", "when": "false" }, + { + "command": "cql.editor.debug-test-case", + "when": "false" + }, { "command": "cql.editor.view-elm.json", "when": "false" @@ -313,6 +363,18 @@ "command": "cql.editor.view-elm.xml", "when": "false" }, + { + "command": "cql.editor.view-elm.ast", + "when": "false" + }, + { + "command": "cql.debug.ast.reveal-cql", + "when": "false" + }, + { + "command": "cql.editor.view-elm.ast.split", + "when": "false" + }, { "command": "cql.explorer.refresh", "when": "false" @@ -329,6 +391,14 @@ "command": "cql.explorer.library.elm.xml", "when": "false" }, + { + "command": "cql.explorer.library.ast", + "when": "false" + }, + { + "command": "cql.explorer.test-case.debug", + "when": "false" + }, { "command": "cql.explorer.test-case.execute", "when": "false" @@ -428,14 +498,6 @@ { "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": [ @@ -449,6 +511,16 @@ "when": "editorLangId == cql && cql.languageServerReady", "group": "1_cqlactions" }, + { + "command": "cql.editor.view-elm.ast", + "when": "editorLangId == cql && cql.languageServerReady", + "group": "1_cqlactions" + }, + { + "command": "cql.editor.view-elm.ast.split", + "when": "editorLangId == cql && cql.languageServerReady", + "group": "1_cqlactions" + }, { "command": "cql.editor.execute", "when": "editorLangId == cql && cql.languageServerReady", @@ -458,6 +530,11 @@ "command": "cql.editor.execute.select-test-cases", "when": "editorLangId == cql && cql.languageServerReady", "group": "1_cqlactions" + }, + { + "command": "cql.editor.debug-test-case", + "when": "editorLangId == cql && cql.languageServerReady", + "group": "1_cqlactions" } ], "view/item/context": [ @@ -476,11 +553,21 @@ "group": "2:library", "when": "view == cql-libraries && viewItem == cql-library" }, + { + "command": "cql.explorer.library.ast", + "group": "2:library", + "when": "view == cql-libraries && viewItem == cql-library" + }, { "command": "cql.explorer.test-case.execute", "group": "1:test-case", "when": "view == cql-libraries && viewItem == cql-testcase" }, + { + "command": "cql.explorer.test-case.debug", + "group": "1:test-case", + "when": "view == cql-libraries && viewItem == cql-testcase" + }, { "command": "cql.explorer.test-case.open-resources", "group": "2:test-case", @@ -536,6 +623,21 @@ "when": "view == cql-libraries && viewItem == cql-testcase", "group": "inline@2" }, + { + "command": "cql.explorer.test-case.debug", + "when": "view == cql-libraries && viewItem == cql-testcase", + "group": "inline@3" + }, + { + "command": "cql.explorer.test-case.debug", + "when": "view == cql-libraries && viewItem =~ /cql-testcase-root/", + "group": "1:test-case-root" + }, + { + "command": "cql.explorer.test-case.debug", + "when": "view == cql-libraries && viewItem =~ /cql-testcase-root/", + "group": "inline@4" + }, { "command": "cql.explorer.test-case.execute-select", "when": "view == cql-libraries && viewItem =~ /cql-testcase-root/", @@ -583,6 +685,11 @@ } ], "view/title": [ + { + "command": "cql.explorer.edit-config", + "when": "view == cql-libraries && cql.solutionLoaded", + "group": "navigation@0" + }, { "command": "cql.execute.select-libraries", "when": "view == cql-libraries && cql.solutionLoaded", @@ -637,16 +744,6 @@ "command": "cql.explorer.sort-asc", "when": "view == cql-libraries && cql.sortDescending", "group": "2_sort@1" - }, - { - "command": "cql.explorer.show-layout-warnings", - "when": "view == cql-libraries && !cql.showLayoutWarnings", - "group": "3_warnings@1" - }, - { - "command": "cql.explorer.hide-layout-warnings", - "when": "view == cql-libraries && cql.showLayoutWarnings", - "group": "3_warnings@1" } ] }, @@ -656,8 +753,66 @@ "id": "cql-libraries", "name": "CQL Explorer" } + ], + "debug": [ + { + "id": "cql.debug.ast", + "name": "CQL AST", + "when": "cql.debug.active" + } ] }, + "debuggers": [ + { + "type": "cql", + "label": "CQL Debug", + "languages": [ + "cql" + ], + "configurationAttributes": { + "launch": { + "required": [ + "libraryUri", + "fhirVersion" + ], + "properties": { + "libraryUri": { + "type": "string" + }, + "fhirVersion": { + "type": "string" + }, + "testCaseName": { + "type": "string" + }, + "testCaseUri": { + "type": "string" + }, + "terminologyUri": { + "type": "string" + }, + "rootDir": { + "type": "string" + }, + "optionsPath": { + "type": "string" + }, + "stepGranularity": { + "type": "string", + "enum": ["cql", "ast"], + "default": "cql", + "description": "Whether step requests advance through CQL source lines (default) or individual ELM/AST nodes." + } + } + } + } + } + ], + "breakpoints": [ + { + "language": "cql" + } + ], "jsonValidation": [ { "fileMatch": "**/input/tests/config.jsonc", @@ -672,7 +827,8 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", - "pretest": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && npm run compile", + "package:local": "npm run compile && node scripts/download-jar.js && npx @vscode/vsce package --ignoreFile .vscodeignore.local", + "pretest": "node scripts/clean-dist.js && npm run compile", "test": "vscode-test", "changelog": "node scripts/update-changelog.js" }, @@ -680,7 +836,7 @@ "cql-language-server": { "groupId": "org.opencds.cqf.cql.ls", "artifactId": "cql-ls-server", - "version": "4.7.0" + "version": "4.8.0" } }, "devDependencies": { @@ -692,6 +848,7 @@ "@types/mock-fs": "^4.13.4", "@types/node": "^16.18.34", "@types/node-fetch": "^2.6.2", + "@types/sinon": "^21.0.1", "@types/vscode": "^1.73.0", "@types/winreg": "^1.2.31", "@vscode/test-cli": "^0.0.12", @@ -701,6 +858,7 @@ "mock-fs": "^5.5.0", "nyc": "^17.1.0", "prettier": "^3.3.3", + "sinon": "^22.0.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" }, diff --git a/scripts/clean-dist.js b/scripts/clean-dist.js new file mode 100644 index 0000000..1b48a76 --- /dev/null +++ b/scripts/clean-dist.js @@ -0,0 +1,52 @@ +const fs = require('fs'); +const path = require('path'); + +const distPath = path.resolve(__dirname, '..', 'dist'); + +if (!fs.existsSync(distPath)) { + process.exit(0); +} + +function deleteRecursively(targetPath, preservePaths) { + if (!fs.existsSync(targetPath)) return; + + const stats = fs.lstatSync(targetPath); + const relativePath = path.relative(distPath, targetPath); + + // Is this path exactly a preserved path or a child of one? + const isPreserved = preservePaths.some(p => + relativePath === p || relativePath.startsWith(p + path.sep) + ); + + if (isPreserved) { + // Keep it + return; + } + + // Is this path a parent of a preserved path? + const isParentOfPreserved = preservePaths.some(p => + p.startsWith(relativePath + path.sep) + ); + + if (isParentOfPreserved) { + if (stats.isDirectory()) { + const files = fs.readdirSync(targetPath); + for (const file of files) { + deleteRecursively(path.join(targetPath, file), preservePaths); + } + } + } else { + // Not preserved and not a parent of something preserved, so delete it + fs.rmSync(targetPath, { recursive: true, force: true }); + } +} + +// Preserve the java-support/jars directory +const preserve = [ + path.join('java-support', 'jars') +]; + +const topLevelFiles = fs.readdirSync(distPath); +for (const file of topLevelFiles) { + deleteRecursively(path.join(distPath, file), preserve); +} diff --git a/scripts/download-jar.js b/scripts/download-jar.js new file mode 100644 index 0000000..b386195 --- /dev/null +++ b/scripts/download-jar.js @@ -0,0 +1,79 @@ +//@ts-check +'use strict'; + +/** + * Downloads the cql-language-server JAR from Maven Central into + * dist/java-support/jars/ so it can be bundled into a local VSIX. + * + * Reads Maven coordinates from package.json javaDependencies. + * Skips the download if the JAR is already present. + * + * Usage: node scripts/download-jar.js + */ + +const fs = require('fs'); +const https = require('https'); +const path = require('path'); + +const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); +const coords = pkg['javaDependencies']['cql-language-server']; +const { groupId, artifactId, version } = coords; + +const groupPath = groupId.replace(/\./g, '/'); +const jarName = `${artifactId}-${version}.jar`; +const jarDir = path.join('dist', 'java-support', 'jars'); +const jarPath = path.join(jarDir, jarName); +const url = `https://repo1.maven.org/maven2/${groupPath}/${artifactId}/${version}/${jarName}`; + +if (fs.existsSync(jarPath)) { + console.log(`JAR already present: ${jarPath}`); + process.exit(0); +} + +fs.mkdirSync(jarDir, { recursive: true }); + +console.log(`Downloading ${jarName}...`); +console.log(` from: ${url}`); +console.log(` to: ${jarPath}`); + +downloadFile(url, jarPath) + .then(() => console.log('Done.')) + .catch(err => { + console.error(`Download failed: ${err.message}`); + if (fs.existsSync(jarPath)) { + fs.unlinkSync(jarPath); + } + process.exit(1); + }); + +/** + * Downloads a URL to a local file, following a single redirect if needed. + * @param {string} url + * @param {string} dest + * @returns {Promise} + */ +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + https + .get(url, res => { + if (res.statusCode === 301 || res.statusCode === 302) { + const location = res.headers['location']; + if (!location) { + return reject(new Error(`Redirect with no Location header from ${url}`)); + } + return downloadFile(location, dest).then(resolve).catch(reject); + } + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + } + const file = fs.createWriteStream(dest); + res.pipe(file); + file.on('finish', () => file.close(() => resolve())); + file.on('error', err => { + fs.unlink(dest, () => {}); + reject(err); + }); + }) + .on('error', reject); + }); +} diff --git a/src/__test__/resources/simple-project/.vscode/launch.json b/src/__test__/resources/simple-project/.vscode/launch.json new file mode 100644 index 0000000..5c7247b --- /dev/null +++ b/src/__test__/resources/simple-project/.vscode/launch.json @@ -0,0 +1,7 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [] +} \ No newline at end of file diff --git a/src/__test__/resources/simple-project/input/cql/SimpleMeasure.cql b/src/__test__/resources/simple-project/input/cql/SimpleMeasure.cql index 3b52fe4..2db23b0 100644 --- a/src/__test__/resources/simple-project/input/cql/SimpleMeasure.cql +++ b/src/__test__/resources/simple-project/input/cql/SimpleMeasure.cql @@ -8,22 +8,3 @@ context Patient define "isOfficial": exists (Patient.name Name where Name.use.value = 'official') - -define "Patient Name": - Coalesce( - "Get Name String"(First(Patient.name Name where Name.use.value = 'official')), - "Get Name String"(First(Patient.name Name where Name.use.value = 'usual')), - "Get Name String"(First(Patient.name)), - 'Unknown' - ) - -define function "Get Name String"(name FHIR.HumanName): - if name is null then null - else - Coalesce( - name.text.value, - Combine(name.given.value, ' ') + ' ' + Coalesce(name.family.value, '') - ) - -define TestAge: Age { value: decimal { value: 12.0 }, unit: string { value: 'a' }, system: uri { value: 'http://unitsofmeasure.org' }, code: code { value: 'a' } } -define TestAgeSpecificallyConverts: FHIRHelpers.ToQuantity(TestAge) diff --git a/src/__test__/suite/commands/debug-test-case.test.ts b/src/__test__/suite/commands/debug-test-case.test.ts new file mode 100644 index 0000000..e0fdfbb --- /dev/null +++ b/src/__test__/suite/commands/debug-test-case.test.ts @@ -0,0 +1,125 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { debug, Uri, window, workspace } from 'vscode'; +import { CqlLibrary } from '../../../model/cqlProject'; +import { promptAndDebugTestCase } from '../../../commands/debug-test-case'; + +suite('promptAndDebugTestCase', () => { + let showInfoStub: sinon.SinonStub; + let createQuickPickStub: sinon.SinonStub; + let startDebuggingStub: sinon.SinonStub; + let quickPickFake: { + items: { label: string }[]; + canSelectMany: boolean; + placeholder: string; + title: string; + selectedItems: { label: string }[]; + activeItems: { label: string }[]; + onDidAccept: (...args: any[]) => void; + onDidHide: (...args: any[]) => void; + show: () => void; + hide: () => void; + }; + let acceptHandlerRef: { current: (() => void) | undefined }; + let hideHandlerRef: { current: (() => void) | undefined }; + + setup(() => { + acceptHandlerRef = { current: undefined }; + hideHandlerRef = { current: undefined }; + showInfoStub = sinon.stub(window, 'showInformationMessage'); + + const makeEvent = (ref: { current: (() => void) | undefined }) => { + const eventFn = (cb: () => void) => { + ref.current = cb; + return { dispose: () => {} }; + }; + (eventFn as any).addListener = (cb: () => void) => { + ref.current = cb; + }; + (eventFn as any).removeListener = () => {}; + return eventFn; + }; + + quickPickFake = { + items: [] as { label: string }[], + canSelectMany: false, + placeholder: '', + title: '', + selectedItems: [] as { label: string }[], + activeItems: [] as { label: string }[], + onDidAccept: makeEvent(acceptHandlerRef), + onDidHide: makeEvent(hideHandlerRef), + show: () => {}, + hide: () => {}, + }; + createQuickPickStub = sinon.stub(window, 'createQuickPick').returns(quickPickFake as any); + startDebuggingStub = sinon.stub(debug, 'startDebugging').resolves(); + }); + + teardown(() => { + showInfoStub.restore(); + createQuickPickStub.restore(); + startDebuggingStub.restore(); + }); + + test('with zero test cases, shows "No test cases found." message', async () => { + const wsRoot = workspace.workspaceFolders![0].uri; + const lib = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/SimpleMeasure.cql')); + await promptAndDebugTestCase(lib); + expect(showInfoStub.calledWith('No test cases found.')).to.be.true; + }); + + test('with exactly one test case, creates and shows quick pick (not skipped to direct debug)', async () => { + const wsRoot = workspace.workspaceFolders![0].uri; + const lib = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/SimpleMeasure.cql')); + lib.addTestCase({ name: 'patient-1', uri: Uri.joinPath(wsRoot, 'fhir/patient-1.json') } as any); + + const acceptPromise = promptAndDebugTestCase(lib); + + expect(createQuickPickStub.called).to.be.true; + expect(quickPickFake.items).to.have.length(1); + expect(quickPickFake.items[0].label).to.equal('patient-1'); + expect(startDebuggingStub.called).to.be.false; + + quickPickFake.selectedItems = [quickPickFake.items[0]]; + expect(acceptHandlerRef.current).to.be.a('function'); + acceptHandlerRef.current!(); + + await acceptPromise; + expect(startDebuggingStub.calledWith( + sinon.match.any, + sinon.match({ + name: 'Debug SimpleMeasure — patient-1', + libraryName: 'SimpleMeasure', + testCaseName: 'patient-1', + }), + )).to.be.true; + }); + + test('with multiple test cases, creates and shows quick pick with all items', async () => { + const wsRoot = workspace.workspaceFolders![0].uri; + const lib = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/SimpleMeasure.cql')); + lib.addTestCase({ name: 'patient-1', uri: Uri.joinPath(wsRoot, 'fhir/patient-1.json') } as any); + lib.addTestCase({ name: 'patient-2', uri: Uri.joinPath(wsRoot, 'fhir/patient-2.json') } as any); + + const acceptPromise = promptAndDebugTestCase(lib); + + expect(createQuickPickStub.called).to.be.true; + expect(quickPickFake.items).to.have.length(2); + expect(quickPickFake.items.map(i => i.label)).to.deep.equal(['patient-1', 'patient-2']); + expect(startDebuggingStub.called).to.be.false; + + quickPickFake.selectedItems = [quickPickFake.items[1]]; + acceptHandlerRef.current!(); + + await acceptPromise; + expect(startDebuggingStub.calledWith( + sinon.match.any, + sinon.match({ + name: 'Debug SimpleMeasure — patient-2', + libraryName: 'SimpleMeasure', + testCaseName: 'patient-2', + }), + )).to.be.true; + }); +}); \ No newline at end of file diff --git a/src/__test__/suite/commands/execute-cql-file.test.ts b/src/__test__/suite/commands/execute-cql-file.test.ts new file mode 100644 index 0000000..81d62cb --- /dev/null +++ b/src/__test__/suite/commands/execute-cql-file.test.ts @@ -0,0 +1,294 @@ +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Uri } from 'vscode'; +import { + formatResponse, + TestCaseResult, + writeIndividualResultFiles, +} from '../../../commands/execute-cql-file'; +import { ExecuteCqlResponse } from '../../../cql-service/cqlService.executeCql'; +import { CqlParametersConfig } from '../../../model/parameters'; + +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(''); + }); + + test('omits version header entirely when versions is undefined', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[Encounter]' }] }], + logs: [], + }; + const output = formatResponse(response); + expect(output).to.equal('IPP=[Encounter]'); + }); +}); + +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('includes versions in JSON output when present in response', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }], + logs: [], + versions: { + translator: '4.9.0', + engine: '4.9.0', + clinicalReasoning: '4.7.0', + languageServer: '4.8.0', + }, + }; + writeIndividualResultFiles('MyLib', undefined, [{ name: 'p1' }], response, Uri.file(tmpDir), Date.now()); + const result = readResult('MyLib', 'p1'); + expect(result.versions).to.deep.equal({ + translator: '4.9.0', + engine: '4.9.0', + clinicalReasoning: '4.7.0', + languageServer: '4.8.0', + }); + }); + + test('omits versions from JSON output when not present in response', () => { + 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.versions).to.be.undefined; + }); + + 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/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index af04112..abab42d 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -57,6 +57,24 @@ suite('viewElm()', () => { expect(editor!.document.getText()).to.equal(MOCK_XML_ELM); }); + test('opens an AST document containing the returned ELM', async () => { + const cqlUri = Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/cql/SimpleMeasure.cql', + ); + + const MOCK_AST_ELM = `Library: SimpleMeasure (version 1.0.0) +├── define: "Patient" +│ └── Retrieve (dataType: Patient)`; + + await viewElm(cqlUri, 'ast', async () => MOCK_AST_ELM); + + const editor = window.activeTextEditor; + expect(editor, 'expected an active text editor after viewElm').to.not.be.undefined; + expect(editor!.document.languageId).to.equal('ast'); + expect(editor!.document.getText()).to.equal(MOCK_AST_ELM); + }); + test('defaults to XML when no type is specified', async () => { const cqlUri = Uri.joinPath( workspace.workspaceFolders![0].uri, @@ -74,7 +92,7 @@ suite('viewElm()', () => { workspace.workspaceFolders![0].uri, 'input/cql/SimpleMeasure.cql', ); - const failingFetcher = async (_uri: Uri, _type: 'xml' | 'json'): Promise => { + const failingFetcher = async (_uri: Uri, _type: 'xml' | 'json' | 'ast'): Promise => { throw new Error('language server unavailable'); }; @@ -90,7 +108,7 @@ suite('viewElm()', () => { ); let capturedType: string | undefined; - const capturingFetcher = async (_uri: Uri, type: 'xml' | 'json'): Promise => { + const capturingFetcher = async (_uri: Uri, type: 'xml' | 'json' | 'ast'): Promise => { capturedType = type; return MOCK_JSON_ELM; }; @@ -109,7 +127,7 @@ suite('viewElm()', () => { ); let capturedUri: Uri | undefined; - const capturingFetcher = async (uri: Uri, _type: 'xml' | 'json'): Promise => { + const capturingFetcher = async (uri: Uri, _type: 'xml' | 'json' | 'ast'): Promise => { capturedUri = uri; return MOCK_JSON_ELM; }; @@ -118,3 +136,43 @@ suite('viewElm()', () => { expect(capturedUri?.fsPath).to.equal(cqlUri.fsPath); }); }); + +suite('viewElm() AST reuse', () => { + const MOCK_AST = `Library: SimpleMeasure (version 1.0.0) +├── define: "Patient" +│ └── Retrieve (dataType: Patient)`; + + teardown(async () => { + await commands.executeCommand('workbench.action.closeAllEditors'); + }); + + test('reuses existing AST doc on second call', async () => { + const cqlUri = Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/cql/SimpleMeasure.cql', + ); + + await viewElm(cqlUri, 'ast', async () => MOCK_AST); + const docsAfterFirst = workspace.textDocuments.filter(d => d.languageId === 'ast'); + expect(docsAfterFirst.length).to.equal(1); + + await viewElm(cqlUri, 'ast', async () => MOCK_AST); + const docsAfterSecond = workspace.textDocuments.filter(d => d.languageId === 'ast'); + expect(docsAfterSecond.length).to.equal(1); + }); + + test('creates a new AST doc when previous was closed', async () => { + const cqlUri = Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/cql/SimpleMeasure.cql', + ); + + await viewElm(cqlUri, 'ast', async () => MOCK_AST); + await commands.executeCommand('workbench.action.closeAllEditors'); + + await viewElm(cqlUri, 'ast', async () => MOCK_AST); + const editor = window.activeTextEditor; + expect(editor, 'expected an active text editor').to.not.be.undefined; + expect(editor!.document.languageId).to.equal('ast'); + }); +}); diff --git a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts index 632beda..abc3136 100644 --- a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts +++ b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { expect } from 'chai'; import * as vscode from 'vscode'; import { Uri, workspace } from 'vscode'; @@ -91,6 +92,90 @@ suite('CqlLibraryRootTreeItem.rebuildTestCases', () => { expect(getTestCaseNames(item)).to.have.members(['1111', '2222']); }); + test('constructor sorts test cases alphabetically', () => { + const freshLib = new CqlLibrary( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/cql/SimpleMeasure.cql', + ), + ); + freshLib.testCaseLoadState = 'loaded'; + // Add test cases in reverse alphabetical order + freshLib.addTestCase( + new CqlTestCase( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/tests/Measure/SimpleMeasure/3333', + ), + ), + ); + freshLib.addTestCase( + new CqlTestCase( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/tests/Measure/SimpleMeasure/1111', + ), + ), + ); + freshLib.addTestCase( + new CqlTestCase( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/tests/Measure/SimpleMeasure/2222', + ), + ), + ); + + const item = new CqlLibraryRootTreeItem( + freshLib, + vscode.TreeItemCollapsibleState.Collapsed, + ); + expect(getTestCaseNames(item)).to.deep.equal(['1111', '2222', '3333']); + }); + + test('rebuildTestCases preserves alphabetical order', () => { + const freshLib = new CqlLibrary( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/cql/SimpleMeasure.cql', + ), + ); + freshLib.testCaseLoadState = 'loaded'; + // Add test cases in reverse alphabetical order + freshLib.addTestCase( + new CqlTestCase( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/tests/Measure/SimpleMeasure/3333', + ), + ), + ); + freshLib.addTestCase( + new CqlTestCase( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/tests/Measure/SimpleMeasure/1111', + ), + ), + ); + freshLib.addTestCase( + new CqlTestCase( + Uri.joinPath( + workspace.workspaceFolders![0].uri, + 'input/tests/Measure/SimpleMeasure/2222', + ), + ), + ); + + const item = new CqlLibraryRootTreeItem( + freshLib, + vscode.TreeItemCollapsibleState.Collapsed, + ); + // Rebuild with same filter + item.rebuildTestCases(''); + expect(getTestCaseNames(item)).to.deep.equal(['1111', '2222', '3333']); + }); + test('rebuildTestCases adds Results node when result is added', () => { const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); // Initially no result @@ -117,3 +202,124 @@ suite('CqlLibraryRootTreeItem.rebuildTestCases', () => { expect(item.children.some(c => c instanceof CqlResultsRootTreeItem)).to.be.false; }); }); + +suite('CqlLibraryRootTreeItem — CMS-numbered test case sorting', () => { + let lib: CqlLibrary; + const cmsDirs: string[] = []; + + setup(() => { + const wsRoot = workspace.workspaceFolders![0].uri; + const libUri = Uri.joinPath(wsRoot, 'input/cql/SimpleMeasure.cql'); + lib = new CqlLibrary(libUri); + lib.testCaseLoadState = 'loaded'; + }); + + teardown(() => { + for (const dir of cmsDirs) { + try { fs.rmdirSync(dir); } catch { /* ok */ } + } + cmsDirs.length = 0; + }); + + function createTestCase(name: string): CqlTestCase { + const wsRoot = workspace.workspaceFolders![0].uri; + const dir = Uri.joinPath(wsRoot, `input/tests/Measure/SimpleMeasure/${name}`).fsPath; + fs.mkdirSync(dir, { recursive: true }); + cmsDirs.push(dir); + return new CqlTestCase(Uri.file(dir)); + } + + function getTestCaseNames(item: CqlLibraryRootTreeItem): string[] { + const root = item.children.find(c => c instanceof CqlTestCaseRootTreeItem) as + | CqlTestCaseRootTreeItem + | undefined; + if (!root) return []; + return root.children + .filter((c): c is CqlTestCaseTreeItem => c instanceof CqlTestCaseTreeItem) + .map(c => c.cqlTestCase.name); + } + + test('constructor sorts CMS-named test cases by numeric measure number', () => { + const tcCMS22 = createTestCase('CMS22FHIRPCSBPScreeningFollowUp'); + const tcCMS2 = createTestCase('CMS2FHIRPCSDepScreenAndFollowUp'); + const tcCMS129 = createTestCase('CMS129FHIRProstCaBoneScanUse'); + // Add in reverse numeric order + lib.addTestCase(tcCMS129); + lib.addTestCase(tcCMS22); + lib.addTestCase(tcCMS2); + + const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); + expect(getTestCaseNames(item)).to.deep.equal([ + 'CMS2FHIRPCSDepScreenAndFollowUp', + 'CMS22FHIRPCSBPScreeningFollowUp', + 'CMS129FHIRProstCaBoneScanUse', + ]); + }); + + test('constructor sorts non-CMS (< CMS alphabetically), then CMS by number, then non-CMS (> CMS alphabetically)', () => { + const tcCMS22 = createTestCase('CMS22FHIRPCSBPScreeningFollowUp'); + const tcAlpha = createTestCase('AHAOverall'); + const tcCMS2 = createTestCase('CMS2FHIRPCSDepScreenAndFollowUp'); + const tcBeta = createTestCase('Antibiotic'); + lib.addTestCase(tcBeta); + lib.addTestCase(tcCMS22); + lib.addTestCase(tcAlpha); + lib.addTestCase(tcCMS2); + + const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); + expect(getTestCaseNames(item)).to.deep.equal([ + 'AHAOverall', + 'Antibiotic', + 'CMS2FHIRPCSDepScreenAndFollowUp', + 'CMS22FHIRPCSBPScreeningFollowUp', + ]); + }); + + test('constructor handles CMSFHIR prefix in numeric measure order', () => { + const tcCMSFHIR529 = createTestCase('CMSFHIR529HybirdHospitalWidReadmission'); + const tcCMS2 = createTestCase('CMS2FHIRPCSDepScreenAndFollowUp'); + lib.addTestCase(tcCMSFHIR529); + lib.addTestCase(tcCMS2); + + const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); + expect(getTestCaseNames(item)).to.deep.equal([ + 'CMS2FHIRPCSDepScreenAndFollowUp', + 'CMSFHIR529HybirdHospitalWidReadmission', + ]); + }); + + test('non-CMS names before CMS alphabetically come first, CMS by number in middle, non-CMS after CMS alphabetically come last', () => { + const tcCMS = createTestCase('CMS2FHIRSimple'); + const tcZebra = createTestCase('Zebra'); + const tcAlpha = createTestCase('Alpha'); + lib.addTestCase(tcZebra); + lib.addTestCase(tcCMS); + lib.addTestCase(tcAlpha); + + const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); + expect(getTestCaseNames(item)).to.deep.equal([ + 'Alpha', + 'CMS2FHIRSimple', + 'Zebra', + ]); + }); + + test('rebuildTestCases preserves CMS-numbered sort order', () => { + const tcCMS22 = createTestCase('CMS22FHIRPCSBPScreeningFollowUp'); + const tcCMS2 = createTestCase('CMS2FHIRPCSDepScreenAndFollowUp'); + lib.addTestCase(tcCMS22); + lib.addTestCase(tcCMS2); + + const item = new CqlLibraryRootTreeItem(lib, vscode.TreeItemCollapsibleState.Collapsed); + expect(getTestCaseNames(item)).to.deep.equal([ + 'CMS2FHIRPCSDepScreenAndFollowUp', + 'CMS22FHIRPCSBPScreeningFollowUp', + ]); + + item.rebuildTestCases(''); + expect(getTestCaseNames(item)).to.deep.equal([ + 'CMS2FHIRPCSDepScreenAndFollowUp', + 'CMS22FHIRPCSBPScreeningFollowUp', + ]); + }); +}); diff --git a/src/__test__/suite/cql-explorer/cqlTestCaseRootTreeItem.test.ts b/src/__test__/suite/cql-explorer/cqlTestCaseRootTreeItem.test.ts index 0a17b89..c969c48 100644 --- a/src/__test__/suite/cql-explorer/cqlTestCaseRootTreeItem.test.ts +++ b/src/__test__/suite/cql-explorer/cqlTestCaseRootTreeItem.test.ts @@ -104,4 +104,21 @@ suite('CqlTestCaseRootTreeItem filtering', () => { const root = makeRoot('cancer'); expect(root.description).to.equal('cancer'); }); + + test('resetChildren sorts test cases alphabetically', () => { + // Add test cases to library in reverse alphabetical order + lib.addTestCase(tc2222); + lib.addTestCase(tc1111); + + const root = makeRoot(); + root.addTestCase(tc2222); + root.addTestCase(tc1111); + + // children match insertion order before reset + expect(root.children.map(c => (c as any).cqlTestCase.name)).to.deep.equal(['2222', '1111']); + + // resetChildren re-sorts via library TestCases + root.resetChildren(); + expect(root.children.map(c => (c as any).cqlTestCase.name)).to.deep.equal(['1111', '2222']); + }); }); diff --git a/src/__test__/suite/cql-explorer/getChildren.test.ts b/src/__test__/suite/cql-explorer/getChildren.test.ts index 15b2b4a..a058412 100644 --- a/src/__test__/suite/cql-explorer/getChildren.test.ts +++ b/src/__test__/suite/cql-explorer/getChildren.test.ts @@ -6,6 +6,7 @@ import { CqlLibraryRootTreeItem, CqlProjectRootTreeItem, CqlProjectTreeDataProvider, + CqlTestCaseRootTreeItem, CqlTestCasesLoadingTreeItem, } from '../../../cql-explorer/cqlProjectTreeDataProvider'; import { DeviationKind } from '../../../model/igLayoutDetector'; @@ -211,6 +212,89 @@ suite('CqlProjectTreeDataProvider.sortRootItemsInPlace', () => { const order = provider.getRootItems().map(i => i.label as string); expect(order).to.deep.equal(['Apple', 'Banana']); }); + + test('ascending sort puts CMS libraries in numeric measure order', () => { + const libCMS2 = new CqlLibrary( + Uri.joinPath(wsRoot, 'input/cql/CMS2FHIRPCSDepScreenAndFollowUp.cql'), + ); + const libCMS22 = new CqlLibrary( + Uri.joinPath(wsRoot, 'input/cql/CMS22FHIRPCSBPScreeningCollowUp.cql'), + ); + const libCMS128 = new CqlLibrary( + Uri.joinPath(wsRoot, 'input/cql/CMS128FHIRHHRF.cql'), + ); + // Libraries provided in reverse numeric order + const provider = new CqlProjectTreeDataProvider( + fakeProject('P', [libCMS128, libCMS2, libCMS22]), + ); + + const order = provider.getRootItems().map(i => i.label as string); + expect(order).to.deep.equal([ + 'CMS2FHIRPCSDepScreenAndFollowUp', + 'CMS22FHIRPCSBPScreeningCollowUp', + 'CMS128FHIRHHRF', + ]); + }); + + test('ascending sort handles CMSFHIR prefix in numeric measure order', () => { + const libCMS2 = new CqlLibrary( + Uri.joinPath(wsRoot, 'input/cql/CMS2FHIR.cql'), + ); + const libCMSFHIR529 = new CqlLibrary( + Uri.joinPath(wsRoot, 'input/cql/CMSFHIR529Hybrid.cql'), + ); + // CMSFHIR529Hybrid has measure 529, sorts after CMS2 (measure 2) + const provider = new CqlProjectTreeDataProvider( + fakeProject('P', [libCMSFHIR529, libCMS2]), + ); + + const order = provider.getRootItems().map(i => i.label as string); + expect(order).to.deep.equal(['CMS2FHIR', 'CMSFHIR529Hybrid']); + }); + + test('ascending sort puts non-CMS (before CMS alphabetically), then CMS by number, then non-CMS (after CMS alphabetically)', () => { + const libCMS2 = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/CMS2.cql')); + const libApple = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/Apple.cql')); + const libBanana = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/Banana.cql')); + // Libraries in mixed order + const provider = new CqlProjectTreeDataProvider( + fakeProject('P', [libBanana, libCMS2, libApple]), + ); + + const order = provider.getRootItems().map(i => i.label as string); + expect(order).to.deep.equal(['Apple', 'Banana', 'CMS2']); + }); + + test('descending sort reverses CMS measure order and non-CMS alphabetical order', () => { + const libCMS2 = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/CMS2.cql')); + const libCMS22 = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/CMS22.cql')); + const libApple = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/Apple.cql')); + const provider = new CqlProjectTreeDataProvider( + fakeProject('P', [libCMS2, libCMS22, libApple]), + ); + + (provider as unknown as { sortDescending: boolean }).sortDescending = true; + (provider as unknown as { sortRootItemsInPlace: () => void }).sortRootItemsInPlace(); + + const order = provider.getRootItems().map(i => i.label as string); + expect(order).to.deep.equal(['CMS22', 'CMS2', 'Apple']); + }); + + test('descending sort with CMS and non-CMS preserves group ordering', () => { + const libCMS2 = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/CMS2.cql')); + const libCMS22 = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/CMS22.cql')); + const libBanana = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/Banana.cql')); + const libApple = new CqlLibrary(Uri.joinPath(wsRoot, 'input/cql/Apple.cql')); + const provider = new CqlProjectTreeDataProvider( + fakeProject('P', [libApple, libCMS22, libBanana, libCMS2]), + ); + + (provider as unknown as { sortDescending: boolean }).sortDescending = true; + (provider as unknown as { sortRootItemsInPlace: () => void }).sortRootItemsInPlace(); + + const order = provider.getRootItems().map(i => i.label as string); + expect(order).to.deep.equal(['CMS22', 'CMS2', 'Banana', 'Apple']); + }); }); suite('CqlProjectTreeDataProvider — LIBRARY_REMOVED bug fix (multi-project)', () => { diff --git a/src/__test__/suite/debug/astIndex.test.ts b/src/__test__/suite/debug/astIndex.test.ts new file mode 100644 index 0000000..cef1413 --- /dev/null +++ b/src/__test__/suite/debug/astIndex.test.ts @@ -0,0 +1,20 @@ +import { expect } from 'chai'; +import { hasFullCoordinates } from '../../../utils/astIndex'; + +suite('hasFullCoordinates', () => { + test('returns false when endLine === line && endColumn === column', () => { + expect(hasFullCoordinates({ line: 5, column: 3, endLine: 5, endColumn: 3 })).to.be.false; + }); + + test('returns true when endColumn > column (single-line span)', () => { + expect(hasFullCoordinates({ line: 5, column: 3, endLine: 5, endColumn: 10 })).to.be.true; + }); + + test('returns true when endLine > line (multi-line span)', () => { + expect(hasFullCoordinates({ line: 5, column: 1, endLine: 7, endColumn: 5 })).to.be.true; + }); + + test('returns true when both endLine > line and endColumn > column', () => { + expect(hasFullCoordinates({ line: 5, column: 3, endLine: 8, endColumn: 12 })).to.be.true; + }); +}); diff --git a/src/__test__/suite/debug/cqlAstDebugViewController.test.ts b/src/__test__/suite/debug/cqlAstDebugViewController.test.ts new file mode 100644 index 0000000..ecec328 --- /dev/null +++ b/src/__test__/suite/debug/cqlAstDebugViewController.test.ts @@ -0,0 +1,174 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { CqlAstDebugViewController, activateController } from '../../../debug/cqlAstDebugViewController'; +import * as fetchModule from '../../../debug/debugAstFetcher'; + +const SAMPLE_AST = `Library: One (version unspecified) [id=0] +├── translator: CQL-to-ELM ? +├── schema: urn:hl7-org:elm r1 +├── using: System (urn:hl7-org:elm-types:r1) +└── define: "One" returns System.Integer [id=208, loc=3:1-4:5] + └── Literal: 1 [id=209, loc=4:5]`; + +suite('CqlAstDebugViewController', () => { + let sandbox: sinon.SinonSandbox; + let context: vscode.ExtensionContext; + let mockTreeView: { reveal: sinon.SinonStub; onDidChangeVisibility: sinon.SinonStub; dispose: sinon.SinonSpy }; + let mockDecoration: { dispose: sinon.SinonSpy }; + + setup(() => { + sandbox = sinon.createSandbox(); + mockTreeView = { + reveal: sandbox.stub(), + onDidChangeVisibility: sandbox.stub().returns({ dispose: sandbox.spy() }), + dispose: sandbox.spy(), + }; + mockDecoration = { dispose: sandbox.spy() }; + + sandbox.stub(vscode.window, 'createTreeView').returns(mockTreeView as any); + sandbox.stub(vscode.window, 'createTextEditorDecorationType').returns(mockDecoration as any); + sandbox.stub(vscode.commands, 'registerCommand').returns({ dispose: sandbox.spy() }); + sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); + sandbox.stub(vscode.debug, 'onDidStartDebugSession').returns({ dispose: sandbox.spy() }); + sandbox.stub(vscode.debug, 'onDidTerminateDebugSession').returns({ dispose: sandbox.spy() }); + sandbox.stub(vscode.debug, 'activeDebugSession').get(() => undefined); + sandbox.stub(vscode.window, 'visibleTextEditors').get(() => []); + + context = { subscriptions: [] } as any; + }); + + teardown(() => { + sandbox.restore(); + }); + + test('activateController returns a controller instance', () => { + const controller = activateController(context); + expect(controller).to.be.instanceOf(CqlAstDebugViewController); + controller.dispose(); + }); + + test('onFrameStopped with same library reveals node and applies decoration', async () => { + const fetchStub = sandbox.stub(fetchModule, 'fetchAstViaDap').resolves(SAMPLE_AST); + const mockEditor = { + setDecorations: sandbox.stub(), + revealRange: sandbox.stub(), + selection: {}, + document: { uri: { fsPath: '/test/file.cql' } }, + }; + sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [mockEditor] as any); + sandbox.stub(vscode.workspace, 'openTextDocument').resolves(mockEditor.document as any); + sandbox.stub(vscode.window, 'showTextDocument').resolves(mockEditor as any); + + const controller = activateController(context); + + await controller.onFrameStopped({ + line: 3, + column: 1, + endLine: 4, + endColumn: 5, + source: { path: '/test/file.cql' }, + }, {} as any); + + // Should fetch AST once (first call) + expect(fetchStub.calledOnce).to.be.true; + + // Should reveal the matching node + expect(mockTreeView.reveal.calledOnce).to.be.true; + const revealedNode = mockTreeView.reveal.firstCall.args[0]; + expect(revealedNode.id).to.equal('lid:208'); + + // Should apply CQL decorations + expect(mockEditor.setDecorations.called).to.be.true; + + controller.dispose(); + }); + + test('onFrameStopped with different library swaps to new AST', async () => { + const fetchStub = sandbox.stub(fetchModule, 'fetchAstViaDap').resolves(SAMPLE_AST); + const mockEditor = { + setDecorations: sandbox.stub(), + revealRange: sandbox.stub(), + selection: {}, + document: { uri: { fsPath: '/test/file.cql' } }, + }; + sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [mockEditor] as any); + sandbox.stub(vscode.workspace, 'openTextDocument').resolves(mockEditor.document as any); + sandbox.stub(vscode.window, 'showTextDocument').resolves(mockEditor as any); + + const controller = activateController(context); + + // First frame on library A + await controller.onFrameStopped({ + line: 3, + column: 1, + endLine: 4, + endColumn: 5, + source: { path: '/test/file.cql' }, + }, {} as any); + + expect(fetchStub.calledOnce).to.be.true; + + // Second frame on library B — should fetch again + await controller.onFrameStopped({ + line: 3, + column: 1, + endLine: 4, + endColumn: 5, + source: { path: '/test/other.cql' }, + }, {} as any); + + expect(fetchStub.calledTwice).to.be.true; + + controller.dispose(); + }); + + test('session end clears state', async () => { + sandbox.stub(fetchModule, 'fetchAstViaDap').resolves(SAMPLE_AST); + const mockEditor = { + setDecorations: sandbox.stub(), + revealRange: sandbox.stub(), + selection: {}, + document: { uri: { fsPath: '/test/file.cql' } }, + }; + sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [mockEditor] as any); + + const controller = activateController(context); + + // Trigger the onDidTerminateDebugSession callback manually + const terminateCallbacks = (vscode.debug.onDidTerminateDebugSession as sinon.SinonStub).getCalls() + .map(c => c.args[0]); + for (const cb of terminateCallbacks) { + cb({ type: 'cql' } as any); + } + + // After session end, decorations should be cleared + expect(mockEditor.setDecorations.called).to.be.true; + + controller.dispose(); + }); + + test('non-CQL source path is ignored', async () => { + const fetchStub = sandbox.stub(fetchModule, 'fetchAstViaDap').resolves(SAMPLE_AST); + const controller = activateController(context); + + await controller.onFrameStopped({ + line: 3, + column: 1, + source: { path: '/test/file.java' }, + }, {} as any); + + expect(fetchStub.called).to.be.false; + controller.dispose(); + }); + + test('dispose cleans up all resources', () => { + const controller = activateController(context); + controller.dispose(); + + const treeCalls = (mockTreeView.dispose as sinon.SinonSpy).callCount; + const decorCalls = (mockDecoration.dispose as sinon.SinonSpy).callCount; + expect(treeCalls).to.equal(1); + expect(decorCalls).to.be.at.least(1); + }); +}); diff --git a/src/__test__/suite/debug/cqlAstTreeDataProvider.test.ts b/src/__test__/suite/debug/cqlAstTreeDataProvider.test.ts new file mode 100644 index 0000000..2a41443 --- /dev/null +++ b/src/__test__/suite/debug/cqlAstTreeDataProvider.test.ts @@ -0,0 +1,201 @@ +import { expect } from 'chai'; +import { CqlAstTreeDataProvider } from '../../../debug/cqlAstTreeDataProvider'; +import { parseAstToTree } from '../../../debug/cqlAstTreeNode'; + +suite('CqlAstTreeDataProvider', () => { + const SAMPLE_AST = `Library: One (version unspecified) [id=0] +├── translator: CQL-to-ELM ? +├── schema: urn:hl7-org:elm r1 +├── using: System (urn:hl7-org:elm-types:r1) +└── define: "One" returns System.Integer [id=208, loc=3:1-4:5] + └── Literal: 1 [id=209, loc=4:5]`; + + suite('getChildren', () => { + test('returns root nodes for a sample AST', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const children = provider.getChildren(); + expect(children.length).to.equal(1); + expect(children[0].label).to.equal('Library: One (version unspecified)'); + }); + + test('returns children of root node', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const rootChildren = provider.getChildren(roots[0]); + expect(rootChildren.length).to.equal(4); + expect(rootChildren[0].label).to.include('translator'); + expect(rootChildren[3].label).to.include('define: "One"'); + }); + + test('returns grandchild nodes', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const defineNode = provider.getChildren(roots[0])[3]; + const grandChildren = provider.getChildren(defineNode); + expect(grandChildren.length).to.equal(1); + expect(grandChildren[0].label).to.equal('Literal: 1'); + }); + + test('returns empty for leaf nodes', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const leafNode = provider.getChildren(roots[0])[0]; // translator has no children + const children = provider.getChildren(leafNode); + expect(children).to.deep.equal([]); + }); + + test('returns placeholder when in loading state', () => { + const provider = new CqlAstTreeDataProvider(); + provider.setLoading(); + + const children = provider.getChildren(); + expect(children.length).to.equal(1); + expect(children[0].id).to.equal('__loading__'); + expect(children[0].label).to.equal('Loading AST...'); + expect(provider.getChildren(children[0])).to.deep.equal([]); + }); + + test('returns empty-state placeholder when no data', () => { + const provider = new CqlAstTreeDataProvider(); + + const children = provider.getChildren(); + expect(children.length).to.equal(1); + expect(children[0].id).to.equal('__empty__'); + expect(children[0].label).to.equal('No active frame'); + }); + + test('returns custom empty-state message when set', () => { + const provider = new CqlAstTreeDataProvider(); + provider.setEmpty('Could not load AST'); + + const children = provider.getChildren(); + expect(children[0].label).to.equal('Could not load AST'); + }); + }); + + suite('getParent', () => { + test('returns undefined for root nodes', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + expect(provider.getParent(roots[0])).to.be.undefined; + }); + + test('returns parent for nested node', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const defineNode = provider.getChildren(roots[0])[3]; + const literalNode = defineNode.children[0]; + const parent = provider.getParent(literalNode); + expect(parent).to.equal(defineNode); + }); + }); + + suite('getTreeItem', () => { + test('returns active styling for active node', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const node = roots[0]; + provider.setActiveNodeId(node.id); + const item = provider.getTreeItem(node); + + expect(item.id).to.equal(node.id); + expect(item.label).to.equal(node.label); + expect(item.description).to.equal(node.description); + expect(item.iconPath).to.not.be.undefined; + }); + + test('returns default styling for inactive node', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const node = provider.getChildren(roots[0])[0]; // translator (no id → path:0/0) + const item = provider.getTreeItem(node); + + expect(item.id).to.equal(node.id); + expect(item.label).to.equal(node.label); + expect(item.iconPath).to.be.undefined; + }); + + test('sets collapsibleState based on children', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const leafItem = provider.getTreeItem(provider.getChildren(roots[0])[0]); // translator (no children) + const parentItem = provider.getTreeItem(roots[0]); // Library (has children) + + // TreeItemCollapsibleState: None = 0, Collapsed = 1 + expect(leafItem.collapsibleState).to.equal(0); // None + expect(parentItem.collapsibleState).to.equal(1); // Collapsed + }); + + test('includes inverse-navigation command', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + + const item = provider.getTreeItem(roots[0]); + expect(item.command).to.not.be.undefined; + expect(item.command!.command).to.equal('cql.debug.ast.reveal-cql'); + expect(item.command!.arguments![0]).to.equal(roots[0]); + }); + + test('placeholder items have no command or description', () => { + const provider = new CqlAstTreeDataProvider(); + provider.setLoading(); + + const children = provider.getChildren(); + const item = provider.getTreeItem(children[0]); + expect(item.command).to.be.undefined; + expect(item.description).to.equal(''); + }); + }); + + suite('stable ids', () => { + test('uses lid: prefix when localId is present', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + + const defineNode = providerGetChild(roots, 3); + expect(defineNode.id).to.equal('lid:208'); + expect(defineNode.children[0].id).to.equal('lid:209'); + }); + + test('uses path: prefix when no localId', () => { + const { roots } = parseAstToTree(SAMPLE_AST); + + const translatorNode = providerGetChild(roots, 0); + expect(translatorNode.id).to.match(/^path:/); + }); + + test('ids are stable across multiple calls', () => { + const { roots: r1 } = parseAstToTree(SAMPLE_AST); + const { roots: r2 } = parseAstToTree(SAMPLE_AST); + expect(r1.length).to.equal(r2.length); + for (let i = 0; i < r1.length; i++) { + expect(r1[i].id).to.equal(r2[i].id); + } + }); + }); +}); + +function providerGetChild(roots: any[], index: number) { + const provider = new CqlAstTreeDataProvider(); + provider.setData(roots); + return provider.getChildren(roots[0])[index]; +} diff --git a/src/__test__/suite/debug/cqlDebugAdapterDescriptorFactory.test.ts b/src/__test__/suite/debug/cqlDebugAdapterDescriptorFactory.test.ts new file mode 100644 index 0000000..d241dbd --- /dev/null +++ b/src/__test__/suite/debug/cqlDebugAdapterDescriptorFactory.test.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { LanguageClient, ExecuteCommandRequest } from 'vscode-languageclient/node'; +import { CqlDebugAdapterDescriptorFactory } from '../../../debug/cqlDebugAdapterDescriptorFactory'; + +suite('CqlDebugAdapterDescriptorFactory', () => { + let sandbox: sinon.SinonSandbox; + let clientMock: any; + let factory: CqlDebugAdapterDescriptorFactory; + let showErrorMessageStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + clientMock = { + sendRequest: sandbox.stub(), + } as any; + factory = new CqlDebugAdapterDescriptorFactory(clientMock as LanguageClient); + showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage'); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('createDebugAdapterDescriptor returns DebugAdapterServer on success', async () => { + clientMock.sendRequest.resolves(4711); + + const sessionMock = {} as vscode.DebugSession; + const result = await factory.createDebugAdapterDescriptor(sessionMock); + + expect(result).to.be.an.instanceOf(vscode.DebugAdapterServer); + expect((result as vscode.DebugAdapterServer).port).to.equal(4711); + expect(clientMock.sendRequest.calledOnce).to.be.true; + expect(clientMock.sendRequest.firstCall.args[0]).to.equal(ExecuteCommandRequest.type); + expect(clientMock.sendRequest.firstCall.args[1]).to.deep.equal({ + command: 'org.opencds.cqf.cql.debug.startDebugSession', + arguments: [], + }); + }); + + test('createDebugAdapterDescriptor shows error toast and throws on failure', async () => { + const error = new Error('Concurrent session active'); + clientMock.sendRequest.rejects(error); + + const sessionMock = {} as vscode.DebugSession; + let thrownError: any = null; + + try { + await factory.createDebugAdapterDescriptor(sessionMock); + } catch (e) { + thrownError = e; + } + + expect(thrownError).to.equal(error); + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.equal( + 'CQL debug session failed to start: Concurrent session active', + ); + }); +}); diff --git a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts new file mode 100644 index 0000000..91a21fe --- /dev/null +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -0,0 +1,179 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { CqlDebugAstTrackerFactory } from '../../../debug/cqlDebugAstTracker'; +import { normalizeSpan } from '../../../debug/types'; +import * as controllerModule from '../../../debug/cqlAstDebugViewController'; + +suite('CqlDebugAstTracker', () => { + let sandbox: sinon.SinonSandbox; + let factory: CqlDebugAstTrackerFactory; + let controllerStub: { onFrameStopped: sinon.SinonStub }; + let getControllerInstanceStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + factory = new CqlDebugAstTrackerFactory(); + controllerStub = { + onFrameStopped: sandbox.stub().resolves(undefined), + }; + getControllerInstanceStub = sandbox.stub(controllerModule, 'getControllerInstance').returns(controllerStub as any); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('stopped event triggers stackTrace and controller call with frame', async () => { + const sessionMock = { + customRequest: sandbox.stub().resolves({ stackFrames: [{ line: 7 }] }), + } as any; + + const tracker = factory.createDebugAdapterTracker(sessionMock); + + await tracker.onDidSendMessage!({ + type: 'event', + event: 'stopped', + body: { threadId: 1 }, + }); + + expect(sessionMock.customRequest.calledOnce).to.be.true; + expect(sessionMock.customRequest.firstCall.args[0]).to.equal('stackTrace'); + expect(sessionMock.customRequest.firstCall.args[1]).to.deep.equal({ + threadId: 1, + startFrame: 0, + levels: 1, + }); + expect(controllerStub.onFrameStopped.calledOnce).to.be.true; + const frameArg = controllerStub.onFrameStopped.firstCall.args[0]; + expect(frameArg.line).to.equal(7); + expect(frameArg.source).to.be.undefined; + }); + + test('passes full frame to controller (instructionPointerReference, source path)', async () => { + const sessionMock = { + customRequest: sandbox.stub().resolves({ + stackFrames: [{ + line: 5, column: 3, endLine: 8, endColumn: 10, + instructionPointerReference: '208', + source: { path: '/test/file.cql' }, + }], + }), + } as any; + + const tracker = factory.createDebugAdapterTracker(sessionMock); + + await tracker.onDidSendMessage!({ + type: 'event', + event: 'stopped', + body: { threadId: 1 }, + }); + + expect(controllerStub.onFrameStopped.calledOnce).to.be.true; + const frameArg = controllerStub.onFrameStopped.firstCall.args[0]; + expect(frameArg.line).to.equal(5); + expect(frameArg.column).to.equal(3); + expect(frameArg.endLine).to.equal(8); + expect(frameArg.endColumn).to.equal(10); + expect(frameArg.instructionPointerReference).to.equal('208'); + expect(frameArg.source.path).to.equal('/test/file.cql'); + }); + + test('piggy-backs on stackTrace response from UI-issued request', () => { + const sessionMock = {} as any; + const tracker = factory.createDebugAdapterTracker(sessionMock); + + tracker.onDidSendMessage!({ + type: 'response', + command: 'stackTrace', + success: true, + body: { stackFrames: [{ line: 4 }] }, + }); + + expect(controllerStub.onFrameStopped.calledOnce).to.be.true; + expect(controllerStub.onFrameStopped.firstCall.args[0].line).to.equal(4); + }); + + test('no controller: tracker swallows without throwing', async () => { + // Re-stub from setup to return undefined instead of the controllerStub + getControllerInstanceStub.returns(undefined); + + const sessionMock = { + customRequest: sandbox.stub().resolves({ stackFrames: [{ line: 7 }] }), + } as any; + + const tracker = factory.createDebugAdapterTracker(sessionMock); + + await tracker.onDidSendMessage!({ + type: 'event', + event: 'stopped', + body: { threadId: 1 }, + }); + + expect(sessionMock.customRequest.calledOnce).to.be.true; + }); + + test('stopped event without threadId: does not call customRequest', async () => { + const sessionMock = { + customRequest: sandbox.stub(), + } as any; + + const tracker = factory.createDebugAdapterTracker(sessionMock); + + await tracker.onDidSendMessage!({ + type: 'event', + event: 'stopped', + body: {}, + }); + + expect(sessionMock.customRequest.called).to.be.false; + expect(controllerStub.onFrameStopped.called).to.be.false; + }); + + test('customRequest rejection is caught silently', async () => { + const sessionMock = { + customRequest: sandbox.stub().rejects(new Error('session ended')), + } as any; + + const tracker = factory.createDebugAdapterTracker(sessionMock); + + await tracker.onDidSendMessage!({ + type: 'event', + event: 'stopped', + body: { threadId: 1 }, + }); + + expect(sessionMock.customRequest.calledOnce).to.be.true; + }); +}); + +suite('normalizeSpan', () => { + test('defaults endLine to line when endLine omitted', () => { + const result = normalizeSpan({ line: 7, column: 3 }); + expect(result).to.deep.equal({ line: 7, column: 3, endLine: 7, endColumn: 3 }); + }); + + test('defaults endColumn to column when endColumn omitted', () => { + const result = normalizeSpan({ line: 7, column: 3, endLine: 7 }); + expect(result).to.deep.equal({ line: 7, column: 3, endLine: 7, endColumn: 3 }); + }); + + test('defaults column to 1 when omitted', () => { + const result = normalizeSpan({ line: 7 }); + expect(result).to.deep.equal({ line: 7, column: 1, endLine: 7, endColumn: 1 }); + }); + + test('preserves explicit end coordinates', () => { + const result = normalizeSpan({ line: 5, column: 3, endLine: 8, endColumn: 12 }); + expect(result).to.deep.equal({ line: 5, column: 3, endLine: 8, endColumn: 12 }); + }); + + test('preserves localId when instructionPointerReference is present', () => { + const result = normalizeSpan({ line: 5, column: 3, endLine: 8, endColumn: 12, instructionPointerReference: '42' }); + expect(result).to.deep.equal({ line: 5, column: 3, endLine: 8, endColumn: 12, localId: '42' }); + }); + + test('omits localId when instructionPointerReference is absent', () => { + const result = normalizeSpan({ line: 7, column: 5, endLine: 7, endColumn: 10 }); + expect(result).to.not.have.property('localId'); + }); +}); diff --git a/src/__test__/suite/debug/cqlEvaluatableExpressionProvider.test.ts b/src/__test__/suite/debug/cqlEvaluatableExpressionProvider.test.ts new file mode 100644 index 0000000..5ae6f25 --- /dev/null +++ b/src/__test__/suite/debug/cqlEvaluatableExpressionProvider.test.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import { Position, Range } from 'vscode'; +import { findQuotedIdentifierRange } from '../../../debug/cqlEvaluatableExpressionProvider'; + +suite('findQuotedIdentifierRange', () => { + const line = 5; + const lineText = ' "Denominator Exceptions" '; + + test('returns null when cursor is before the opening quote', () => { + expect(findQuotedIdentifierRange(lineText, line, 0)).to.be.null; + expect(findQuotedIdentifierRange(lineText, line, 1)).to.be.null; + }); + + test('returns full quoted range when cursor is on the opening quote', () => { + const result = findQuotedIdentifierRange(lineText, line, 2); + expect(result).to.deep.equal( + new Range(new Position(line, 2), new Position(line, 26)), + ); + }); + + test('returns full quoted range when cursor is on a letter inside the string', () => { + const result = findQuotedIdentifierRange(lineText, line, 5); + expect(result).to.deep.equal( + new Range(new Position(line, 2), new Position(line, 26)), + ); + }); + + test('returns full quoted range when cursor is on the closing quote', () => { + const result = findQuotedIdentifierRange(lineText, line, 25); + expect(result).to.deep.equal( + new Range(new Position(line, 2), new Position(line, 26)), + ); + }); + + test('returns null when cursor is after the closing quote', () => { + expect(findQuotedIdentifierRange(lineText, line, 26)).to.be.null; + expect(findQuotedIdentifierRange(lineText, line, 27)).to.be.null; + }); + + test('handles first word of multi-word identifier', () => { + const result = findQuotedIdentifierRange(lineText, line, 3); + expect(result).to.deep.equal( + new Range(new Position(line, 2), new Position(line, 26)), + ); + }); + + test('handles last word of multi-word identifier', () => { + const result = findQuotedIdentifierRange(lineText, line, 24); + expect(result).to.deep.equal( + new Range(new Position(line, 2), new Position(line, 26)), + ); + }); + + test('handles single-word quoted identifier', () => { + const text = ' "InitialPopulation" '; + const result = findQuotedIdentifierRange(text, line, 5); + expect(result).to.deep.equal( + new Range(new Position(line, 2), new Position(line, 21)), + ); + }); + + test('handles cursor on the opening quote of single-word identifier', () => { + const text = '"InitialPopulation"'; + const result = findQuotedIdentifierRange(text, line, 0); + expect(result).to.deep.equal( + new Range(new Position(line, 0), new Position(line, 19)), + ); + }); + + test('handles cursor on the closing quote of single-word identifier', () => { + const text = '"InitialPopulation"'; + const result = findQuotedIdentifierRange(text, line, 18); + expect(result).to.deep.equal( + new Range(new Position(line, 0), new Position(line, 19)), + ); + }); + + test('returns null when there are no quotes on line', () => { + expect(findQuotedIdentifierRange('plain text', line, 2)).to.be.null; + }); + + test('handles multiple quoted strings (cursor in first)', () => { + const text = '"First" and "Second"'; + const result = findQuotedIdentifierRange(text, line, 3); + expect(result).to.deep.equal( + new Range(new Position(line, 0), new Position(line, 7)), + ); + }); + + test('handles multiple quoted strings (cursor in second)', () => { + const text = '"First" and "Second"'; + const result = findQuotedIdentifierRange(text, line, 18); + expect(result).to.deep.equal( + new Range(new Position(line, 12), new Position(line, 20)), + ); + }); + + test('handles unclosed quote at end of line', () => { + const text = '"Unclosed'; + const result = findQuotedIdentifierRange(text, line, 5); + expect(result).to.deep.equal( + new Range(new Position(line, 0), new Position(line, 9)), + ); + }); +}); diff --git a/src/__test__/suite/debug/stepGranularityToggle.test.ts b/src/__test__/suite/debug/stepGranularityToggle.test.ts new file mode 100644 index 0000000..39f32ea --- /dev/null +++ b/src/__test__/suite/debug/stepGranularityToggle.test.ts @@ -0,0 +1,121 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { activateStepGranularityToggle } from '../../../debug/stepGranularityToggle'; + +suite('stepGranularityToggle', () => { + let sandbox: sinon.SinonSandbox; + let context: vscode.ExtensionContext; + let statusBarItemStub: { show: sinon.SinonSpy; hide: sinon.SinonSpy; command: string | undefined; text: string; tooltip: string | undefined }; + + setup(() => { + sandbox = sinon.createSandbox(); + statusBarItemStub = { + show: sandbox.spy(), + hide: sandbox.spy(), + command: undefined, + text: '', + tooltip: undefined, + }; + context = { + subscriptions: [], + } as any; + sandbox.stub(vscode.window, 'createStatusBarItem').returns(statusBarItemStub as any); + sandbox.stub(vscode.commands, 'registerCommand').callsFake((command, handler) => { + context.subscriptions.push({ command, execute: handler } as any); + return { dispose: sandbox.spy() }; + }); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('toggle alternates cql and ast and emits customRequest', async () => { + const sessionMock = { + type: 'cql', + configuration: { stepGranularity: 'cql' }, + customRequest: sandbox.stub().resolves(undefined), + } as any; + + sandbox.stub(vscode.debug, 'onDidStartDebugSession').callsFake((callback: (e: vscode.DebugSession) => void) => { + callback(sessionMock); + return { dispose: sandbox.spy() }; + }); + sandbox.stub(vscode.debug, 'activeDebugSession').get(() => sessionMock); + + activateStepGranularityToggle(context); + + const toggleCommand = context.subscriptions.find( + (sub: any) => sub.command === 'cql.debug.toggle-step-granularity' && typeof sub.execute === 'function', + ); + expect(toggleCommand).to.exist; + + await (toggleCommand as any).execute(); + + expect(sessionMock.customRequest.calledOnce).to.be.true; + expect(sessionMock.customRequest.firstCall.args[0]).to.equal('setStepGranularity'); + expect(sessionMock.customRequest.firstCall.args[1]).to.deep.equal({ granularity: 'ast' }); + }); + + test('status bar hidden when no cql session is active', () => { + sandbox.stub(vscode.debug, 'onDidChangeActiveDebugSession').callsFake((callback: (e: vscode.DebugSession | undefined) => void) => { + callback(undefined); + return { dispose: sandbox.spy() }; + }); + sandbox.stub(vscode.debug, 'onDidTerminateDebugSession').callsFake((callback: (e: vscode.DebugSession) => void) => { + return { dispose: sandbox.spy() }; + }); + + activateStepGranularityToggle(context); + + expect(statusBarItemStub.hide.called).to.be.true; + }); + + test('focuses AST tree view when toggling to ast', async () => { + const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); + + const sessionMock = { + type: 'cql', + configuration: { stepGranularity: 'cql' }, + customRequest: sandbox.stub().resolves(undefined), + } as any; + + sandbox.stub(vscode.debug, 'onDidStartDebugSession').callsFake((callback: (e: vscode.DebugSession) => void) => { + callback(sessionMock); + return { dispose: sandbox.spy() }; + }); + sandbox.stub(vscode.debug, 'activeDebugSession').get(() => sessionMock); + + activateStepGranularityToggle(context); + + const toggleCommand = context.subscriptions.find( + (sub: any) => sub.command === 'cql.debug.toggle-step-granularity' && typeof sub.execute === 'function', + ); + expect(toggleCommand).to.exist; + + await (toggleCommand as any).execute(); + + expect(executeCommandStub.calledOnce).to.be.true; + expect(executeCommandStub.firstCall.args[0]).to.equal('cql.debug.ast.focus'); + }); + + test('non-CQL session does not trigger focus', async () => { + const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); + + const sessionMock = { + type: 'not-cql', + configuration: {}, + customRequest: sandbox.stub().resolves(undefined), + } as any; + + sandbox.stub(vscode.debug, 'onDidStartDebugSession').callsFake((callback: (e: vscode.DebugSession) => void) => { + return { dispose: sandbox.spy() }; + }); + sandbox.stub(vscode.debug, 'activeDebugSession').get(() => undefined); + + activateStepGranularityToggle(context); + + expect(executeCommandStub.called).to.be.false; + }); +}); diff --git a/src/__test__/suite/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index 36a4758..fa8b01a 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -5,8 +5,14 @@ const EXPECTED_COMMANDS = [ // Active editor commands 'cql.editor.execute', 'cql.editor.execute.select-test-cases', + 'cql.editor.debug-test-case', 'cql.editor.view-elm.xml', 'cql.editor.view-elm.json', + 'cql.editor.view-elm.ast', + 'cql.editor.view-elm.ast.split', + // Debug commands + 'cql.debug.toggle-step-granularity', + 'cql.debug.ast.reveal-cql', // Global commands 'cql.execute.select-libraries', 'cql.open.server-log', @@ -26,11 +32,13 @@ const EXPECTED_COMMANDS = [ // Explorer project node commands 'cql.explorer.project.execute-all', 'cql.explorer.project.execute-select', + 'cql.explorer.edit-config', // Explorer library node commands 'cql.explorer.library.execute-all', 'cql.explorer.library.execute', 'cql.explorer.library.elm.json', 'cql.explorer.library.elm.xml', + 'cql.explorer.library.ast', // Explorer test case node commands 'cql.explorer.test-case.execute', 'cql.explorer.test-case.open-resources', @@ -42,12 +50,17 @@ const EXPECTED_COMMANDS = [ 'cql.explorer.test-case.execute-all', 'cql.explorer.test-case.filter', 'cql.explorer.test-case.clear-filter', + 'cql.explorer.test-case.debug', // Explorer resource node commands 'cql.explorer.resource.fix-references', 'cql.explorer.resource.rename', 'cql.explorer.resource.copy', 'cql.explorer.resource.cut', 'cql.explorer.resource.delete', + // Auto-generated view commands (from contributes.views) + 'cql.debug.ast.open', + 'cql.debug.ast.focus', + 'cql.debug.ast.resetViewLocation', ]; suite('CQL command registration', () => { diff --git a/src/__test__/suite/extension/packageJson.test.ts b/src/__test__/suite/extension/packageJson.test.ts index e358710..6ec7f46 100644 --- a/src/__test__/suite/extension/packageJson.test.ts +++ b/src/__test__/suite/extension/packageJson.test.ts @@ -16,6 +16,7 @@ const pkg: PackageJson = JSON.parse( const CONTEXT_MENU_COMMANDS = [ 'cql.editor.execute', 'cql.editor.execute.select-test-cases', + 'cql.editor.debug-test-case', 'cql.editor.view-elm.xml', 'cql.editor.view-elm.json', ]; @@ -34,6 +35,7 @@ suite('package.json contributions', () => { 'cql.editor.execute', 'cql.execute.select-libraries', 'cql.editor.execute.select-test-cases', + 'cql.editor.debug-test-case', ]) { expect(ids, `"${cmd}" should be declared in contributes.commands`).to.include(cmd); } diff --git a/src/__test__/suite/commands/execute-cql.test.ts b/src/__test__/suite/helpers/cqlHelpers.test.ts similarity index 51% rename from src/__test__/suite/commands/execute-cql.test.ts rename to src/__test__/suite/helpers/cqlHelpers.test.ts index b7a951f..efba0aa 100644 --- a/src/__test__/suite/commands/execute-cql.test.ts +++ b/src/__test__/suite/helpers/cqlHelpers.test.ts @@ -4,17 +4,12 @@ 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'; +} from '../../../helpers/cqlHelpers'; import { TestCaseExclusion } from '../../../model/testCase'; suite('getFhirVersion()', () => { @@ -197,6 +192,22 @@ suite('loadTestConfig()', () => { expect(result.resultFormat).to.equal('flat'); }); + test('flatResultsInSubfolder defaults to false when not specified', () => { + 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.flatResultsInSubfolder).to.equal(false); + }); + + test('flatResultsInSubfolder is read back when set to true', () => { + const config = { testCasesToExclude: [], resultFormat: 'flat', flatResultsInSubfolder: true }; + const configPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config)); + const result = loadTestConfig(Uri.file(configPath)); + expect(result.flatResultsInSubfolder).to.equal(true); + }); + test('strips JSONC comments when parsing', () => { const jsonc = `{ // this is a comment @@ -249,77 +260,6 @@ suite('resolveTestConfigPath()', () => { }); }); -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()', () => { let tmpDir: string; @@ -362,174 +302,3 @@ 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/statusBar.test.ts b/src/__test__/suite/statusBar.test.ts index 271a67e..16c2793 100644 --- a/src/__test__/suite/statusBar.test.ts +++ b/src/__test__/suite/statusBar.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import * as vscode from 'vscode'; import { statusBar } from '../../statusBar'; +import { VersionInfo } from '../../protocol'; suite('statusBar', () => { teardown(() => { @@ -37,6 +38,32 @@ suite('statusBar', () => { expect(tooltip.value).to.include('4.2.0'); }); + test('setReady with VersionInfo shows all component versions', () => { + const vi: VersionInfo = { + translator: '4.9.0', + engine: '4.9.0', + clinicalReasoning: '4.7.0', + languageServer: '4.8.0', + }; + statusBar.setReady(undefined, vi); + expect(statusBar.text).to.equal('$(check) CQL'); + const tooltip = statusBar.tooltip as vscode.MarkdownString; + expect(tooltip.value).to.include('Translator: 4.9.0'); + expect(tooltip.value).to.include('Engine: 4.9.0'); + expect(tooltip.value).to.include('Clinical Reasoning: 4.7.0'); + expect(tooltip.value).to.include('Language Server: 4.8.0'); + }); + + test('setReady with partial VersionInfo omits undefined lines', () => { + const vi: VersionInfo = { translator: '4.9.0', engine: undefined, clinicalReasoning: undefined, languageServer: undefined }; + statusBar.setReady(undefined, vi); + const tooltip = statusBar.tooltip as vscode.MarkdownString; + expect(tooltip.value).to.include('Translator: 4.9.0'); + expect(tooltip.value).not.to.include('Engine'); + expect(tooltip.value).not.to.include('Clinical Reasoning'); + expect(tooltip.value).not.to.include('Language Server'); + }); + test('setError() sets error icon', () => { statusBar.setError(); expect(statusBar.text).to.equal('$(error) CQL'); diff --git a/src/__test__/suite/utils/astIndex.test.ts b/src/__test__/suite/utils/astIndex.test.ts new file mode 100644 index 0000000..e62fdfa --- /dev/null +++ b/src/__test__/suite/utils/astIndex.test.ts @@ -0,0 +1,338 @@ +import { expect } from 'chai'; +import { buildAstLineIndex, buildLocatorKey, sortAstBySourceOrder } from '../../../utils/astIndex'; + +suite('buildAstLineIndex()', () => { + test('parses a single node with loc metadata', () => { + const ast = `Library: Test (version 1.0.0) [id=0] +└── define: "Foo" [id=1, loc=3:1-5:10]`; + + const index = buildAstLineIndex(ast); + + expect(index.astToCqlLoc.size).to.equal(1); + expect(index.astToCqlLoc.get(1)).to.deep.equal({ + startLine: 3, + startCol: 1, + endLine: 5, + endCol: 10, + }); + + expect(index.cqlToAstLines.get(2)).to.deep.equal([1]); // CQL line 3 → 0-indexed 2 + expect(index.cqlToAstLines.get(3)).to.deep.equal([1]); // CQL line 4 → 0-indexed 3 + expect(index.cqlToAstLines.get(4)).to.deep.equal([1]); // CQL line 5 → 0-indexed 4 + }); + + test('skips lines without loc metadata', () => { + const ast = `Library: Test (version 1.0.0) +├── translator: CQL-to-ELM ? +├── schema: urn:hl7-org:elm r1 +└── define: "Foo" [id=1, loc=3:1-5:10]`; + + const index = buildAstLineIndex(ast); + + expect(index.astToCqlLoc.size).to.equal(1); + expect(index.astToCqlLoc.has(0)).to.be.false; + expect(index.astToCqlLoc.has(1)).to.be.false; + expect(index.astToCqlLoc.has(2)).to.be.false; + expect(index.astToCqlLoc.has(3)).to.be.true; + }); + + test('handles multiple nodes with overlapping CQL ranges', () => { + const ast = `Library: Test (version 1.0.0) +└── define: "Foo" [id=1, loc=3:1-5:10] + └── Query [id=2, loc=4:1-5:10]`; + + const index = buildAstLineIndex(ast); + + expect(index.astToCqlLoc.size).to.equal(2); + expect(index.astToCqlLoc.get(1)).to.deep.equal({ + startLine: 3, startCol: 1, endLine: 5, endCol: 10, + }); + expect(index.astToCqlLoc.get(2)).to.deep.equal({ + startLine: 4, startCol: 1, endLine: 5, endCol: 10, + }); + + // CQL line 3 (0-indexed 2) → only AST line 1 + expect(index.cqlToAstLines.get(2)).to.deep.equal([1]); + // CQL line 4 (0-indexed 3) → both AST lines 1 and 2 + expect(index.cqlToAstLines.get(3)).to.deep.equal([1, 2]); + // CQL line 5 (0-indexed 4) → both AST lines 1 and 2 + expect(index.cqlToAstLines.get(4)).to.deep.equal([1, 2]); + }); + + test('returns empty index for content without loc metadata', () => { + const ast = `Library: Test (version 1.0.0) +├── translator: CQL-to-ELM ?`; + + const index = buildAstLineIndex(ast); + + expect(index.astToCqlLoc.size).to.equal(0); + expect(index.cqlToAstLines.size).to.equal(0); + }); + + test('returns empty index for empty content', () => { + const index = buildAstLineIndex(''); + + expect(index.astToCqlLoc.size).to.equal(0); + expect(index.cqlToAstLines.size).to.equal(0); + }); + + test('parses single-line range (loc with same start and end line)', () => { + const ast = `└── Literal: 1 [id=1, loc=4:5-4:5]`; + + const index = buildAstLineIndex(ast); + expect(index.astToCqlLoc.get(0)).to.deep.equal({ + startLine: 4, startCol: 5, endLine: 4, endCol: 5, + }); + expect(index.cqlToAstLines.get(3)).to.deep.equal([0]); // CQL line 4 → 0-indexed 3 + }); + + test('parses single-position loc (no range)', () => { + const ast = `└── Literal: 1 [id=1, loc=4:5]`; + + const index = buildAstLineIndex(ast); + expect(index.astToCqlLoc.get(0)).to.deep.equal({ + startLine: 4, startCol: 5, endLine: 4, endCol: 5, + }); + expect(index.cqlToAstLines.get(3)).to.deep.equal([0]); + }); + + test('handles loc without id prefix', () => { + const ast = `└── define: "Foo" [loc=3:1-5:10]`; + + const index = buildAstLineIndex(ast); + expect(index.astToCqlLoc.get(0)).to.deep.equal({ + startLine: 3, startCol: 1, endLine: 5, endCol: 10, + }); + }); + + test('returns no entry for unmapped CQL lines (blank / header lines)', () => { + const ast = `Library: Test (version 1.0.0) +└── define: "Foo" [id=1, loc=3:1-5:10]`; + + const index = buildAstLineIndex(ast); + // CQL line 1 (0-indexed 0 → library header) should have no mapping + expect(index.cqlToAstLines.get(0)).to.be.undefined; + // CQL line 2 (0-indexed 1) should have no mapping + expect(index.cqlToAstLines.get(1)).to.be.undefined; + // CQL line 3 (0-indexed 2) should have the mapping + expect(index.cqlToAstLines.get(2)).to.deep.equal([1]); + }); + + test('parses real-world format from One.ast test fixture', () => { + const ast = `Library: One (version unspecified) [id=0] +├── translator: CQL-to-ELM ? +├── schema: urn:hl7-org:elm r1 +├── using: System (urn:hl7-org:elm-types:r1) +└── define: "One" returns System.Integer [id=208, loc=3:1-4:5] + └── Literal: 1 [id=209, loc=4:5]`; + + const index = buildAstLineIndex(ast); + + expect(index.astToCqlLoc.size).to.equal(2); + expect(index.astToCqlLoc.get(4)).to.deep.equal({ + startLine: 3, startCol: 1, endLine: 4, endCol: 5, + }); + expect(index.astToCqlLoc.get(5)).to.deep.equal({ + startLine: 4, startCol: 5, endLine: 4, endCol: 5, + }); + + // CQL line 3 (0-indexed 2) → AST line 4 only + expect(index.cqlToAstLines.get(2)).to.deep.equal([4]); + // CQL line 4 (0-indexed 3) → both AST lines 4 and 5 + expect(index.cqlToAstLines.get(3)).to.deep.equal([4, 5]); + }); + + suite('locatorToAstLines', () => { + test('populates locatorToAstLines for a normal range annotation', () => { + const ast = `└── define: "Foo" [id=1, loc=4:1-4:20]`; + const index = buildAstLineIndex(ast); + const key = buildLocatorKey(4, 1, 4, 20); + expect(index.locatorToAstLines.get(key)).to.deep.equal([0]); + }); + + test('maps multiple AST lines to the same key when their locators match', () => { + const ast = `└── define: "Foo" [id=1, loc=4:1-4:20] + └── Query [id=2, loc=4:1-4:20]`; + const index = buildAstLineIndex(ast); + const key = buildLocatorKey(4, 1, 4, 20); + expect(index.locatorToAstLines.get(key)).to.deep.equal([0, 1]); + }); + + test('handles a single-position annotation (loc=4:5) using startLine:startCol twice', () => { + const ast = `└── Literal: 1 [id=1, loc=4:5]`; + const index = buildAstLineIndex(ast); + const key = buildLocatorKey(4, 5, 4, 5); + expect(index.locatorToAstLines.get(key)).to.deep.equal([0]); + }); + + test('cqlToAstLines still populated correctly (regression guard)', () => { + const ast = `└── define: "Foo" [id=1, loc=4:1-4:20] + └── Query [id=2, loc=4:1-4:20]`; + const index = buildAstLineIndex(ast); + expect(index.cqlToAstLines.get(3)).to.deep.equal([0, 1]); + }); + }); + + suite('localIdToAstLines', () => { + test('populates localIdToAstLines for a node with id and loc', () => { + const ast = `└── define: "One" [id=208, loc=3:1-4:5]`; + const index = buildAstLineIndex(ast); + expect(index.localIdToAstLines.get('208')).to.deep.equal([0]); + }); + + test('maps each AST line to its unique localId', () => { + const ast = `Library: One (version unspecified) [id=0] +└── define: "One" returns System.Integer [id=208, loc=3:1-4:5] + └── Literal: 1 [id=209, loc=4:5]`; + const index = buildAstLineIndex(ast); + expect(index.localIdToAstLines.get('0')).to.deep.equal([0]); + expect(index.localIdToAstLines.get('208')).to.deep.equal([1]); + expect(index.localIdToAstLines.get('209')).to.deep.equal([2]); + }); + + test('locatorToAstLines still populated correctly when localId is present (regression guard)', () => { + const ast = `└── define: "One" [id=208, loc=3:1-4:5]`; + const index = buildAstLineIndex(ast); + expect(index.locatorToAstLines.size).to.equal(1); + }); + }); +}); + +suite('sortAstBySourceOrder()', () => { + test('sorts defines by CQL start line, keeps non-defines in place', () => { + const ast = `Library: Test (version 1.0.0) +├── translator: CQL-to-ELM ? +├── schema: urn:hl7-org:elm r1 +├── context: Patient +├── define: "Denominator" [id=409, loc=40:1-41:22] +├── define: "Initial Population" [id=292, loc=36:1-38:67] +└── define: "Patient" [id=287, loc=34:1-34:15]`; + + const sorted = sortAstBySourceOrder(ast); + const lines = sorted.split('\n'); + + expect(lines[0]).to.equal('Library: Test (version 1.0.0)'); + // Non-defines stay in original order + expect(lines[1]).to.equal('├── translator: CQL-to-ELM ?'); + expect(lines[2]).to.equal('├── schema: urn:hl7-org:elm r1'); + expect(lines[3]).to.equal('├── context: Patient'); + // Defines sorted by CQL line: 34, 36, 40 + expect(lines[4]).to.include('define: "Patient"'); + expect(lines[4]).to.include('loc=34:'); + expect(lines[5]).to.include('define: "Initial Population"'); + expect(lines[5]).to.include('loc=36:'); + expect(lines[6]).to.include('define: "Denominator"'); + expect(lines[6]).to.include('loc=40:'); + // Last item uses └── + expect(lines[6]).to.match(/^└── define/); + }); + + test('preserves child nodes of each define when reordering', () => { + const ast = `Library: Test (version 1.0.0) +├── context: Patient +├── define: "B" [id=2, loc=10:1-10:5] +│ └── Literal: 2 [id=3, loc=10:5] +└── define: "A" [id=1, loc=5:1-5:5] + └── Literal: 1 [id=4, loc=5:5]`; + + const sorted = sortAstBySourceOrder(ast); + const lines = sorted.split('\n'); + + // A (loc=5) comes before B (loc=10) + const aIdx = lines.findIndex(l => l.includes('define: "A"')); + const bIdx = lines.findIndex(l => l.includes('define: "B"')); + expect(aIdx).to.be.lessThan(bIdx); + + // Child nodes travel with their parent define + expect(lines[aIdx + 1]).to.include('Literal: 1'); + expect(lines[bIdx + 1]).to.include('Literal: 2'); + }); + + test('handles single define (no reordering needed)', () => { + const ast = `Library: Test (version 1.0.0) +└── define: "Only" [id=1, loc=3:1-3:5]`; + + expect(sortAstBySourceOrder(ast)).to.equal(ast); + }); + + test('handles content without defines', () => { + const ast = `Library: Test (version 1.0.0) +├── translator: CQL-to-ELM ? +└── schema: urn:hl7-org:elm r1`; + + expect(sortAstBySourceOrder(ast)).to.equal(ast); + }); + + test('fixes └── connector on last sorted define', () => { + const ast = `Library: Test (version 1.0.0) +├── define: "B" [id=2, loc=10:1-10:5] +│ └── Literal: 2 [id=3, loc=10:5] +├── define: "C" [id=3, loc=15:1-15:5] +└── define: "A" [id=1, loc=5:1-5:5]`; + + const sorted = sortAstBySourceOrder(ast); + const lines = sorted.split('\n'); + + // Last define (highest loc) gets └── + expect(lines[lines.length - 1]).to.match(/^└── define/); + expect(lines[lines.length - 1]).to.include('"C"'); + // Others get ├── (find by content since child lines shift absolute indices) + const aIdx = lines.findIndex(l => l.includes('define: "A"')); + const bIdx = lines.findIndex(l => l.includes('define: "B"')); + expect(lines[aIdx]).to.match(/^├── define/); + expect(lines[aIdx]).to.include('"A"'); + expect(lines[bIdx]).to.match(/^├── define/); + expect(lines[bIdx]).to.include('"B"'); + }); + + test('sorts define function segments by CQL line alongside define segments', () => { + const ast = `Library: Test (version 1.0.0) +├── define: "B" [id=1, loc=10:1-10:5] +├── define function: "Foo" [id=2, loc=5:1-5:5] +└── define: "A" [id=3, loc=15:1-15:5]`; + + const sorted = sortAstBySourceOrder(ast); + const lines = sorted.split('\n'); + + const fooIdx = lines.findIndex(l => l.includes('define function: "Foo"')); + const bIdx = lines.findIndex(l => l.includes('define: "B"')); + const aIdx = lines.findIndex(l => l.includes('define: "A"')); + expect(fooIdx).to.be.lessThan(bIdx); + expect(bIdx).to.be.lessThan(aIdx); + // Last item (highest loc) gets └── + expect(lines[lines.length - 1]).to.match(/^└── define/); + expect(lines[lines.length - 1]).to.include('"A"'); + }); + + test('sorts fluent function segments by source line', () => { + const ast = `Library: Test (version 1.0.0) +├── define: "B" [id=1, loc=10:1-10:5] +├── define fluent function: "Bar" [id=2, loc=3:1-3:5] +└── define: "A" [id=3, loc=15:1-15:5]`; + + const sorted = sortAstBySourceOrder(ast); + const lines = sorted.split('\n'); + + const barIdx = lines.findIndex(l => l.includes('define fluent function: "Bar"')); + const bIdx = lines.findIndex(l => l.includes('define: "B"')); + expect(barIdx).to.be.lessThan(bIdx); + }); + + test('fixes └── connector after sort when last segment is a function def', () => { + const ast = `Library: Test (version 1.0.0) +├── define: "A" [id=1, loc=5:1-5:5] +└── define function: "Foo" [id=2, loc=10:1-10:5]`; + + const sorted = sortAstBySourceOrder(ast); + const lines = sorted.split('\n'); + + const lastLine = lines[lines.length - 1]; + expect(lastLine).to.match(/^└── define function/); + expect(lastLine).to.include('"Foo"'); + }); + + test('returns input unchanged for empty or single-line content', () => { + expect(sortAstBySourceOrder('')).to.equal(''); + expect(sortAstBySourceOrder('Library: Test (version 1.0.0)')).to.equal('Library: Test (version 1.0.0)'); + }); +}); diff --git a/src/__test__/suite/views/configEditorWebview.test.ts b/src/__test__/suite/views/configEditorWebview.test.ts new file mode 100644 index 0000000..6eef9c0 --- /dev/null +++ b/src/__test__/suite/views/configEditorWebview.test.ts @@ -0,0 +1,218 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Uri } from 'vscode'; +import { loadTestConfig } from '../../../helpers/cqlHelpers'; + +suite('ConfigEditorWebview — save round-trip', () => { + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-cql-test-')); + }); + + teardown(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('writes and reads back TestConfig correctly', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { + testCasesToExclude: [ + { library: 'CMS122', testCase: '1234-abcd', reason: 'Flaky' }, + { library: 'CMS130', testCase: '5678-efgh', reason: 'WIP' }, + ], + resultFormat: 'individual' as const, + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.testCasesToExclude).to.have.length(2); + expect(result.testCasesToExclude[0].library).to.equal('CMS122'); + expect(result.testCasesToExclude[0].testCase).to.equal('1234-abcd'); + expect(result.testCasesToExclude[0].reason).to.equal('Flaky'); + expect(result.testCasesToExclude[1].library).to.equal('CMS130'); + expect(result.testCasesToExclude[1].testCase).to.equal('5678-efgh'); + expect(result.testCasesToExclude[1].reason).to.equal('WIP'); + expect(result.resultFormat).to.equal('individual'); + }); + + test('writes flat result format and reads it back', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { + testCasesToExclude: [], + resultFormat: 'flat', + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.testCasesToExclude).to.deep.equal([]); + expect(result.resultFormat).to.equal('flat'); + }); + + test('writes to .jsonc path and reads back (comments stripped on write)', () => { + const configPath = path.join(tmpDir, 'config.jsonc'); + const config = { + testCasesToExclude: [ + { library: 'CMS130', testCase: 'aaaa-bbbb', reason: 'Test' }, + ], + resultFormat: 'flat', + }; + + // Simulate what the webview save does: write clean JSON to .jsonc path + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.testCasesToExclude).to.have.length(1); + expect(result.testCasesToExclude[0].library).to.equal('CMS130'); + expect(result.resultFormat).to.equal('flat'); + }); + + test('saved JSON is valid', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { + testCasesToExclude: [], + resultFormat: 'flat', + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const raw = fs.readFileSync(configPath, 'utf-8'); + + expect(() => JSON.parse(raw)).to.not.throw(); + const parsed = JSON.parse(raw); + expect(parsed).to.deep.equal(config); + }); + + test('loadTestConfig returns defaults when config file does not exist', () => { + const configPath = path.join(tmpDir, 'nonexistent', 'config.json'); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.testCasesToExclude).to.deep.equal([]); + expect(result.resultFormat).to.equal('flat'); + }); + + test('preserves empty testCasesToExclude array', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { testCasesToExclude: [], resultFormat: 'flat' }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.testCasesToExclude).to.be.an('array').that.is.empty; + }); + + test('writes and reads back global parameters', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { + testCasesToExclude: [], + resultFormat: 'flat' as const, + parameters: [ + { name: 'Measurement Period', type: 'Interval', value: 'Interval[@2024-01-01, @2025-01-01)' }, + { name: 'Threshold', type: 'Integer', value: '42' }, + ], + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.parameters).to.have.length(2); + const p0 = result.parameters![0]; + expect(p0).to.not.have.property('library'); + expect((p0 as { name: string }).name).to.equal('Measurement Period'); + expect((p0 as { type: string }).type).to.equal('Interval'); + expect((p0 as { value: string }).value).to.equal('Interval[@2024-01-01, @2025-01-01)'); + const p1 = result.parameters![1] as { name: string; type: string; value: string }; + expect(p1.name).to.equal('Threshold'); + expect(p1.type).to.equal('Integer'); + expect(p1.value).to.equal('42'); + }); + + test('writes and reads back library blocks with parameters', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { + testCasesToExclude: [], + resultFormat: 'flat' as const, + parameters: [ + { + library: 'CMS122', + version: '2.1.0', + parameters: [ + { name: 'Threshold', type: 'Decimal', value: '10.5' }, + ], + }, + { + library: 'CMS130', + parameters: [ + { name: 'Rate', type: 'Quantity', value: '100.0 \'mg/dL\'' }, + ], + }, + ], + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.parameters).to.have.length(2); + const b0 = result.parameters![0] as { library: string; version?: string; parameters?: any[] }; + expect(b0.library).to.equal('CMS122'); + expect(b0.version).to.equal('2.1.0'); + expect(b0.parameters).to.have.length(1); + expect(b0.parameters![0].name).to.equal('Threshold'); + expect(b0.parameters![0].value).to.equal('10.5'); + + const b1 = result.parameters![1] as { library: string; version?: string; parameters?: any[] }; + expect(b1.library).to.equal('CMS130'); + expect(b1.version).to.be.undefined; + expect(b1.parameters).to.have.length(1); + expect(b1.parameters![0].name).to.equal('Rate'); + }); + + test('writes and reads back library block with test case overrides', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { + testCasesToExclude: [], + resultFormat: 'flat' as const, + parameters: [ + { + library: 'CMS122', + parameters: [ + { name: 'Threshold', type: 'Integer', value: '50' }, + ], + testCases: { + 'patient-1234': [ + { name: 'Threshold', type: 'Integer', value: '75' }, + ], + 'patient-5678': [ + { name: 'Threshold', type: 'Integer', value: '25' }, + { name: 'Rate', type: 'Decimal', value: '1.5' }, + ], + }, + }, + ], + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + const block = result.parameters![0] as any; + expect(block.library).to.equal('CMS122'); + expect(block.testCases).to.have.all.keys('patient-1234', 'patient-5678'); + expect(block.testCases['patient-1234']).to.have.length(1); + expect(block.testCases['patient-1234'][0].value).to.equal('75'); + expect(block.testCases['patient-5678']).to.have.length(2); + expect(block.testCases['patient-5678'][1].name).to.equal('Rate'); + }); + + test('parameters is undefined when config has no parameters field', () => { + const configPath = path.join(tmpDir, 'config.json'); + const config = { testCasesToExclude: [], resultFormat: 'flat' }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const result = loadTestConfig(Uri.file(configPath)); + + expect(result.parameters).to.be.undefined; + }); +}); diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 3333836..30878c5 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -9,11 +9,6 @@ export namespace Commands { */ export const OPEN_BROWSER = 'vscode.open'; - /** - * Execute Workspace Command - */ - export const EXECUTE_WORKSPACE_COMMAND = 'cql.execute.workspaceCommand'; - /** * Open CQL Language Server Log file */ @@ -39,8 +34,15 @@ export namespace Commands { */ export const VIEW_ELM_COMMAND_XML = 'cql.editor.view-elm.xml'; export const VIEW_ELM_COMMAND_JSON = 'cql.editor.view-elm.json'; + export const VIEW_ELM_COMMAND_AST = 'cql.editor.view-elm.ast'; + export const VIEW_ELM_COMMAND_AST_SPLIT = 'cql.editor.view-elm.ast.split'; export const VIEW_ELM = 'org.opencds.cqf.cql.ls.viewElm'; + /* + * Get Version Info + */ + export const GET_VERSION_INFO = 'org.opencds.cqf.cql.ls.getVersionInfo'; + /* * Execute CQL */ @@ -50,6 +52,8 @@ export namespace Commands { 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'; + export const DEBUG_TEST_CASE_COMMAND = 'cql.editor.debug-test-case'; + /* * Open settings.json */ diff --git a/src/commands/debug-test-case.ts b/src/commands/debug-test-case.ts new file mode 100644 index 0000000..f1174c2 --- /dev/null +++ b/src/commands/debug-test-case.ts @@ -0,0 +1,136 @@ +import { commands, debug, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { Commands } from '../commands/commands'; +import { getCqlPaths, getFhirVersion, loadTestConfig, waitForTestCasesLoaded } from '../helpers/cqlHelpers'; +import { resolveParameters } from '../helpers/parametersHelper'; +import { CqlLibrary, CqlTestCase } from '../model/cqlProject'; +import { CqlSolution } from '../model/cqlSolution'; + +let _context: ExtensionContext | undefined; + +export function register(context: ExtensionContext): void { + _context = context; + + context.subscriptions.push( + commands.registerCommand(Commands.DEBUG_TEST_CASE_COMMAND, async (uri: Uri) => { + await debugTestCase(uri); + }), + ); +} + +export async function debugTestCase(cqlFileUri: Uri): Promise { + const project = CqlSolution.getCurrent().findProjectForUri(cqlFileUri); + if (!project) { + window.showErrorMessage('Unable to resolve needed CQL project paths.'); + return; + } + const library = project.Libraries.find(lib => lib.uri.fsPath === cqlFileUri.fsPath); + if (!library) { + window.showErrorMessage('No CQL library found for the given file.'); + return; + } + await promptAndDebugTestCase(library); +} + +export async function startDebuggingForTestCase( + library: CqlLibrary, + testCase: CqlTestCase, +): Promise { + const uri = library.uri; + const cqlPaths = getCqlPaths(uri); + if (!cqlPaths) { + window.showErrorMessage('Unable to determine needed CQL Paths.'); + return; + } + const raw = await workspace.fs.readFile(uri); + const cqlSource = Buffer.from(raw).toString(); + const fhirVersion = getFhirVersion(cqlSource) ?? 'R4'; + + const testConfig = loadTestConfig(cqlPaths.testConfigPath); + const resolvedParams = resolveParameters( + testConfig.parameters ?? [], + library.name, + undefined, + testCase.name, + ); + + const parameters = resolvedParams.map(p => ({ + parameterName: p.name, + parameterType: p.type, + parameterValue: p.value, + })); + + await debug.startDebugging( + workspace.getWorkspaceFolder(uri), + { + type: 'cql', + request: 'launch', + name: `Debug ${library.name} — ${testCase.name}`, + libraryUri: uri.toString(), + libraryName: library.name, + fhirVersion, + testCaseName: testCase.name, + testCaseUri: testCase.uri.toString(), + terminologyUri: cqlPaths.terminologyDirectoryPath?.toString(), + rootDir: cqlPaths.projectDirectoryPath?.toString(), + optionsPath: cqlPaths.optionsPath?.toString(), + parameters: parameters.length > 0 ? parameters : undefined, + }, + ); +} + +export async function promptAndDebugTestCase(library: CqlLibrary): Promise { + if (library.project) { + await waitForTestCasesLoaded(library, library.project, false); + } + + const testCases = library.TestCases; + if (testCases.length === 0) { + window.showInformationMessage('No test cases found.'); + return; + } + + const stateKey = `debugTestCase.selections.${library.name}`; + const saved = _context?.workspaceState.get(stateKey); + + const quickPick = window.createQuickPick(); + quickPick.items = [...testCases] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(tc => ({ label: tc.name })); + quickPick.canSelectMany = false; + quickPick.placeholder = 'Select a test case to debug'; + quickPick.title = `Debug — ${library.name}`; + + const savedMatch = saved + ? quickPick.items.find(item => item.label === saved) + : undefined; + + const selectedTestCase = await new Promise(resolve => { + let accepted = false; + + quickPick.onDidAccept(() => { + accepted = true; + const selected = quickPick.selectedItems[0]; + quickPick.hide(); + resolve( + selected + ? testCases.find(tc => tc.name === selected.label) + : undefined, + ); + }); + + quickPick.onDidHide(() => { + if (!accepted) resolve(undefined); + }); + + setTimeout(() => { + quickPick.show(); + if (savedMatch) { + quickPick.activeItems = [savedMatch]; + } + }, 0); + }); + + if (!selectedTestCase) return; + await _context?.workspaceState.update(stateKey, selectedTestCase.name); + await startDebuggingForTestCase(library, selectedTestCase); +} \ No newline at end of file diff --git a/src/commands/execute-cql-file.ts b/src/commands/execute-cql-file.ts new file mode 100644 index 0000000..c2e2f68 --- /dev/null +++ b/src/commands/execute-cql-file.ts @@ -0,0 +1,388 @@ +import * as fse from 'fs-extra'; +import * as fs from 'node:fs'; +import { + commands, + ExtensionContext, + Position, + ProgressLocation, + Range, + Uri, + window, + workspace +} from 'vscode'; +import { Utils } from 'vscode-uri'; +import { Commands } from '../commands/commands'; +import { executeCql, ExecuteCqlResponse, ExpressionResult } from '../cql-service/cqlService.executeCql'; +import { + CqlPaths, + getCqlPaths, + getExcludedTestCases, + getFhirVersion, + loadTestConfig, + waitForTestCasesLoaded +} from '../helpers/cqlHelpers'; +import { extractLibraryVersion } from '../helpers/fileHelper'; +import { resolveParameters } from '../helpers/parametersHelper'; +import { CqlSolution } from '../model/cqlSolution'; +import { CqlParametersConfig, ParameterEntry, ResultParameterEntry } from '../model/parameters'; +import { getMeasureReportData, getTestCases, TestCase } from '../model/testCase'; +import { VersionInfo } from '../protocol'; + +export interface TestCaseResult { + executedAt: string; + libraryName: string; + testCaseName: string | null; + testCaseDescription: string | null; + parameters: ResultParameterEntry[]; + results: ExpressionResult[]; + errors: string[]; + versions?: VersionInfo; +} + +export function register(context: ExtensionContext): void { + context.subscriptions.push( + commands.registerCommand(Commands.EXECUTE_CQL_COMMAND, async (uri: Uri) => { + executeCQLFile(uri); + }), + ); +} + +/** + * Main command entry point orchestrating execution flow. + */ +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.'); + return; + } + + const cqlPaths = getCqlPaths(cqlFileUri); + if (!cqlPaths) { + window.showErrorMessage('Unable to determine needed CQL Paths.'); + return; + } + + const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; + const libraryDisplayName = Utils.basename(cqlFileUri).replace('.cql', ''); + const testConfig = loadTestConfig(cqlPaths.testConfigPath); + const excludedTestCases = getExcludedTestCases(libraryName, testConfig.testCasesToExclude); + + // Sync / Wait for projects if required + if (testCases === undefined) { + const project = CqlSolution.getCurrent().findProjectForUri(cqlFileUri); + const library = project?.Libraries.find(lib => lib.uri.fsPath === cqlFileUri.fsPath); + if (library && project) { + await waitForTestCasesLoaded(library, project, showProgress); + } + } + + const effectiveTestCases: Array = + testCases ?? + getTestCases(cqlPaths.testDirectoryPath, libraryName, Array.from(excludedTestCases.keys())); + + if (effectiveTestCases.length === 0) { + effectiveTestCases.push({}); + } + + const cqlSource = fs.readFileSync(cqlFileUri.fsPath, 'utf-8'); + const determinedFhirVersion = getFhirVersion(cqlSource); + const fhirVersion = determinedFhirVersion ?? 'R4'; + if (!determinedFhirVersion) { + window.showInformationMessage('Unable to determine version of FHIR used. Defaulting to R4.'); + } + const libraryVersion = extractLibraryVersion(cqlSource); + const resultFormat = resultFormatOverride ?? testConfig.resultFormat; + + const doExecute = () => + executeCql( + cqlFileUri, + effectiveTestCases, + cqlPaths.terminologyDirectoryPath, + fhirVersion, + cqlPaths.optionsPath, + cqlPaths.projectDirectoryPath, + undefined, + testConfig.parameters, + libraryVersion, + ); + + const startExecution = Date.now(); + let response: ExecuteCqlResponse | undefined; + + if (showProgress) { + response = await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Executing CQL: ${libraryDisplayName}`, + cancellable: false, + }, + async progress => { + progress.report({ message: 'Running...' }); + return doExecute(); + }, + ); + } else { + response = await doExecute(); + } + + const elapsedSeconds = (Date.now() - startExecution) / 1000; + + if (!response) return; + + // Delegate based on format layout strategy + if (resultFormat === 'individual') { + await handleIndividualResults( + libraryName, + libraryVersion, + effectiveTestCases, + response, + cqlPaths, + startExecution, + testConfig.parameters, + showCompletion, + elapsedSeconds + ); + } else { + await handleAggregatedTextReport( + libraryName, + effectiveTestCases, + excludedTestCases, + response, + cqlPaths, + testConfig.flatResultsInSubfolder ?? false, + elapsedSeconds + ); + } +} + +/** + * Format Handler: Individual JSON Outputs + */ +async function handleIndividualResults( + libraryName: string, + libraryVersion: string | undefined, + effectiveTestCases: TestCase[], + response: ExecuteCqlResponse, + cqlPaths: CqlPaths, + startExecution: number, + parameters: CqlParametersConfig | undefined, + showCompletion: boolean, + elapsedSeconds: number +) { + writeIndividualResultFiles( + libraryName, + libraryVersion, + effectiveTestCases, + response, + cqlPaths.resultDirectoryPath, + startExecution, + parameters, + ); + + if (!showCompletion) return; + + 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 { + window.showInformationMessage( + `CQL execution complete — ${effectiveTestCases.length} test cases written (${elapsedSeconds.toFixed(1)}s)`, + ); + } +} + +/** + * Format Handler: Consolidated Text Output + */ +async function handleAggregatedTextReport( + libraryName: string, + effectiveTestCases: TestCase[], + excludedTestCases: Map, + response: ExecuteCqlResponse, + cqlPaths: CqlPaths, + flatResultsInSubfolder: boolean, + elapsedSeconds: number +) { + const outDir = flatResultsInSubfolder + ? Utils.resolvePath(cqlPaths.resultDirectoryPath, libraryName) + : cqlPaths.resultDirectoryPath; + fse.ensureDirSync(outDir.fsPath); + const outputPath = Utils.resolvePath(outDir, `${libraryName}.txt`); + + if (!fs.existsSync(outputPath.fsPath)) { + fs.writeFileSync(outputPath.fsPath, ''); + } + + const textDocument = await workspace.openTextDocument(outputPath); + const textEditor = await window.showTextDocument(textDocument); + + // Generate complete unified report buffer + const reportText = generateTextReport( + cqlPaths, + effectiveTestCases, + excludedTestCases, + response, + elapsedSeconds + ); + + // Atomic replace execution + const entireDocRange = new Range( + new Position(0, 0), + textDocument.lineAt(Math.max(0, textDocument.lineCount - 1)).range.end, + ); + + await textEditor.edit( + editBuilder => { + editBuilder.replace(entireDocRange, reportText); + }, + { undoStopBefore: false, undoStopAfter: false }, + ); + + await textDocument.save(); +} + +/** + * String Assembler: Composes entire plaintext dashboard view + */ +function generateTextReport( + cqlPaths: CqlPaths, + effectiveTestCases: TestCase[], + excludedTestCases: Map, + response: ExecuteCqlResponse, + elapsedSeconds: number +): string { + const lines: string[] = []; + + lines.push(`CQL: ${cqlPaths.libraryDirectoryPath.fsPath}`); + + // Map system variations cleanly via configuration definitions + if (response.versions) { + const versionKeys: Array<[keyof VersionInfo, string]> = [ + ['clinicalReasoning', 'Clinical Reasoning version'], + ['engine', 'Engine version'], + ['languageServer', 'Language Server version'], + ['translator', 'Translator version'], + ]; + for (const [key, label] of versionKeys) { + if (response.versions[key]) { + lines.push(`${label}: ${response.versions[key]}`); + } + } + } + + const termPath = cqlPaths.terminologyDirectoryPath.fsPath; + lines.push( + fs.existsSync(termPath) + ? `Terminology: ${termPath}` + : `No terminology found at ${termPath}. Evaluation may fail if terminology is required.` + ); + + if (effectiveTestCases.length === 1 && !effectiveTestCases[0].name) { + lines.push(`No data found at ${cqlPaths.testDirectoryPath.fsPath}. Evaluation may fail if data is required.`); + } else { + lines.push('Test cases:'); + for (const p of effectiveTestCases) { + lines.push(`${p.name} - ${p.path?.fsPath}`); + } + } + + if (excludedTestCases.size > 0) { + lines.push('\nExcluded test cases:'); + for (const [testCase, reason] of excludedTestCases.entries()) { + lines.push(`${testCase} - ${reason}`); + } + } + + lines.push(''); + lines.push(formatResponse(response)); + lines.push(`\nelapsed: ${elapsedSeconds.toString()} seconds\n`); + + return lines.join('\n'); +} + +export function formatResponse(response: ExecuteCqlResponse): string { + const lines: string[] = []; + + response.results.forEach((result, i) => { + if (i > 0) lines.push(''); + for (const expr of result.expressions) { + lines.push(`${expr.name}=${expr.value}`); + } + }); + + if (response.logs.length > 0) { + lines.push('', 'Evaluation logs:', ...response.logs); + } + return lines.join('\n'); +} + +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, + versions: response.versions, + }; + + const patientId = testCaseName ?? 'no-context'; + const outputPath = Utils.resolvePath( + resultDirectoryPath, + libraryName, + `TestCaseResult-${patientId}.json`, + ); + fse.outputFileSync(outputPath.fsPath, JSON.stringify(result, null, 2)); + } +} \ No newline at end of file diff --git a/src/commands/execute-cql.ts b/src/commands/execute-cql.ts deleted file mode 100644 index a834c4f..0000000 --- a/src/commands/execute-cql.ts +++ /dev/null @@ -1,653 +0,0 @@ -import * as fse from 'fs-extra'; -import { glob } from 'glob'; -import { ParseError, parse as parseJsonc } from 'jsonc-parser'; -import * as fs from 'node:fs'; -import { - commands, - ExtensionContext, - Position, - ProgressLocation, - Range, - TextEditor, - Uri, - window, - workspace, -} from 'vscode'; -import { Utils } from 'vscode-uri'; -import { Commands } from '../commands/commands'; -import { ExecuteCqlResponse, ExpressionResult, executeCql } from '../cql-service/cqlService.executeCql'; -import { CqlLibrary, CqlProject, CqlProjectEvents } from '../model/cqlProject'; -import { CqlSolution } from '../model/cqlSolution'; -import { extractLibraryVersion, toGlobPath } from '../helpers/fileHelper'; -import { resolveParameters } from '../helpers/parametersHelper'; -import * as log from '../log-services/logger'; -import { CqlParametersConfig, ParameterEntry, ResultParameterEntry } from '../model/parameters'; -import { getMeasureReportData, getTestCases, 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; - optionsPath: Uri; - resultDirectoryPath: Uri; - terminologyDirectoryPath: Uri; - testConfigPath: Uri; - testDirectoryPath: Uri; -} - -export interface TestConfig { - testCasesToExclude: TestCaseExclusion[]; - parameters?: CqlParametersConfig; - resultFormat?: 'individual' | 'flat'; -} - -const DEFAULT_TEST_CONFIG: TestConfig = { testCasesToExclude: [], resultFormat: 'flat' } - -export function register(context: ExtensionContext): void { - _context = context; - - // Track when library shells are loaded so the select-libraries command can be enabled in menus. - // Uses libraryShellsLoaded to handle the race where LOADED fires before register() runs. - const solution = CqlSolution.getCurrent(); - commands.executeCommand('setContext', 'cql.solutionLoaded', false); - const total = solution.projects.length; - if (total === 0) { - commands.executeCommand('setContext', 'cql.solutionLoaded', true); - } else { - let loadedCount = solution.projects.filter(p => p.libraryShellsLoaded).length; - if (loadedCount >= total) { - commands.executeCommand('setContext', 'cql.solutionLoaded', true); - } else { - for (const project of solution.projects) { - if (!project.libraryShellsLoaded) { - project.once(CqlProjectEvents.LOADED, () => { - if (++loadedCount >= total) { - commands.executeCommand('setContext', 'cql.solutionLoaded', true); - } - }); - } - } - } - } - - context.subscriptions.push( - commands.registerCommand(Commands.EXECUTE_CQL_COMMAND, async (uri: Uri) => { - executeCQLFile(uri); - }), - commands.registerCommand(Commands.EXECUTE_CQL_COMMAND_SELECT_LIBRARIES, async (uri: Uri) => { - selectLibraries(); - }), - commands.registerCommand(Commands.EXECUTE_CQL_COMMAND_SELECT_TEST_CASES, async (uri: Uri) => { - selectTestCases(uri); - }), - ); -} - -export async function selectLibraries(): Promise { - // Use the active editor's URI to scope to the right project; fall back to - // the first project if no editor is open or the file is not in any project. - const activeUri = window.activeTextEditor?.document.uri; - const solution = CqlSolution.getCurrent(); - const fallbackProject = solution.projects[0]; - const anchorUri = activeUri - ? (solution.findProjectForUri(activeUri) ? activeUri : fallbackProject && Uri.file(fallbackProject.igRoot)) - : (fallbackProject && Uri.file(fallbackProject.igRoot)); - if (!anchorUri) { - window.showErrorMessage('No CQL project found in this workspace.'); - return; - } - const cqlPaths = getCqlPaths(anchorUri); - if (!cqlPaths) { - window.showErrorMessage('Unable to determine needed CQL Paths.'); - return; - } - - const libraries = getLibraries(cqlPaths.libraryDirectoryPath); - const quickPickItems = libraries - .map(uri => ({ label: Utils.basename(uri), uri })) - .sort((a, b) => a.label.localeCompare(b.label)); - - const quickPick = window.createQuickPick<{ label: string; uri: Uri }>(); - if (quickPickItems.length > 0) { - quickPick.items = quickPickItems; - quickPick.canSelectMany = true; - - const stateKey = 'selectLibraries.selections'; - const savedSelections = _context?.workspaceState.get(stateKey) ?? []; - if (savedSelections.length > 0) { - quickPick.selectedItems = quickPick.items.filter(item => - savedSelections.includes(item.label), - ); - } - - quickPick.show(); - - quickPick.onDidAccept(async () => { - const selected = [...quickPick.selectedItems]; - _context?.workspaceState.update(stateKey, selected.map(item => item.label)); - quickPick.hide(); - - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Executing CQL Libraries', - cancellable: true, - }, - 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; - } - const item = selected[i]; - progress.report({ - message: `(${i + 1}/${total}) ${item.label}`, - increment: (1 / total) * 100, - }); - 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); - }, - ); - }); - } -} - -export async function selectTestCases(cqlFileUri: Uri): Promise { - const cqlPaths = getCqlPaths(cqlFileUri); - if (!cqlPaths) { - const msg = 'Unable to resolve needed CQL project paths.'; - log.error(msg); - window.showErrorMessage(msg); - return; - } - - const quickPick = window.createQuickPick(); - const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; - const testConfig = loadTestConfig(cqlPaths.testConfigPath); - const excludedTestCases = getExcludedTestCases(libraryName, testConfig.testCasesToExclude); - const testCases = getTestCases( - cqlPaths.testDirectoryPath, - libraryName, - Array.from(excludedTestCases.keys()), - ); - const namedTestCases = testCases.filter( - (testCase): testCase is Required => testCase.name !== undefined, - ); - const quickPickItems = namedTestCases.map(testCase => ({ - label: testCase.name, - detail: getMeasureReportData(testCase.path)?.description, - })); - - if (quickPickItems.length > 0) { - quickPick.items = quickPickItems; - quickPick.canSelectMany = true; - quickPick.matchOnDetail = true; - - const stateKey = `selectTestCases.selections.${libraryName}`; - const savedSelections = _context?.workspaceState.get(stateKey) ?? []; - if (savedSelections.length > 0) { - quickPick.selectedItems = quickPick.items.filter(item => - savedSelections.includes(item.label), - ); - } - - quickPick.show(); - - quickPick.onDidAccept(() => { - const selectedLabels = quickPick.selectedItems.map(item => item.label); - _context?.workspaceState.update(stateKey, selectedLabels); - - const selected = namedTestCases.filter(testCase => - selectedLabels.some(label => label === testCase.name), - ); - quickPick.hide(); - executeCQLFile(cqlFileUri, selected); - }); - } -} - -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.'); - return; - } - - const cqlPaths = getCqlPaths(cqlFileUri); - if (!cqlPaths) { - window.showErrorMessage('Unable to determine needed CQL Paths.'); - return; - } - - const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; - const libraryDisplayName = Utils.basename(cqlFileUri).replace('.cql', ''); - - const testConfig = loadTestConfig(cqlPaths.testConfigPath); - const excludedTestCases = getExcludedTestCases(libraryName, testConfig.testCasesToExclude); - - // When no test cases are provided we are about to scan disk for them. Wait for the - // in-memory model to finish loading first so we never execute against an empty set - // during initial workspace startup. No-op if the library is already loaded. - if (testCases === undefined) { - const project = CqlSolution.getCurrent().findProjectForUri(cqlFileUri); - const library = project?.Libraries.find(lib => lib.uri.fsPath === cqlFileUri.fsPath); - if (library && project) { - await waitForTestCasesLoaded(library, project, showProgress); - } - } - - const effectiveTestCases: Array = - testCases ?? - 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 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 resultFormat = resultFormatOverride - ?? testConfig.resultFormat - - const doExecute = () => - executeCql( - cqlFileUri, - effectiveTestCases, - cqlPaths.terminologyDirectoryPath, - fhirVersion, - cqlPaths.optionsPath, - cqlPaths.projectDirectoryPath, - undefined, - testConfig.parameters, - libraryVersion, - ); - - const startExecution = Date.now(); - - - let response: ExecuteCqlResponse | undefined; - if (showProgress) { - response = await window.withProgress( - { - location: ProgressLocation.Notification, - title: `Executing CQL: ${libraryDisplayName}`, - cancellable: false, - }, - async progress => { - progress.report({ message: 'Running...' }); - return doExecute(); - }, - ); - } else { - 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 }, - ); - - 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(cqlFileUri: Uri): CqlPaths | undefined { - const project = CqlSolution.getCurrent().findProjectForUri(cqlFileUri); - if (!project) { - window.showErrorMessage('Unable to determine path to project root.'); - return; - } - const projectDirectoryPath = Uri.file(project.igRoot); - const libraryDirectoryPath = Utils.resolvePath(projectDirectoryPath, 'input', 'cql'); - const testDirectoryPath = Utils.resolvePath(projectDirectoryPath, 'input', 'tests'); - return { - projectDirectoryPath: projectDirectoryPath, - libraryDirectoryPath: libraryDirectoryPath, - optionsPath: Utils.resolvePath(libraryDirectoryPath, 'cql-options.json'), - resultDirectoryPath: Utils.resolvePath(testDirectoryPath, 'results'), - terminologyDirectoryPath: Utils.resolvePath( - projectDirectoryPath, - 'input', - 'vocabulary', - 'valueset', - ), - testConfigPath: resolveTestConfigPath(testDirectoryPath), - testDirectoryPath: testDirectoryPath, - }; -} - -export function getExcludedTestCases( - libraryName: string, - testCasesToExclude: TestCaseExclusion[], -): Map { - let excludedTestCases = new Map(); - for (let excludedTestCase of testCasesToExclude) { - if (excludedTestCase.library == libraryName) { - excludedTestCases.set(excludedTestCase.testCase, excludedTestCase.reason); - } - } - return excludedTestCases; -} - -export function getFhirVersion(cqlContent: string): string | null { - // 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; -} - -export function getLibraries(libraryPath: Uri): Array { - if (!fs.existsSync(libraryPath.fsPath)) { - log.warn(`unable to find libraries @ ${libraryPath.fsPath}`); - return []; - } - return glob - .sync(`${toGlobPath(libraryPath.fsPath)}/**/*.cql`) - .filter(f => fs.statSync(f).isFile()) - .map(f => Uri.file(f)); -} - - -async function insertLineAtEnd(textEditor: TextEditor, text: string) { - const document = textEditor.document; - await textEditor.edit( - editBuilder => { - editBuilder.insert(new Position(document.lineCount, 0), text + '\n'); - }, - { undoStopBefore: false, undoStopAfter: false }, - ); -} - -async function waitForTestCasesLoaded( - library: CqlLibrary, - project: CqlProject, - showProgress: boolean, -): Promise { - if (library.testCaseLoadState === 'loaded') return; - - const waitPromise = new Promise(resolve => { - const handler = (loaded: CqlLibrary) => { - if (loaded === library) { - project.off(CqlProjectEvents.LIBRARY_TESTCASES_LOADED, handler); - resolve(); - } - }; - project.on(CqlProjectEvents.LIBRARY_TESTCASES_LOADED, handler); - if (library.testCaseLoadState === 'not-loaded') { - project.loadTestCasesForLibrary(library).catch(() => {}); - } - }); - - if (showProgress) { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: `Loading ${library.name}\u2026`, - cancellable: false, - }, - () => waitPromise, - ); - } else { - await waitPromise; - } -} - -export function loadTestConfig(testConfigPath: Uri): TestConfig { - if (!fs.existsSync(testConfigPath.fsPath)) { - log.info('No test config file found, using default settings', testConfigPath.fsPath); - return DEFAULT_TEST_CONFIG; - } - try { - const jsonString = fs.readFileSync(testConfigPath.fsPath, 'utf-8'); - const errors: ParseError[] = []; - const parsed = parseJsonc(jsonString, errors) as TestConfig; - if (errors.length > 0) { - log.error('Error parsing config file', errors); - return DEFAULT_TEST_CONFIG; - } - return { - ...parsed, - testCasesToExclude: parsed.testCasesToExclude ?? DEFAULT_TEST_CONFIG.testCasesToExclude, - resultFormat: parsed.resultFormat ?? DEFAULT_TEST_CONFIG.resultFormat - }; - } catch (error) { - log.error('Error reading config file', error); - return { testCasesToExclude: [] }; - } -} - diff --git a/src/commands/select-libraries.ts b/src/commands/select-libraries.ts new file mode 100644 index 0000000..63d5d42 --- /dev/null +++ b/src/commands/select-libraries.ts @@ -0,0 +1,129 @@ +import { commands, ExtensionContext, ProgressLocation, Uri, window } from 'vscode'; +import { Utils } from 'vscode-uri'; +import { Commands } from '../commands/commands'; +import { CqlProjectEvents } from '../model/cqlProject'; +import { CqlSolution } from '../model/cqlSolution'; +import { getCqlPaths, getLibraries } from '../helpers/cqlHelpers'; +import { executeCQLFile } from './execute-cql-file'; +import * as log from '../log-services/logger'; + +let _context: ExtensionContext | undefined; + +export function register(context: ExtensionContext): void { + _context = context; + + const solution = CqlSolution.getCurrent(); + commands.executeCommand('setContext', 'cql.solutionLoaded', false); + const total = solution.projects.length; + if (total === 0) { + commands.executeCommand('setContext', 'cql.solutionLoaded', true); + } else { + let loadedCount = solution.projects.filter(p => p.libraryShellsLoaded).length; + if (loadedCount >= total) { + commands.executeCommand('setContext', 'cql.solutionLoaded', true); + } else { + for (const project of solution.projects) { + if (!project.libraryShellsLoaded) { + project.once(CqlProjectEvents.LOADED, () => { + if (++loadedCount >= total) { + commands.executeCommand('setContext', 'cql.solutionLoaded', true); + } + }); + } + } + } + } + + context.subscriptions.push( + commands.registerCommand(Commands.EXECUTE_CQL_COMMAND_SELECT_LIBRARIES, async () => { + selectLibraries(); + }), + ); +} + +export async function selectLibraries(): Promise { + const activeUri = window.activeTextEditor?.document.uri; + const solution = CqlSolution.getCurrent(); + const fallbackProject = solution.projects[0]; + const anchorUri = activeUri + ? (solution.findProjectForUri(activeUri) ? activeUri : fallbackProject && Uri.file(fallbackProject.igRoot)) + : (fallbackProject && Uri.file(fallbackProject.igRoot)); + if (!anchorUri) { + window.showErrorMessage('No CQL project found in this workspace.'); + return; + } + const cqlPaths = getCqlPaths(anchorUri); + if (!cqlPaths) { + window.showErrorMessage('Unable to determine needed CQL Paths.'); + return; + } + + const libraries = getLibraries(cqlPaths.libraryDirectoryPath); + const quickPickItems = libraries + .map(uri => ({ label: Utils.basename(uri), uri })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const quickPick = window.createQuickPick<{ label: string; uri: Uri }>(); + if (quickPickItems.length > 0) { + quickPick.items = quickPickItems; + quickPick.canSelectMany = true; + + const stateKey = 'selectLibraries.selections'; + const savedSelections = _context?.workspaceState.get(stateKey) ?? []; + if (savedSelections.length > 0) { + quickPick.selectedItems = quickPick.items.filter(item => + savedSelections.includes(item.label), + ); + } + + quickPick.show(); + + quickPick.onDidAccept(async () => { + const selected = [...quickPick.selectedItems]; + _context?.workspaceState.update(stateKey, selected.map(item => item.label)); + quickPick.hide(); + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: 'Executing CQL Libraries', + cancellable: true, + }, + 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; + } + const item = selected[i]; + progress.report({ + message: `(${i + 1}/${total}) ${item.label}`, + increment: (1 / total) * 100, + }); + 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); + }, + ); + }); + } +} diff --git a/src/commands/select-test-cases.ts b/src/commands/select-test-cases.ts new file mode 100644 index 0000000..339e300 --- /dev/null +++ b/src/commands/select-test-cases.ts @@ -0,0 +1,73 @@ +import { commands, ExtensionContext, Uri, window } from 'vscode'; +import { Utils } from 'vscode-uri'; +import { Commands } from '../commands/commands'; +import { getCqlPaths, getExcludedTestCases, loadTestConfig } from '../helpers/cqlHelpers'; +import { getMeasureReportData, getTestCases, TestCase } from '../model/testCase'; +import * as log from '../log-services/logger'; +import { executeCQLFile } from './execute-cql-file'; + +let _context: ExtensionContext | undefined; + +export function register(context: ExtensionContext): void { + _context = context; + + context.subscriptions.push( + commands.registerCommand(Commands.EXECUTE_CQL_COMMAND_SELECT_TEST_CASES, async (uri: Uri) => { + selectTestCases(uri); + }), + ); +} + +export async function selectTestCases(cqlFileUri: Uri): Promise { + const cqlPaths = getCqlPaths(cqlFileUri); + if (!cqlPaths) { + const msg = 'Unable to resolve needed CQL project paths.'; + log.error(msg); + window.showErrorMessage(msg); + return; + } + + const quickPick = window.createQuickPick(); + const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; + const testConfig = loadTestConfig(cqlPaths.testConfigPath); + const excludedTestCases = getExcludedTestCases(libraryName, testConfig.testCasesToExclude); + const testCases = getTestCases( + cqlPaths.testDirectoryPath, + libraryName, + Array.from(excludedTestCases.keys()), + ); + const namedTestCases = testCases.filter( + (testCase): testCase is Required => testCase.name !== undefined, + ); + const quickPickItems = namedTestCases.map(testCase => ({ + label: testCase.name, + detail: getMeasureReportData(testCase.path)?.description, + })); + + if (quickPickItems.length > 0) { + quickPick.items = quickPickItems; + quickPick.canSelectMany = true; + quickPick.matchOnDetail = true; + + const stateKey = `selectTestCases.selections.${libraryName}`; + const savedSelections = _context?.workspaceState.get(stateKey) ?? []; + if (savedSelections.length > 0) { + quickPick.selectedItems = quickPick.items.filter(item => + savedSelections.includes(item.label), + ); + } + + quickPick.show(); + + quickPick.onDidAccept(() => { + const selectedLabels = quickPick.selectedItems.map(item => item.label); + _context?.workspaceState.update(stateKey, selectedLabels); + + const selected = namedTestCases.filter(testCase => + selectedLabels.some(label => label === testCase.name), + ); + quickPick.hide(); + executeCQLFile(cqlFileUri, selected); + }); + } +} diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index 925eb04..44f685e 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -1,7 +1,19 @@ -import { commands, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { + commands, + ExtensionContext, + Range, + TextDocument, + Uri, + window, + workspace, +} from 'vscode'; import { getElm } from '../cql-service/cqlService.getElm'; import * as log from '../log-services/logger'; import { Commands } from './commands'; +import { formatJson } from '../utils/astIndex'; +import { AstSplitSessionManager } from '../views/astSplitSession'; + +const openAstDocs = new Map(); export function register(context: ExtensionContext): void { context.subscriptions.push( @@ -11,19 +23,62 @@ export function register(context: ExtensionContext): void { commands.registerCommand(Commands.VIEW_ELM_COMMAND_JSON, async (uri: Uri) => { viewElm(uri, 'json'); }), + commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST, async (uri: Uri) => { + viewElm(uri, 'ast'); + }), + commands.registerCommand( + Commands.VIEW_ELM_COMMAND_AST_SPLIT, + async (uri: Uri, fetcher?: (uri: Uri, type: 'ast') => Promise) => { + await AstSplitSessionManager.createOrUpdateSession(uri, fetcher); + }, + ), + workspace.onDidCloseTextDocument(doc => { + for (const [key, tracked] of openAstDocs) { + if (tracked === doc) { + openAstDocs.delete(key); + break; + } + } + }), ); } export async function viewElm( cqlFileUri: Uri, - elmType: 'xml' | 'json' = 'xml', - elmFetcher: (uri: Uri, type: 'xml' | 'json') => Promise = getElm, + elmType: 'xml' | 'json' | 'ast' = 'xml', + elmFetcher: (uri: Uri, type: 'xml' | 'json' | 'ast') => Promise = getElm, ) { try { log.debug(`attempting to get ELM from [${cqlFileUri}] as ${elmType}`); const elm: string = await elmFetcher(cqlFileUri, elmType); + const languageId = elmType === 'ast' ? 'ast' : elmType; const formatted = elmType === 'json' ? formatJson(elm) : elm; - const doc = await workspace.openTextDocument({ language: elmType, content: formatted }); + if (elmType === 'ast') { + const uriKey = cqlFileUri.toString(); + const existing = openAstDocs.get(uriKey); + let reused = false; + if (existing && !existing.isClosed) { + try { + const editor = await window.showTextDocument(existing); + const lastLine = editor.document.lineAt(editor.document.lineCount - 1); + reused = await editor.edit(edit => + edit.replace(new Range(0, 0, lastLine.lineNumber, lastLine.text.length), formatted), + ) ?? false; + if (!reused) { + openAstDocs.delete(uriKey); + } + } catch { + openAstDocs.delete(uriKey); + } + } + if (!reused) { + const doc = await workspace.openTextDocument({ language: 'ast', content: formatted }); + openAstDocs.set(uriKey, doc); + await window.showTextDocument(doc); + } + return; + } + const doc = await workspace.openTextDocument({ language: languageId, content: formatted }); await window.showTextDocument(doc); if (elmType === 'xml') { await commands.executeCommand('editor.action.formatDocument'); @@ -32,11 +87,3 @@ export async function viewElm( window.showErrorMessage(`Error while converting ${cqlFileUri.fsPath} to ELM. err: ${error}`); } } - -function formatJson(raw: string): string { - try { - return JSON.stringify(JSON.parse(raw), null, 2); - } catch { - return raw; - } -} diff --git a/src/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index d8e3788..276bfaa 100644 --- a/src/cql-explorer/cqlExplorer.ts +++ b/src/cql-explorer/cqlExplorer.ts @@ -1,7 +1,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; -import { executeCQLFile } from '../commands/execute-cql'; +import { executeCQLFile } from '../commands/execute-cql-file'; +import { promptAndDebugTestCase, startDebuggingForTestCase } from '../commands/debug-test-case'; import { viewElm } from '../commands/view-elm'; import { logger } from '../extensionLogger'; import { @@ -17,6 +18,7 @@ import { CqlProject, CqlTestCase } from '../model/cqlProject'; import { CqlSolution } from '../model/cqlSolution'; import { DeviationKind } from '../model/igLayoutDetector'; import { cloneTestCase } from './testCaseCloner'; +import { ConfigEditorWebview } from '../views/configEditorWebview'; import { copyResources, deleteResources, @@ -188,7 +190,11 @@ export class CqlExplorer { const filter = this.nameFilter.toLowerCase(); const libs = this.cqlProjects .flatMap(p => p.Libraries) - .filter(l => filter === '' || l.name.toLowerCase().includes(filter)); + .filter( + l => + (!this.hideEmpty || l.TestCases.length > 0) && + (filter === '' || l.name.toLowerCase().includes(filter)), + ); if (libs.length === 0) { return; } @@ -238,6 +244,15 @@ export class CqlExplorer { }, ), + // view ELM as AST + vscode.commands.registerCommand( + 'cql.explorer.library.ast', + async (item: CqlLibraryTreeItem) => { + logger.debug(`Command cql.explorer.library.ast selected for item: ${item.label}`); + await viewElm(item.cqlLibrary.uri, 'ast'); + }, + ), + // execute a single test case vscode.commands.registerCommand( 'cql.explorer.test-case.execute', @@ -629,6 +644,7 @@ export class CqlExplorer { this.updateTreeViewDescription(); }), + // TODO: Re-evaluate — layout warnings removed from package.json UI surface // Enable layout deviation warnings (icons + Problems panel) vscode.commands.registerCommand('cql.explorer.show-layout-warnings', () => { this.showLayoutWarnings = true; @@ -637,6 +653,7 @@ export class CqlExplorer { this.publishDiagnostics(); }), + // TODO: Re-evaluate — layout warnings removed from package.json UI surface // Disable layout deviation warnings vscode.commands.registerCommand('cql.explorer.hide-layout-warnings', () => { this.showLayoutWarnings = false; @@ -713,6 +730,50 @@ export class CqlExplorer { this.cqlLibraryTreeProvider.setTestCaseFilter(item.cqlLibrary.uri.fsPath, ''); }, ), + + // debug a test case + vscode.commands.registerCommand( + 'cql.explorer.test-case.debug', + async (item: CqlTestCaseRootTreeItem | CqlTestCaseTreeItem) => { + if (item instanceof CqlTestCaseTreeItem) { + const library = this.cqlProjects + .flatMap(p => p.Libraries) + .find(lib => + lib.TestCases.some(tc => tc.uri.fsPath === item.cqlTestCase.uri.fsPath), + ); + if (!library) { + vscode.window.showErrorMessage('Unable to find parent library.'); + return; + } + await startDebuggingForTestCase(library, item.cqlTestCase); + return; + } + + await promptAndDebugTestCase(item.cqlLibrary); + }, + ), + + // Edit project configuration + vscode.commands.registerCommand('cql.explorer.edit-config', async () => { + const projects = CqlSolution.getCurrent().projects; + if (projects.length === 0) { + vscode.window.showInformationMessage('No CQL projects found.'); + return; + } + let project: CqlProject; + if (projects.length === 1) { + project = projects[0]; + } else { + const pick = await vscode.window.showQuickPick( + projects.map(p => ({ label: p.name, project: p })), + { placeHolder: 'Select a project to configure' }, + ); + if (!pick) return; + project = pick.project; + } + logger.debug(`Command cql.explorer.edit-config selected for project: ${project.name}`); + ConfigEditorWebview.createOrShow(project); + }), ); } diff --git a/src/cql-explorer/cqlProjectTreeDataProvider.ts b/src/cql-explorer/cqlProjectTreeDataProvider.ts index a5a41ef..81bc54d 100644 --- a/src/cql-explorer/cqlProjectTreeDataProvider.ts +++ b/src/cql-explorer/cqlProjectTreeDataProvider.ts @@ -11,6 +11,58 @@ import { } from '../model/cqlProject'; import { DeviationKind } from '../model/igLayoutDetector'; +interface CmsNameParts { + isCms: boolean; + measureNumber: number; + suffix: string; +} + +function parseCmsName(name: string): CmsNameParts { + const cmsMatch = name.match(/^CMS(?:FHIR)?(\d+)(.*)$/); + if (cmsMatch) { + return { + isCms: true, + measureNumber: parseInt(cmsMatch[1], 10), + suffix: cmsMatch[2], + }; + } + return { isCms: false, measureNumber: 0, suffix: name }; +} + +function compareCmsNames(a: string, b: string, descending: boolean): number { + const pa = parseCmsName(a); + const pb = parseCmsName(b); + + function group(p: CmsNameParts, name: string): number { + if (p.isCms) return 1; + return name.localeCompare('CMS') < 0 ? 0 : 2; + } + + const ga = group(pa, a); + const gb = group(pb, b); + + if (ga !== gb) { + // Ascending groups: 0 (< CMS), 1 (CMS), 2 (> CMS) + // Descending groups: 2, 1, 0 + return descending ? gb - ga : ga - gb; + } + + if (ga === 1) { + // Both CMS: sort by measure number numerically + const numDiff = descending + ? pb.measureNumber - pa.measureNumber + : pa.measureNumber - pb.measureNumber; + if (numDiff !== 0) return numDiff; + // Tie: sort alphabetically by suffix + return descending + ? pb.suffix.localeCompare(pa.suffix) + : pa.suffix.localeCompare(pb.suffix); + } + + // Both non-CMS (group 0 or 2): standard localeCompare + return descending ? b.localeCompare(a) : a.localeCompare(b); +} + export class CqlTestCaseResourceTreeItem extends vscode.TreeItem { constructor(public readonly resource: CqlTestCaseResource) { super(resource.name, vscode.TreeItemCollapsibleState.None); @@ -88,8 +140,8 @@ export class CqlTestCaseRootTreeItem extends vscode.TreeItem { public resetChildren(): void { this._children.length = 0; - this.cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); - this.cqlLibrary.TestCases.forEach(tc => this.addTestCase(tc)); + const testCases = this.cqlLibrary.TestCases.sort((a, b) => compareCmsNames(a.name, b.name, false)); + testCases.forEach(tc => this.addTestCase(tc)); } public get children(): vscode.TreeItem[] { @@ -174,8 +226,8 @@ export class CqlLibraryRootTreeItem extends vscode.TreeItem { this.cqlLibraryTreeItem = new CqlLibraryTreeItem(cqlLibrary); this._children.push(this.cqlLibraryTreeItem); - cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); - cqlLibrary.TestCases.forEach(tc => this.addTestCase(tc)); + const testCases = cqlLibrary.TestCases.sort((a, b) => compareCmsNames(a.name, b.name, false)); + testCases.forEach(tc => this.addTestCase(tc)); if (cqlLibrary.resultUris.length > 0) { this._children.push(new CqlResultsRootTreeItem(cqlLibrary.resultUris)); @@ -219,8 +271,8 @@ export class CqlLibraryRootTreeItem extends vscode.TreeItem { } this._cqlTestCaseRootTreeItem = undefined; - this.cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); - this.cqlLibrary.TestCases.forEach(tc => this.addTestCase(tc)); + const testCases = this.cqlLibrary.TestCases.sort((a, b) => compareCmsNames(a.name, b.name, false)); + testCases.forEach(tc => this.addTestCase(tc)); if (this.cqlLibrary.resultUris.length > 0) { this._children.push(new CqlResultsRootTreeItem(this.cqlLibrary.resultUris)); @@ -564,9 +616,7 @@ export class CqlProjectTreeDataProvider implements vscode.TreeDataProvider { - if (this.sortDescending) { - this.sortRootItemsInPlace(); - } + this.sortRootItemsInPlace(); this.refresh(); }); @@ -592,9 +642,7 @@ export class CqlProjectTreeDataProvider implements vscode.TreeDataProvider - this.sortDescending - ? b.cqlLibrary.name.localeCompare(a.cqlLibrary.name) - : a.cqlLibrary.name.localeCompare(b.cqlLibrary.name), + compareCmsNames(a.cqlLibrary.name, b.cqlLibrary.name, this.sortDescending), ); } else { // Multi-project: full rebuild is acceptable (rare case) @@ -808,9 +856,7 @@ export function buildTree( !hideEmpty || lib.testCaseLoadState !== 'loaded' || lib.TestCases.length > 0, ) .filter(lib => filter === '' || lib.name.toLowerCase().includes(filter)) - .sort((a, b) => - sortDescending ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name), - ) + .sort((a, b) => compareCmsNames(a.name, b.name, sortDescending)) .map( lib => new CqlLibraryRootTreeItem( diff --git a/src/cql-language-server/cqlLanguageClient.ts b/src/cql-language-server/cqlLanguageClient.ts index 8e6667f..1bb8b43 100644 --- a/src/cql-language-server/cqlLanguageClient.ts +++ b/src/cql-language-server/cqlLanguageClient.ts @@ -19,6 +19,7 @@ import { ActionableNotification, ExecuteClientCommandRequest, ProgressReportNotification, + VersionInfo, } from '../protocol'; import { statusBar } from '../statusBar'; import { prepareExecutable } from './languageServerStarter'; @@ -131,6 +132,13 @@ export class CqlLanguageClient { // TODO: Set this once we have the initialization signal from the LS. statusBar.setReady(version); + + // Fetch and display all component versions in the status bar tooltip. + sendRequest(Commands.GET_VERSION_INFO, []).then((vi: VersionInfo) => { + statusBar.setReady(version, vi); + }).catch(() => { + // Non-fatal; status bar already shows LS version from JAR filename. + }); }); //this.registerCommands(context); diff --git a/src/cql-service/cqlService.executeCql.ts b/src/cql-service/cqlService.executeCql.ts index 9f7e771..762c747 100644 --- a/src/cql-service/cqlService.executeCql.ts +++ b/src/cql-service/cqlService.executeCql.ts @@ -7,10 +7,12 @@ import { CqlParametersConfig } from '../model/parameters'; import { resolveParameters } from '../helpers/parametersHelper'; import { extractLibraryVersion } from '../helpers/fileHelper'; import { TestCase } from '../model/testCase'; +import { VersionInfo } from '../protocol'; export interface ExecuteCqlResponse { results: LibraryResult[]; logs: string[]; + versions?: VersionInfo; } export interface LibraryResult { diff --git a/src/cql-service/cqlService.getElm.ts b/src/cql-service/cqlService.getElm.ts index f3449d7..b777625 100644 --- a/src/cql-service/cqlService.getElm.ts +++ b/src/cql-service/cqlService.getElm.ts @@ -2,6 +2,6 @@ import { Uri } from 'vscode'; import { Commands } from '../commands/commands'; import { sendRequest } from '../cql-language-server/cqlLanguageClient'; -export async function getElm(cqlFileUri: Uri, elmType: 'xml' | 'json'): Promise { +export async function getElm(cqlFileUri: Uri, elmType: 'xml' | 'json' | 'ast'): Promise { return await sendRequest(Commands.VIEW_ELM, [cqlFileUri.toString(), elmType]); } diff --git a/src/debug/cqlAstDebugViewController.ts b/src/debug/cqlAstDebugViewController.ts new file mode 100644 index 0000000..96b72d6 --- /dev/null +++ b/src/debug/cqlAstDebugViewController.ts @@ -0,0 +1,334 @@ +import { + commands, + debug, + DecorationOptions, + Disposable, + ExtensionContext, + Position, + Range, + Selection, + TextEditorRevealType, + ThemeColor, + ThemeIcon, + Uri, + window, + workspace, +} from 'vscode'; +import { CqlSpan, normalizeSpan } from './types'; +import { CqlAstNode, CqlAstIndex, parseAstToTree } from './cqlAstTreeNode'; +import { CqlAstTreeDataProvider } from './cqlAstTreeDataProvider'; +import { fetchAstViaDap } from './debugAstFetcher'; +import { hasFullCoordinates } from '../utils/astIndex'; +import * as log from '../log-services/logger'; + +let _instance: CqlAstDebugViewController | undefined; + +export function getControllerInstance(): CqlAstDebugViewController | undefined { + return _instance; +} + +export function activateController(context: ExtensionContext): CqlAstDebugViewController { + _instance?.dispose(); + _instance = new CqlAstDebugViewController(context); + return _instance; +} + +interface DapFrame { + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + instructionPointerReference?: string; + source?: { path?: string }; +} + +export class CqlAstDebugViewController implements Disposable { + private provider: CqlAstTreeDataProvider; + private treeView: ReturnType>; + private lineDecoration: ReturnType; + private spanDecoration: ReturnType; + private cqlIndex: CqlAstIndex; + private activeCqlPath: string | undefined; + private activeNodeId: string | undefined; + private disposables: Disposable[] = []; + + constructor(context: ExtensionContext) { + this.provider = new CqlAstTreeDataProvider(); + this.cqlIndex = { nodeById: new Map(), localIdToNodeId: new Map(), locatorToNodeId: new Map(), cqlLineToNodeIds: new Map() }; + this.activeCqlPath = undefined; + this.activeNodeId = undefined; + + this.treeView = window.createTreeView('cql.debug.ast', { + treeDataProvider: this.provider, + showCollapseAll: true, + canSelectMany: false, + }); + + this.lineDecoration = window.createTextEditorDecorationType({ + backgroundColor: new ThemeColor('editor.findMatchHighlightBackground'), + isWholeLine: true, + }); + + this.spanDecoration = window.createTextEditorDecorationType({ + backgroundColor: new ThemeColor('editor.findMatchBackground'), + border: '1px solid', + borderColor: new ThemeColor('editor.findMatchBorder'), + }); + + const revealCmd = commands.registerCommand('cql.debug.ast.reveal-cql', this.onRevealCql, this); + context.subscriptions.push(revealCmd); + this.disposables.push(revealCmd); + + this.disposables.push( + this.treeView.onDidChangeVisibility(e => { + if (e.visible) this.onViewVisible(); + }), + ); + + const startSession = debug.onDidStartDebugSession(s => { + if (s.type !== 'cql') return; + commands.executeCommand('setContext', 'cql.debug.active', true); + this.provider.setLoading(); + }); + context.subscriptions.push(startSession); + this.disposables.push(startSession); + + const endSession = debug.onDidTerminateDebugSession(s => { + if (s.type !== 'cql') return; + this.onSessionEnd(); + }); + context.subscriptions.push(endSession); + this.disposables.push(endSession); + + this.maybeAttachToExistingSession(); + } + + async onFrameStopped( + frame: DapFrame, + _session: import('vscode').DebugSession, + ): Promise { + const span = normalizeSpan(frame); + const sourcePath = frame.source?.path; + + if (!sourcePath || !sourcePath.toLowerCase().endsWith('.cql')) { + log.debug('cqlAstVC: non-CQL source path={}', sourcePath); + return; + } + + if (sourcePath !== this.activeCqlPath) { + try { + await this.loadAst(sourcePath); + this.activeCqlPath = sourcePath; + } catch (e) { + log.debug('cqlAstVC: loadAst failed for {} err={}', sourcePath, e); + this.provider.setEmpty(`Could not load AST for ${sourcePath}`); + return; + } + } + + this.resolveAndReveal(span); + await this.applyCqlDecoration(span); + } + + dispose(): void { + this.treeView.dispose(); + this.lineDecoration.dispose(); + this.spanDecoration.dispose(); + for (const d of this.disposables) { + try { d.dispose(); } catch { /* noop */ } + } + this.provider.setEmpty(); + } + + private async loadAst(cqlPath: string): Promise { + const uri = Uri.file(cqlPath); + log.debug('cqlAstVC: fetching AST for {}', cqlPath); + const astContent = await fetchAstViaDap(uri, 'ast'); + const { roots, index } = parseAstToTree(astContent); + this.cqlIndex = index; + this.provider.setData(roots); + log.debug('cqlAstVC: AST loaded with {} root nodes', roots.length); + } + + private resolveAndReveal(span: CqlSpan): void { + const nodeId = this.resolveSpanToNodeId(span); + if (!nodeId) { + log.debug('cqlAstVC: no nodeId resolved for span={}', span); + return; + } + + const node = this.cqlIndex.nodeById.get(nodeId); + if (!node) { + log.debug('cqlAstVC: node not found for id={}', nodeId); + return; + } + + this.activeNodeId = nodeId; + this.provider.setActiveNodeId(nodeId); + try { + this.treeView.reveal(node, { expand: true, select: true, focus: false }); + } catch (e) { + log.debug('cqlAstVC: reveal failed for id={} err={}', nodeId, e); + } + } + + private resolveSpanToNodeId(span: CqlSpan): string | undefined { + if (span.localId) { + const id = this.cqlIndex.localIdToNodeId.get(span.localId); + if (id) { + log.debug('cqlAstVC: resolved via localId={} -> id={}', span.localId, id); + return id; + } + } + + if (hasFullCoordinates(span)) { + const key = `${span.line}:${span.column}-${span.endLine}:${span.endColumn}`; + const id = this.cqlIndex.locatorToNodeId.get(key); + if (id) { + log.debug('cqlAstVC: resolved via locator key={} -> id={}', key, id); + return id; + } + } + + const ids = this.cqlIndex.cqlLineToNodeIds.get(span.line - 1); + if (ids && ids.length > 0) { + log.debug('cqlAstVC: resolved via cqlLine={} -> id={}', span.line - 1, ids[0]); + return ids[0]; + } + + log.debug('cqlAstVC: no resolution for span={}', span); + return undefined; + } + + private async applyCqlDecoration(span: CqlSpan): Promise { + const cqlPath = this.activeCqlPath; + if (!cqlPath) return; + + let editor = window.visibleTextEditors.find( + e => e.document.uri.fsPath === cqlPath, + ); + + if (!editor) { + try { + const doc = await workspace.openTextDocument(Uri.file(cqlPath)); + editor = await window.showTextDocument(doc, { preserveFocus: true, preview: false }); + } catch (e) { + log.debug('cqlAstVC: could not open CQL editor for {} err={}', cqlPath, e); + return; + } + } + + const start = new Position(span.line - 1, span.column - 1); + const end = new Position(span.endLine - 1, span.endColumn); + const vsRange = new Range(start, end); + + editor.setDecorations(this.lineDecoration, [ + new Range(span.line - 1, 0, span.endLine - 1, 0), + ]); + editor.setDecorations(this.spanDecoration, [ + { range: vsRange } as DecorationOptions, + ]); + editor.revealRange(vsRange, TextEditorRevealType.InCenterIfOutsideViewport); + editor.selection = new Selection(start, end); + } + + private clearCqlDecoration(): void { + for (const editor of window.visibleTextEditors) { + editor.setDecorations(this.lineDecoration, []); + editor.setDecorations(this.spanDecoration, []); + } + } + + private async onRevealCql(node: CqlAstNode): Promise { + if (!node.loc) { + log.debug('cqlAstVC: onRevealCql: node has no loc id={}', node.id); + return; + } + + const cqlPath = this.activeCqlPath; + if (!cqlPath) { + log.debug('cqlAstVC: onRevealCql: no activeCqlPath'); + return; + } + + let editor = window.visibleTextEditors.find( + e => e.document.uri.fsPath === cqlPath, + ); + + if (!editor) { + try { + const doc = await workspace.openTextDocument(Uri.file(cqlPath)); + editor = await window.showTextDocument(doc, { preserveFocus: true, preview: false }); + } catch (e) { + log.debug('cqlAstVC: onRevealCql: could not open editor err={}', e); + return; + } + } + + const start = new Position(node.loc.startLine - 1, node.loc.startCol - 1); + const end = new Position(node.loc.endLine - 1, node.loc.endCol); + const vsRange = new Range(start, end); + + editor.setDecorations(this.lineDecoration, [ + new Range(node.loc.startLine - 1, 0, node.loc.endLine - 1, 0), + ]); + editor.setDecorations(this.spanDecoration, [ + { range: vsRange } as DecorationOptions, + ]); + editor.revealRange(vsRange, TextEditorRevealType.InCenterIfOutsideViewport); + editor.selection = new Selection(start, end); + + this.provider.setActiveNodeId(node.id); + this.activeNodeId = node.id; + } + + private onViewVisible(): void { + if (this.activeNodeId && this.provider.roots.length > 0) { + const node = this.cqlIndex.nodeById.get(this.activeNodeId); + if (node) { + try { + this.treeView.reveal(node, { expand: true, select: true, focus: false }); + } catch (e) { + log.debug('cqlAstVC: onViewVisible reveal failed err={}', e); + } + } + } + } + + private onSessionEnd(): void { + commands.executeCommand('setContext', 'cql.debug.active', false); + this.activeCqlPath = undefined; + this.activeNodeId = undefined; + this.cqlIndex = { nodeById: new Map(), localIdToNodeId: new Map(), locatorToNodeId: new Map(), cqlLineToNodeIds: new Map() }; + this.provider.setEmpty(); + this.clearCqlDecoration(); + } + + private async maybeAttachToExistingSession(): Promise { + const session = debug.activeDebugSession; + if (!session || session.type !== 'cql') { + return; + } + + commands.executeCommand('setContext', 'cql.debug.active', true); + this.provider.setLoading(); + + try { + const resp = await session.customRequest('stackTrace', { + threadId: 1, + startFrame: 0, + levels: 1, + }); + const top = resp?.stackFrames?.[0]; + if (top && typeof top.line === 'number') { + log.debug('cqlAstVC: mid-session attach: detected frame at line={}', top.line); + await this.onFrameStopped(top, session); + } else { + this.provider.setEmpty('No active frame'); + } + } catch (e) { + log.debug('cqlAstVC: mid-session attach: stackTrace failed err={}', e); + this.provider.setEmpty('Debug session active but no frame available'); + } + } +} diff --git a/src/debug/cqlAstTreeDataProvider.ts b/src/debug/cqlAstTreeDataProvider.ts new file mode 100644 index 0000000..78ead12 --- /dev/null +++ b/src/debug/cqlAstTreeDataProvider.ts @@ -0,0 +1,118 @@ +import { + EventEmitter, + TreeDataProvider, + TreeItem, + TreeItemCollapsibleState, + ThemeIcon, + ThemeColor, + window, +} from 'vscode'; +import { CqlAstNode, buildParentMap } from './cqlAstTreeNode'; + +export type ViewState = 'loading' | 'empty' | 'data'; +const PLACEHOLDER_LOADING = '__loading__'; +const PLACEHOLDER_EMPTY = '__empty__'; + +export class CqlAstTreeDataProvider implements TreeDataProvider { + private _roots: CqlAstNode[] = []; + private _activeNodeId: string | undefined; + private _parentMap = new Map(); + private _viewState: ViewState = 'empty'; + private _emptyMessage: string = 'No active frame'; + private _onDidChangeTreeData = new EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + get roots(): CqlAstNode[] { return this._roots; } + get activeNodeId(): string | undefined { return this._activeNodeId; } + + setData(roots: CqlAstNode[]): void { + this._roots = roots; + this._viewState = roots.length > 0 ? 'data' : 'empty'; + this._parentMap = buildParentMap(roots); + this._onDidChangeTreeData.fire(undefined); + } + + setActiveNodeId(id: string | undefined): void { + this._activeNodeId = id; + this._onDidChangeTreeData.fire(undefined); + } + + setLoading(): void { + this._roots = []; + this._activeNodeId = undefined; + this._viewState = 'loading'; + this._parentMap.clear(); + this._onDidChangeTreeData.fire(undefined); + } + + setEmpty(message?: string): void { + this._roots = []; + this._activeNodeId = undefined; + this._viewState = 'empty'; + this._emptyMessage = message ?? 'No active frame'; + this._parentMap.clear(); + this._onDidChangeTreeData.fire(undefined); + } + + getChildren(element?: CqlAstNode): CqlAstNode[] { + if (!element) { + if (this._viewState === 'loading') { + return [this.makePlaceholder(PLACEHOLDER_LOADING, 'Loading AST...')]; + } + if (this._viewState === 'empty' || this._roots.length === 0) { + return [this.makePlaceholder(PLACEHOLDER_EMPTY, this._emptyMessage)]; + } + return this._roots; + } + if (element.id === PLACEHOLDER_LOADING || element.id === PLACEHOLDER_EMPTY) { + return []; + } + return element.children; + } + + getParent(element: CqlAstNode): CqlAstNode | undefined { + return this._parentMap.get(element.id); + } + + getTreeItem(element: CqlAstNode): TreeItem { + const isPlaceholder = element.id === PLACEHOLDER_LOADING || element.id === PLACEHOLDER_EMPTY; + if (isPlaceholder) { + const item = new TreeItem(element.label, TreeItemCollapsibleState.None); + item.id = element.id; + item.description = ''; + if (element.id === PLACEHOLDER_LOADING) { + item.iconPath = new ThemeIcon('loading~spin'); + } + return item; + } + + const isActive = element.id === this._activeNodeId; + const item = new TreeItem( + element.label, + element.children.length > 0 + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None, + ); + item.id = element.id; + item.description = element.description; + + if (isActive) { + item.iconPath = new ThemeIcon( + 'debug-stackframe', + new ThemeColor('debugIcon.breakpointForeground'), + ); + } + + item.command = { + command: 'cql.debug.ast.reveal-cql', + title: 'Reveal in CQL Editor', + arguments: [element], + }; + + return item; + } + + private makePlaceholder(id: string, label: string): CqlAstNode { + return { id, label, description: '', children: [] }; + } +} diff --git a/src/debug/cqlAstTreeNode.ts b/src/debug/cqlAstTreeNode.ts new file mode 100644 index 0000000..735a5ad --- /dev/null +++ b/src/debug/cqlAstTreeNode.ts @@ -0,0 +1,178 @@ +import { AstLoc } from '../utils/astIndex'; + +export interface CqlAstNode { + id: string; + label: string; + description: string; + loc?: AstLoc; + localId?: string; + children: CqlAstNode[]; +} + +export interface CqlAstIndex { + nodeById: Map; + localIdToNodeId: Map; + locatorToNodeId: Map; + cqlLineToNodeIds: Map; +} + +const LOC_IN_META_REGEX = /loc=(\d+):(\d+)(?:-(\d+):(\d+))?/; +const ID_IN_META_REGEX = /id=(\d+)/; + +function getDepth(line: string): number { + const idx = line.indexOf('\u2500\u2500'); + if (idx === -1) return 0; + return Math.floor(idx / 2) + 1; +} + +function extractLabel(line: string): string { + return line + .replace(/^[ \u2502\u251C\u2514]*\u2500\u2500\s*/, '') + .replace(/\s*\[.*?\]\s*$/, '') + .trim(); +} + +function parseMetadata(line: string): { loc?: AstLoc; localId?: string } { + const metaMatch = line.match(/\[(.*?)\]/); + if (!metaMatch) return {}; + + const meta = metaMatch[1]; + const locMatch = meta.match(LOC_IN_META_REGEX); + const idMatch = meta.match(ID_IN_META_REGEX); + + let loc: AstLoc | undefined; + if (locMatch) { + loc = { + startLine: parseInt(locMatch[1], 10), + startCol: parseInt(locMatch[2], 10), + endLine: locMatch[3] ? parseInt(locMatch[3], 10) : parseInt(locMatch[1], 10), + endCol: locMatch[4] ? parseInt(locMatch[4], 10) : parseInt(locMatch[2], 10), + }; + } + + return { + loc, + localId: idMatch ? idMatch[1] : undefined, + }; +} + +function computeStableId(localId: string | undefined, path: number[]): string { + if (localId) return `lid:${localId}`; + return `path:${path.join('/')}`; +} + +function assignPathIds(nodes: CqlAstNode[], prefix: number[]): void { + for (let i = 0; i < nodes.length; i++) { + const path = [...prefix, i]; + nodes[i].id = computeStableId(nodes[i].localId, path); + assignPathIds(nodes[i].children, path); + } +} + +function buildLocatorKey( + line: number, col: number, endLine: number, endCol: number, +): string { + return `${line}:${col}-${endLine}:${endCol}`; +} + +function indexNode( + node: CqlAstNode, + nodeById: Map, + localIdToNodeId: Map, + locatorToNodeId: Map, + cqlLineToNodeIds: Map, +): void { + nodeById.set(node.id, node); + + if (node.localId) { + localIdToNodeId.set(node.localId, node.id); + } + + if (node.loc) { + const key = buildLocatorKey( + node.loc.startLine, node.loc.startCol, + node.loc.endLine, node.loc.endCol, + ); + locatorToNodeId.set(key, node.id); + + for (let line = node.loc.startLine; line <= node.loc.endLine; line++) { + const cqlIndex = line - 1; + const existing = cqlLineToNodeIds.get(cqlIndex) ?? []; + existing.push(node.id); + cqlLineToNodeIds.set(cqlIndex, existing); + } + } + + for (const child of node.children) { + indexNode(child, nodeById, localIdToNodeId, locatorToNodeId, cqlLineToNodeIds); + } +} + +export function buildNodeIndex(roots: CqlAstNode[]): CqlAstIndex { + const nodeById = new Map(); + const localIdToNodeId = new Map(); + const locatorToNodeId = new Map(); + const cqlLineToNodeIds = new Map(); + + for (const root of roots) { + indexNode(root, nodeById, localIdToNodeId, locatorToNodeId, cqlLineToNodeIds); + } + + return { nodeById, localIdToNodeId, locatorToNodeId, cqlLineToNodeIds }; +} + +export function buildParentMap(roots: CqlAstNode[]): Map { + const map = new Map(); + function walk(nodes: CqlAstNode[], parent?: CqlAstNode) { + for (const node of nodes) { + if (parent) map.set(node.id, parent); + walk(node.children, node); + } + } + walk(roots); + return map; +} + +export function parseAstToTree(astContent: string): { roots: CqlAstNode[]; index: CqlAstIndex } { + const lines = astContent.split('\n'); + if (lines.length === 0) return { roots: [], index: buildNodeIndex([]) }; + + const roots: CqlAstNode[] = []; + const stack: { node: CqlAstNode; depth: number }[] = []; + + for (const line of lines) { + if (!line.trim()) continue; + + const depth = getDepth(line); + const label = extractLabel(line); + const { loc, localId } = parseMetadata(line); + + const node: CqlAstNode = { + id: '', + label, + description: loc + ? `${loc.startLine}:${loc.startCol}-${loc.endLine}:${loc.endCol}` + : '', + loc, + localId, + children: [], + }; + + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].node.children.push(node); + } + + stack.push({ node, depth }); + } + + assignPathIds(roots, []); + const index = buildNodeIndex(roots); + + return { roots, index }; +} diff --git a/src/debug/cqlDebugAdapterDescriptorFactory.ts b/src/debug/cqlDebugAdapterDescriptorFactory.ts new file mode 100644 index 0000000..c161ec2 --- /dev/null +++ b/src/debug/cqlDebugAdapterDescriptorFactory.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; +import { LanguageClient, ExecuteCommandRequest } from 'vscode-languageclient/node'; + +const START_DEBUG_SESSION_CMD = 'org.opencds.cqf.cql.debug.startDebugSession'; + +export class CqlDebugAdapterDescriptorFactory + implements vscode.DebugAdapterDescriptorFactory +{ + constructor(private readonly client: LanguageClient) {} + + async createDebugAdapterDescriptor( + _session: vscode.DebugSession, + ): Promise { + try { + const port = await this.client.sendRequest(ExecuteCommandRequest.type, { + command: START_DEBUG_SESSION_CMD, + arguments: [], + }); + return new vscode.DebugAdapterServer(port as number); + } catch (e: any) { + const msg = e instanceof Error ? e.message : String(e); + vscode.window.showErrorMessage(`CQL debug session failed to start: ${msg}`); + throw e; + } + } +} diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts new file mode 100644 index 0000000..bc99d64 --- /dev/null +++ b/src/debug/cqlDebugAstTracker.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; +import * as log from '../log-services/logger'; +import { getControllerInstance } from './cqlAstDebugViewController'; + +let lastStoppedThreadId: number | undefined; + +export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFactory { + createDebugAdapterTracker(session: vscode.DebugSession): vscode.DebugAdapterTracker { + return { + onDidSendMessage: async (message: any) => { + if (!message || typeof message !== 'object') return; + + if (message.type === 'event' && message.event === 'terminated') { + log.debug('DAP terminated event received'); + return; + } + if (message.type === 'event' && message.event === 'exited') { + log.debug('DAP exited event received', { exitCode: message.body?.exitCode }); + return; + } + + if (message.type === 'event' && message.event === 'stopped') { + lastStoppedThreadId = message.body?.threadId; + log.debug('DAP stopped event received threadId={}', lastStoppedThreadId); + await syncFromStackTrace(session, lastStoppedThreadId); + return; + } + + if ( + message.type === 'response' && + message.command === 'stackTrace' && + message.success && + Array.isArray(message.body?.stackFrames) && + message.body.stackFrames.length > 0 + ) { + await applyTopFrame(message.body.stackFrames[0], session); + } + }, + }; + } +} + +async function syncFromStackTrace( + session: vscode.DebugSession, + threadId: number | undefined, +): Promise { + if (threadId === undefined) return; + try { + const resp = await session.customRequest('stackTrace', { + threadId, + startFrame: 0, + levels: 1, + }); + const top = resp?.stackFrames?.[0]; + log.debug('syncFromStackTrace: threadId={} topFrame={}', threadId, top ? { + line: top.line, column: top.column, source: top.source?.path, localId: top.instructionPointerReference, + } : null); + if (top) await applyTopFrame(top, session); + } catch (e) { + log.debug('syncFromStackTrace: error threadId={} err={}', threadId, e); + } +} + +async function applyTopFrame( + frame: { + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + instructionPointerReference?: string; + source?: { path?: string }; + }, + session: vscode.DebugSession, +): Promise { + const controller = getControllerInstance(); + if (!controller) { + log.debug('applyTopFrame: no controller instance'); + return; + } + if (typeof frame.line !== 'number') { + log.debug('applyTopFrame: no line in frame'); + return; + } + + const sourcePath = frame.source?.path; + if (sourcePath && !sourcePath.toLowerCase().endsWith('.cql')) { + log.debug('applyTopFrame: non-CQL source path={}', sourcePath); + return; + } + + log.debug('applyTopFrame: sourcePath={} line={}', sourcePath, frame.line); + await controller.onFrameStopped(frame, session); +} diff --git a/src/debug/cqlEvaluatableExpressionProvider.ts b/src/debug/cqlEvaluatableExpressionProvider.ts new file mode 100644 index 0000000..0b8bed2 --- /dev/null +++ b/src/debug/cqlEvaluatableExpressionProvider.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; + +/** + * Provides evaluatable expressions for CQL during debugging. + * + * VS Code sends `evaluate` requests with `context: "hover"` during a debug session. + * The default expression extraction uses word boundaries, which breaks multi-word + * CQL identifiers and dotted paths (`Patient.name` becomes just `Patient` or `name`). + * + * For define references (quoted identifiers like `"Patient Age 12..."`), the + * expression sent is the identifier text so the DAP server can look it up by name. + * For sub-expressions, the expression is `@line:col` so the DAP server matches by + * source position. + */ +export class CqlEvaluatableExpressionProvider + implements vscode.EvaluatableExpressionProvider +{ + provideEvaluatableExpression( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.ProviderResult { + const lineNumber = position.line; + const lineText = document.lineAt(lineNumber).text; + const col = position.character; + + const quoted = findQuotedIdentifierRange(lineText, lineNumber, col); + if (quoted) { + return new vscode.EvaluatableExpression( + quoted, + extractQuotedText(lineText, quoted), + ); + } + + const wordRange = + document.getWordRangeAtPosition(position) ?? + new vscode.Range(position, position); + return new vscode.EvaluatableExpression( + wordRange, + `@${position.line}:${position.character}`, + ); + } +} + +/** + * Returns the text between the surrounding double quotes of a quoted-identifier + * range. The range is assumed to start at `"` and end just past the closing `"`. + */ +function extractQuotedText(lineText: string, range: vscode.Range): string { + const start = range.start.character + 1; // skip opening quote + const end = range.end.character - 1; // skip closing quote + return lineText.slice(start, end); +} + +/** + * If the cursor is within a double-quoted string on the line, returns a range + * covering the full quoted text (including the quotes). Otherwise returns null. + * + * Boundary behavior: + * On opening " → Returns full quoted range + * On letter inside str → Returns full quoted range + * On closing " → Returns full quoted range + * Before opening " → Returns null (falls through to word range) + * After closing " → Returns null (falls through to word range) + */ +export function findQuotedIdentifierRange( + lineText: string, + lineNumber: number, + col: number, +): vscode.Range | null { + let openQuote = -1; + for (let i = 0; i < lineText.length; i++) { + if (lineText[i] === '"') { + if (openQuote === -1) { + openQuote = i; + } else { + if (col >= openQuote && col <= i) { + return new vscode.Range( + new vscode.Position(lineNumber, openQuote), + new vscode.Position(lineNumber, i + 1), + ); + } + openQuote = -1; + } + } + } + if (openQuote !== -1 && col >= openQuote) { + return new vscode.Range( + new vscode.Position(lineNumber, openQuote), + new vscode.Position(lineNumber, lineText.length), + ); + } + return null; +} diff --git a/src/debug/debugAstFetcher.ts b/src/debug/debugAstFetcher.ts new file mode 100644 index 0000000..ea6d90a --- /dev/null +++ b/src/debug/debugAstFetcher.ts @@ -0,0 +1,23 @@ +import { debug, Uri } from 'vscode'; +import * as log from '../log-services/logger'; +import { getElm } from '../cql-service/cqlService.getElm'; + +export async function fetchAstViaDap( + cqlFileUri: Uri, + _type: 'ast', +): Promise { + const session = debug.activeDebugSession; + if (!session || session.type !== 'cql') throw new Error('No active CQL debug session'); + try { + const resp = await session.customRequest('getAst', { uri: cqlFileUri.toString() }); + if (resp?.ast) { + log.debug('fetchAstViaDap: DAP getAst succeeded for {} ({} chars)', cqlFileUri.toString(), resp.ast.length); + return resp.ast; + } + log.debug('fetchAstViaDap: DAP getAst returned no ast for {}', cqlFileUri.toString()); + } catch (e) { + log.debug('fetchAstViaDap: DAP getAst failed for {} err={}', cqlFileUri.toString(), e); + } + log.debug('fetchAstViaDap: falling back to getElm for {}', cqlFileUri.toString()); + return getElm(cqlFileUri, 'ast'); +} diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts new file mode 100644 index 0000000..b7b6813 --- /dev/null +++ b/src/debug/stepGranularityToggle.ts @@ -0,0 +1,53 @@ +import * as vscode from 'vscode'; +import * as log from '../log-services/logger'; + +type Granularity = 'cql' | 'ast'; +const sessionGranularity = new WeakMap(); + +let statusItem: vscode.StatusBarItem | undefined; + +export function activateStepGranularityToggle(context: vscode.ExtensionContext) { + statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusItem.command = 'cql.debug.toggle-step-granularity'; + context.subscriptions.push(statusItem); + + context.subscriptions.push( + vscode.debug.onDidStartDebugSession((s) => { + if (s.type !== 'cql') return; + const initial: Granularity = (s.configuration.stepGranularity as Granularity) ?? 'cql'; + sessionGranularity.set(s, initial); + refresh(s); + log.debug('onDidStartDebugSession fired', {sessionGranularity: s}); + }), + vscode.debug.onDidTerminateDebugSession(() => { + log.debug('onDidTerminateDebugSession fired'); + refresh(vscode.debug.activeDebugSession); + }), + vscode.debug.onDidChangeActiveDebugSession((s) => refresh(s)), + vscode.commands.registerCommand('cql.debug.toggle-step-granularity', async () => { + const s = vscode.debug.activeDebugSession; + if (!s || s.type !== 'cql') return; + const current = sessionGranularity.get(s) ?? 'cql'; + const next: Granularity = current === 'cql' ? 'ast' : 'cql'; + await s.customRequest('setStepGranularity', { granularity: next }); + sessionGranularity.set(s, next); + refresh(s); + if (next === 'ast') { + try { + await vscode.commands.executeCommand('cql.debug.ast.focus'); + } catch (e) { + log.debug('toggle-step-granularity: could not focus AST view err={}', e); + } + } + }), + ); +} + +function refresh(s: vscode.DebugSession | undefined) { + if (!statusItem) return; + if (!s || s.type !== 'cql') { statusItem.hide(); return; } + const g = sessionGranularity.get(s) ?? 'cql'; + statusItem.text = `Step: ${g.toUpperCase()}`; + statusItem.tooltip = 'Click to toggle CQL ↔ AST stepping'; + statusItem.show(); +} diff --git a/src/debug/types.ts b/src/debug/types.ts new file mode 100644 index 0000000..c94a22e --- /dev/null +++ b/src/debug/types.ts @@ -0,0 +1,25 @@ +export interface CqlSpan { + line: number; + column: number; + endLine: number; + endColumn: number; + localId?: string; +} + +export function normalizeSpan(frame: { + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + instructionPointerReference?: string; +}): CqlSpan { + const line = frame.line ?? 1; + const column = frame.column ?? 1; + return { + line, + column, + endLine: frame.endLine ?? line, + endColumn: frame.endColumn ?? column, + ...(frame.instructionPointerReference ? { localId: frame.instructionPointerReference } : {}), + }; +} diff --git a/src/executeCql.ts b/src/executeCql.ts deleted file mode 100644 index f7dfacf..0000000 --- a/src/executeCql.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { glob } from 'glob'; -import { Position, TextEditor, Uri, commands, window, workspace } from 'vscode'; -import { Utils } from 'vscode-uri'; -import { Commands } from './commands/commands'; - -import * as fs from 'fs'; -import * as fse from 'fs-extra'; -import path from 'path'; -import { logger } from './extensionLogger'; - -// NOTE: This is not the intended future state of executing CQL. -// There's a CQL debug server under development that will replace this. -export async function executeCQLFile(uri: Uri, testCases?: TestCase[]): Promise { - if (!fs.existsSync(uri.fsPath)) { - window.showInformationMessage('No library content found. Please save before executing.'); - return; - } - - const libraryDirectory = Utils.dirname(uri); - const libraryName = Utils.basename(uri).replace('.cql', '').split('-')[0]; - const projectPath = workspace.getWorkspaceFolder(uri)!.uri; - - // todo: make this a setting - let terminologyPath: Uri = Utils.resolvePath(projectPath, 'input', 'vocabulary', 'valueset'); - - let fhirVersion = getFhirVersion(); - if (!fhirVersion) { - fhirVersion = 'R4'; - window.showInformationMessage('Unable to determine version of FHIR used. Defaulting to R4.'); - } - - const rootDir = Utils.resolvePath(projectPath); - const optionsPath = Utils.resolvePath(libraryDirectory, 'cql-options.json'); - const measurementPeriod = ''; - const testPath = Utils.resolvePath(projectPath, 'input', 'tests'); - const resultPath = Utils.resolvePath(testPath, 'results'); - const outputPath = Utils.resolvePath(resultPath, `${libraryName}.txt`); - - fse.ensureFileSync(outputPath.fsPath); - - const textDocument = await workspace.openTextDocument(outputPath); - const textEditor = await window.showTextDocument(textDocument); - - var testCasesArgs: string[] = []; - var testPaths: TestCase[] = testCases ?? getTestPaths(testPath, libraryName); - - // We didn't find any test cases, so we'll just execute an empty one - if (testPaths.length === 0) { - testPaths.push({ name: null, path: null }); - } - - for (var p of testPaths) { - testCasesArgs.push( - ...getExecArgs( - libraryDirectory, - libraryName, - p.path, - terminologyPath, - p.name, - measurementPeriod, - ), - ); - } - - const cqlMessage = `CQL: ${libraryDirectory.fsPath}`; - const terminologyMessage = fs.existsSync(terminologyPath.fsPath) - ? `Terminology: ${terminologyPath.fsPath}` - : `No terminology found at ${terminologyPath.fsPath}. Evaluation may fail if terminology is required.`; - - let testMessage = ''; - if (testPaths.length == 1 && testPaths[0].name === null) { - testMessage = `No data found at ${testPath.fsPath}. Evaluation may fail if data is required.`; - } else { - testMessage = `Test cases:\n`; - for (var p of testPaths) { - testMessage += `${p.name} - ${p.path?.fsPath}\n`; - } - } - - await insertLineAtEnd(textEditor, `${cqlMessage}`); - await insertLineAtEnd(textEditor, `${terminologyMessage}`); - await insertLineAtEnd(textEditor, `${testMessage}`); - - let operationArgs = getCqlCommandArgs(fhirVersion, optionsPath, rootDir); - operationArgs.push(...testCasesArgs); - await executeCQL(textEditor, operationArgs); -} - -function getFhirVersion(): string | null { - try { - const fhirVersionRegex = /using (FHIR|"FHIR") version '(\d(.|\d)*)'/; - const matches = window.activeTextEditor!.document.getText().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'; - } - } - } catch (error) { - logger.error('error while getting FHIR version', error); - } - return null; -} - -export interface TestCase { - name: string | null; - path: Uri | null; -} - -/** - * Get the test cases to execute - * @param testPath the root path to look for test cases - * @returns a list of test cases to execute - */ -function getTestPaths(testPath: Uri, libraryName: string): TestCase[] { - if (!fs.existsSync(testPath.fsPath)) { - return []; - } - - let testCases: TestCase[] = []; - let directories = glob - .sync(testPath.fsPath + `/**/${libraryName}`) - .filter(d => fs.statSync(d).isDirectory()); - for (var dir of directories) { - let cases = fs.readdirSync(dir).filter(d => fs.statSync(path.join(dir, d)).isDirectory()); - for (var c of cases) { - testCases.push({ name: c, path: Uri.file(path.join(dir, c)) }); - } - } - - return testCases; -} - -async function insertLineAtEnd(textEditor: TextEditor, text: string) { - const document = textEditor.document; - await textEditor.edit(editBuilder => { - editBuilder.insert(new Position(textEditor.document.lineCount, 0), text + '\n'); - }); -} - -async function executeCQL(textEditor: TextEditor, operationArgs: string[]) { - const startExecution = Date.now(); - const result: string | undefined = await commands.executeCommand( - Commands.EXECUTE_WORKSPACE_COMMAND, - Commands.EXECUTE_CQL, - ...operationArgs, - ); - const endExecution = Date.now(); - - await insertLineAtEnd(textEditor, result!); - await insertLineAtEnd( - textEditor, - `elapsed: ${((endExecution - startExecution) / 1000).toString()} seconds`, - ); -} - -function getCqlCommandArgs(fhirVersion: string, optionsPath: Uri, rootDir: Uri): string[] { - const args = ['cql']; - - args.push(`-fv=${fhirVersion}`); - - if (optionsPath && fs.existsSync(optionsPath.fsPath)) { - args.push(`-op=${optionsPath}`); - } - - if (rootDir) { - args.push(`-rd=${rootDir}`); - } - - return args; -} - -function getExecArgs( - libraryDirectory: Uri, - libraryName: string, - modelPath: Uri | null, - terminologyPath: Uri | null, - contextValue: string | null, - 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=${libraryName}`); - args.push(`-lu=${libraryDirectory}`); - - if (modelPath) { - args.push(`-m=${modelType}`); - args.push(`-mu=${modelPath}`); - } - - if (terminologyPath) { - args.push(`-t=${terminologyPath}`); - } - - if (contextValue) { - args.push(`-c=${contextType}`); - args.push(`-cv=${contextValue}`); - } - - if (measurementPeriod && measurementPeriod !== '') { - args.push(`-p=${libraryName}."Measurement Period"`); - args.push(`-pv=${measurementPeriod}`); - } - - return args; -} diff --git a/src/extension.ts b/src/extension.ts index f6c994e..6778019 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { commands, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { commands, debug, ExtensionContext, languages, Uri, window, workspace } from 'vscode'; import { CloseAction, ErrorAction, @@ -9,10 +9,18 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient'; import { Commands } from './commands/commands'; -import { register as registerExecuteCql } from './commands/execute-cql'; +import { register as registerExecuteCqlFile } from './commands/execute-cql-file'; +import { register as registerSelectLibraries } from './commands/select-libraries'; +import { register as registerSelectTestCases } from './commands/select-test-cases'; +import { register as registerDebugTestCase } from './commands/debug-test-case'; import { register as registerLogCommands } from './commands/log-files'; import { register as registerViewElmCommand } from './commands/view-elm'; import { cqlLanguageClientInstance } from './cql-language-server/cqlLanguageClient'; +import { CqlDebugAdapterDescriptorFactory } from './debug/cqlDebugAdapterDescriptorFactory'; +import { CqlDebugAstTrackerFactory } from './debug/cqlDebugAstTracker'; +import { CqlEvaluatableExpressionProvider } from './debug/cqlEvaluatableExpressionProvider'; +import { activateStepGranularityToggle } from './debug/stepGranularityToggle'; +import { activateController } from './debug/cqlAstDebugViewController'; import { CqlExplorer } from './cql-explorer/cqlExplorer'; import { ClientStatus } from './extension.api'; import * as requirements from './java-support/requirements'; @@ -82,6 +90,17 @@ export function activate(context: ExtensionContext): Promise { new CqlExplorer(context); } + registerSelectLibraries(context); + registerExecuteCqlFile(context); + registerSelectTestCases(context); + registerDebugTestCase(context); + registerLogCommands(context); + registerViewElmCommand(context); + activateStepGranularityToggle(context); + const controller = activateController(context); + context.subscriptions.push(controller); + context.subscriptions.push(statusBar); + return requirements .resolveRequirements(context) .catch(error => { @@ -117,11 +136,6 @@ export function activate(context: ExtensionContext): Promise { outputChannelName: EXTENSION_NAME, }; - registerExecuteCql(context); - registerLogCommands(context); - registerViewElmCommand(context); - context.subscriptions.push(statusBar); - await startServer(context, requirements, clientOptions, workspacePath); }); } @@ -139,6 +153,17 @@ async function startServer( try { await cqlLanguageClientInstance.initialize(context, requirements, clientOptions, workspacePath); await cqlLanguageClientInstance.start(); + context.subscriptions.push( + debug.registerDebugAdapterDescriptorFactory( + 'cql', + new CqlDebugAdapterDescriptorFactory(cqlLanguageClientInstance.getClient()), + ), + debug.registerDebugAdapterTrackerFactory('cql', new CqlDebugAstTrackerFactory()), + languages.registerEvaluatableExpressionProvider( + { language: 'cql' }, + new CqlEvaluatableExpressionProvider(), + ), + ); statusBar.showStatusBar(); } catch (error) { log.error(`Failed to start server: ${error}`); diff --git a/src/helpers/cqlHelpers.ts b/src/helpers/cqlHelpers.ts new file mode 100644 index 0000000..54b5126 --- /dev/null +++ b/src/helpers/cqlHelpers.ts @@ -0,0 +1,168 @@ +import { glob } from 'glob'; +import { ParseError, parse as parseJsonc } from 'jsonc-parser'; +import * as fs from 'node:fs'; +import { ProgressLocation, Uri, window } from 'vscode'; +import { Utils } from 'vscode-uri'; +import { CqlLibrary, CqlProject, CqlProjectEvents } from '../model/cqlProject'; +import { CqlSolution } from '../model/cqlSolution'; +import { CqlParametersConfig } from '../model/parameters'; +import { TestCaseExclusion } from '../model/testCase'; +import * as log from '../log-services/logger'; +import { toGlobPath } from './fileHelper'; + +export interface CqlPaths { + libraryDirectoryPath: Uri; + projectDirectoryPath: Uri; + optionsPath: Uri; + resultDirectoryPath: Uri; + terminologyDirectoryPath: Uri; + testConfigPath: Uri; + testDirectoryPath: Uri; +} + +export interface TestConfig { + testCasesToExclude: TestCaseExclusion[]; + parameters?: CqlParametersConfig; + resultFormat: 'individual' | 'flat'; + flatResultsInSubfolder?: boolean; +} + +const DEFAULT_TEST_CONFIG: TestConfig = { testCasesToExclude: [], resultFormat: 'flat', flatResultsInSubfolder: false } + +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'); +} + +export function getCqlPaths(cqlFileUri: Uri): CqlPaths | undefined { + const project = CqlSolution.getCurrent().findProjectForUri(cqlFileUri); + if (!project) { + window.showErrorMessage('Unable to determine path to project root.'); + return; + } + const projectDirectoryPath = Uri.file(project.igRoot); + const libraryDirectoryPath = Utils.resolvePath(projectDirectoryPath, 'input', 'cql'); + const testDirectoryPath = Utils.resolvePath(projectDirectoryPath, 'input', 'tests'); + return { + projectDirectoryPath: projectDirectoryPath, + libraryDirectoryPath: libraryDirectoryPath, + optionsPath: Utils.resolvePath(libraryDirectoryPath, 'cql-options.json'), + resultDirectoryPath: Utils.resolvePath(testDirectoryPath, 'results'), + terminologyDirectoryPath: Utils.resolvePath( + projectDirectoryPath, + 'input', + 'vocabulary', + 'valueset', + ), + testConfigPath: resolveTestConfigPath(testDirectoryPath), + testDirectoryPath: testDirectoryPath, + }; +} + +export function getExcludedTestCases( + libraryName: string, + testCasesToExclude: TestCaseExclusion[], +): Map { + let excludedTestCases = new Map(); + for (let excludedTestCase of testCasesToExclude) { + if (excludedTestCase.library == libraryName) { + excludedTestCases.set(excludedTestCase.testCase, excludedTestCase.reason); + } + } + return excludedTestCases; +} + +export function getFhirVersion(cqlContent: string): string | null { + 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'; + } + + const qicoreMatch = cqlContent.match(/using\s+(?:QICore|"QICore")\s+version\s+'(\d[^']*)'/); + if (qicoreMatch) { + return qicoreMatch[1].startsWith('3') ? 'DSTU3' : 'R4'; + } + + if (/using\s+(?:USCore|"USCore")\s+version\s+'/.test(cqlContent)) { + return 'R4'; + } + + return null; +} + +export function getLibraries(libraryPath: Uri): Array { + if (!fs.existsSync(libraryPath.fsPath)) { + log.warn(`unable to find libraries @ ${libraryPath.fsPath}`); + return []; + } + return glob + .sync(`${toGlobPath(libraryPath.fsPath)}/**/*.cql`) + .filter(f => fs.statSync(f).isFile()) + .map(f => Uri.file(f)); +} + +export function loadTestConfig(testConfigPath: Uri): TestConfig { + if (!fs.existsSync(testConfigPath.fsPath)) { + log.info('No test config file found, using default settings', testConfigPath.fsPath); + return DEFAULT_TEST_CONFIG; + } + try { + const jsonString = fs.readFileSync(testConfigPath.fsPath, 'utf-8'); + const errors: ParseError[] = []; + const parsed = parseJsonc(jsonString, errors) as TestConfig; + if (errors.length > 0) { + log.error('Error parsing config file', errors); + return DEFAULT_TEST_CONFIG; + } + return { + ...parsed, + testCasesToExclude: parsed.testCasesToExclude ?? DEFAULT_TEST_CONFIG.testCasesToExclude, + resultFormat: parsed.resultFormat ?? DEFAULT_TEST_CONFIG.resultFormat, + flatResultsInSubfolder: parsed.flatResultsInSubfolder ?? DEFAULT_TEST_CONFIG.flatResultsInSubfolder, + }; + } catch (error) { + log.error('Error reading config file', error); + return { ...DEFAULT_TEST_CONFIG }; + } +} + +export async function waitForTestCasesLoaded( + library: CqlLibrary, + project: CqlProject, + showProgress: boolean, +): Promise { + if (library.testCaseLoadState === 'loaded') return; + + const waitPromise = new Promise(resolve => { + const handler = (loaded: CqlLibrary) => { + if (loaded === library) { + project.off(CqlProjectEvents.LIBRARY_TESTCASES_LOADED, handler); + resolve(); + } + }; + project.on(CqlProjectEvents.LIBRARY_TESTCASES_LOADED, handler); + if (library.testCaseLoadState === 'not-loaded') { + project.loadTestCasesForLibrary(library).catch(() => {}); + } + }); + + if (showProgress) { + await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Loading ${library.name}\u2026`, + cancellable: false, + }, + () => waitPromise, + ); + } else { + await waitPromise; + } +} diff --git a/src/java-support/javaServiceInstaller.ts b/src/java-support/javaServiceInstaller.ts index ba435ac..683730f 100644 --- a/src/java-support/javaServiceInstaller.ts +++ b/src/java-support/javaServiceInstaller.ts @@ -12,8 +12,8 @@ interface MavenCoords { type?: string; } -function getJarHome(): string { - return path.join(__dirname, 'jars'); +function getJarHome(extensionPath: string): string { + return path.join(extensionPath, 'dist', 'java-support', 'jars'); } export function getServicePath(context: ExtensionContext, serviceName: string): string { @@ -23,7 +23,7 @@ export function getServicePath(context: ExtensionContext, serviceName: string): throw Error(`Maven coordinates not found for ${serviceName}`); } - return getServicePathFromCoords(serviceCoords); + return getServicePathFromCoords(context.extensionPath, serviceCoords); } function getCoords(context: ExtensionContext): { @@ -36,22 +36,23 @@ function getCoords(context: ExtensionContext): { return javaDependencies as { [serviceName: string]: MavenCoords }; } -function getServicePathFromCoords(coords: MavenCoords): string { - const jarHome = getJarHome(); +function getServicePathFromCoords(extensionPath: string, coords: MavenCoords): string { + const jarHome = getJarHome(extensionPath); const jarName = getLocalName(coords); return path.join(jarHome, jarName); } export async function installJavaDependencies(context: ExtensionContext): Promise { const coords = getCoords(context); - await installJavaDependenciesFromCoords(coords); + await installJavaDependenciesFromCoords(context.extensionPath, coords); } -async function installJavaDependenciesFromCoords(coordsMaps: { - [serviceName: string]: MavenCoords; -}): Promise { +async function installJavaDependenciesFromCoords( + extensionPath: string, + coordsMaps: { [serviceName: string]: MavenCoords }, +): Promise { for (const [key, value] of Object.entries(coordsMaps)) { - await installServiceIfMissing(key, value); + await installServiceIfMissing(extensionPath, key, value); } } @@ -66,8 +67,12 @@ function getSearchUrl(coords: MavenCoords): string { return `https://repo1.maven.org/maven2/${groupIdAsDirectory}/${coords.artifactId}/${coords.version}/${getLocalName(coords)}`; } -async function installServiceIfMissing(serviceName: string, coords: MavenCoords): Promise { - const doesExist = isServiceInstalled(coords); +async function installServiceIfMissing( + extensionPath: string, + serviceName: string, + coords: MavenCoords, +): Promise { + const doesExist = isServiceInstalled(extensionPath, coords); if (!doesExist) { return window.withProgress( { @@ -76,14 +81,14 @@ async function installServiceIfMissing(serviceName: string, coords: MavenCoords) title: `Installing ${serviceName}`, }, async progress => { - await installJar(serviceName, coords, progress); + await installJar(extensionPath, serviceName, coords, progress); }, ); } } -function isServiceInstalled(coords: MavenCoords): boolean { - const jarPath = getServicePathFromCoords(coords); +function isServiceInstalled(extensionPath: string, coords: MavenCoords): boolean { + const jarPath = getServicePathFromCoords(extensionPath, coords); try { const stats = fs.lstatSync(jarPath); return stats != null; @@ -94,12 +99,13 @@ function isServiceInstalled(coords: MavenCoords): boolean { // Installs a jar using maven coordinates async function installJar( + extensionPath: string, serviceName: string, coords: MavenCoords, progress?: Progress<{ message?: string; increment?: number }>, ): Promise { - const jarPath = getServicePathFromCoords(coords); - const jarHome = getJarHome(); + const jarPath = getServicePathFromCoords(extensionPath, coords); + const jarHome = getJarHome(extensionPath); if (progress) { progress.report({ message: `Starting download` }); @@ -134,22 +140,33 @@ async function downloadFile( progress?: Progress<{ message?: string; increment?: number }>, ) { const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to download ${url}: ${res.statusText} (${res.status})`); + } const fileStream = fs.createWriteStream(path); let bytesSoFar = 0; - await new Promise((resolve, reject) => { - res.body.pipe(fileStream); - res.body.on('data', chunk => { - if (progress) { - bytesSoFar += chunk.length; - progress.report({ - message: `Downloading`, - increment: bytesSoFar / totalBytes!, - }); - } + try { + await new Promise((resolve, reject) => { + res.body.pipe(fileStream); + res.body.on('data', chunk => { + if (progress) { + bytesSoFar += chunk.length; + progress.report({ + message: `Downloading`, + increment: bytesSoFar / totalBytes!, + }); + } + }); + res.body.on('error', reject); + fileStream.on('error', reject); + fileStream.on('finish', resolve); }); - res.body.on('error', reject); - fileStream.on('finish', resolve); - }); + } catch (e) { + if (fs.existsSync(path)) { + fs.unlinkSync(path); + } + throw e; + } } async function setupDownload(serviceName: string, url: string) { diff --git a/src/java-support/requirements.ts b/src/java-support/requirements.ts index 780d7e4..0f7df40 100644 --- a/src/java-support/requirements.ts +++ b/src/java-support/requirements.ts @@ -35,57 +35,51 @@ export interface CqlLsInfo { export async function resolveJavaRequirements( _context: ExtensionContext, ): Promise { - return new Promise(async (resolve, reject) => { - let source: string; - let javaVersion: number | undefined; + let source: string; + let javaVersion: number | undefined; - let javaHome: string | undefined; - // java.home not specified, search valid JDKs from env.JAVA_HOME, env.PATH, Registry(Window), Common directories - const javaRuntimes = await findJavaHomes(); - const validJdks = javaRuntimes.filter(r => r.version >= REQUIRED_JDK_VERSION); - if (validJdks.length > 0) { - sortJdksBySource(validJdks); - javaHome = validJdks[0].home; - javaVersion = validJdks[0].version; - } - if (javaHome) { - // java.home explicitly specified - source = `java.home variable defined in ${env.appName} settings`; - javaHome = expandTilde(javaHome); - if (!(await fse.pathExists(javaHome!))) { - rejectWithMessage( - reject, - `The ${source} points to a missing or inaccessible folder (${javaHome})`, - ); - } else if (!(await fse.pathExists(path.resolve(javaHome!, 'bin', JAVAC_FILENAME)))) { - let msg: string; - if (await fse.pathExists(path.resolve(javaHome!, JAVAC_FILENAME))) { - msg = `'bin' should be removed from the ${source} (${javaHome})`; - } else { - msg = `The ${source} (${javaHome}) does not point to a JDK.`; - } - rejectWithMessage(reject, msg); + let javaHome: string | undefined; + // java.home not specified, search valid JDKs from env.JAVA_HOME, env.PATH, Registry(Window), Common directories + const javaRuntimes = await findJavaHomes(); + const validJdks = javaRuntimes.filter(r => r.version >= REQUIRED_JDK_VERSION); + if (validJdks.length > 0) { + sortJdksBySource(validJdks); + javaHome = validJdks[0].home; + javaVersion = validJdks[0].version; + } + if (javaHome) { + // java.home explicitly specified + source = `java.home variable defined in ${env.appName} settings`; + javaHome = expandTilde(javaHome); + if (!(await fse.pathExists(javaHome!))) { + throw createRejectWithMessage( + `The ${source} points to a missing or inaccessible folder (${javaHome})`, + ); + } else if (!(await fse.pathExists(path.resolve(javaHome!, 'bin', JAVAC_FILENAME)))) { + let msg: string; + if (await fse.pathExists(path.resolve(javaHome!, JAVAC_FILENAME))) { + msg = `'bin' should be removed from the ${source} (${javaHome})`; + } else { + msg = `The ${source} (${javaHome}) does not point to a JDK.`; } - javaVersion = await getJavaVersion(javaHome!); + throw createRejectWithMessage(msg); } + javaVersion = await getJavaVersion(javaHome!); + } - if (javaVersion! < REQUIRED_JDK_VERSION) { - openJDKDownload( - reject, - `Java ${REQUIRED_JDK_VERSION} or more recent is required to run the Java extension. Please download and install a recent JDK.`, - ); - } + if (javaVersion === undefined || javaVersion < REQUIRED_JDK_VERSION) { + throw createOpenJDKDownload( + `Java ${REQUIRED_JDK_VERSION} or more recent is required to run the Java extension. Please download and install a recent JDK.`, + ); + } - resolve({ java_home: javaHome!, java_version: javaVersion! }); - }); + return { java_home: javaHome!, java_version: javaVersion! }; } export async function resolveJavaDependencies(context: ExtensionContext): Promise { - return new Promise(async (resolve, reject) => { - await installJavaDependencies(context); - const cqlLsJar = getServicePath(context, 'cql-language-server'); - resolve({ cql_ls_jar: cqlLsJar }); - }); + await installJavaDependencies(context); + const cqlLsJar = getServicePath(context, 'cql-language-server'); + return { cql_ls_jar: cqlLsJar }; } export async function resolveRequirements(context: ExtensionContext): Promise { @@ -112,30 +106,17 @@ export function sortJdksBySource(jdks: JavaRuntime[]) { rankedJdks.sort((a, b) => a.rank - b.rank); } -function openJDKDownload( - reject: { - (reason?: any): void; - (arg0: { message: any; label: string; command: string; commandParam: Uri }): void; - }, - cause: string, -) { - reject({ +function createOpenJDKDownload(cause: string) { + return { message: cause, label: 'Get the Java Development Kit', command: Commands.OPEN_BROWSER, commandParam: Uri.parse(JDK_URL), - }); + }; } -function rejectWithMessage( - reject: { - (reason?: any): void; - (reason?: any): void; - (arg0: { message: string }): void; - }, - cause: string, -) { - reject({ +function createRejectWithMessage(cause: string) { + return { message: cause, - }); + }; } diff --git a/src/log-services/multi-transport-logger.ts b/src/log-services/multi-transport-logger.ts index d98fe0d..79cae8e 100644 --- a/src/log-services/multi-transport-logger.ts +++ b/src/log-services/multi-transport-logger.ts @@ -112,9 +112,9 @@ export class MultiTransportLogger implements LogOutputChannel { const lines = value.split('\n'); // Logback server stderr pattern: - // %-4relative [%thread] %-5level %logger{35} %msg %n - // e.g. "1234 [main] WARN org.opencds.SomeClass Some message" - const logbackPattern = /^\d+\s+\[\S+\]\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+/i; + // %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{35} %msg %n + // e.g. "2026-06-09 14:30:01.123 [main] WARN org.opencds.SomeClass Some message" + const logbackPattern = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+\[\S+\]\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+/i; // vscode-languageclient LSP trace pattern: "[Trace - HH:MM:SS AM] ..." // The header and its params body arrive in separate appendLine() calls, so diff --git a/src/model/cqlProject.ts b/src/model/cqlProject.ts index 6f3a234..45925d5 100644 --- a/src/model/cqlProject.ts +++ b/src/model/cqlProject.ts @@ -469,7 +469,7 @@ export class CqlProject extends EventEmitter { this.configureTestCaseResourceWatcher(); this.resultFolderWatcher = workspace.createFileSystemWatcher( - new RelativePattern(this.resultFolder, '*.txt'), + new RelativePattern(this.resultFolder, '**/*.txt'), ); this.configureResultFolderWatcher(); @@ -510,7 +510,7 @@ export class CqlProject extends EventEmitter { matched++; } } else if (entry.isDirectory()) { - // Individual format: {LibraryName}/TestCaseResult-{patientId}.json + // {LibraryName}/ — may contain flat .txt and/or individual TestCaseResult-*.json const lib = this.findLibraryByName(entry.name); if (lib) { try { @@ -519,7 +519,12 @@ export class CqlProject extends EventEmitter { { withFileTypes: true }, ); for (const sub of subEntries) { - if ( + if (sub.isFile() && sub.name.endsWith('.txt')) { + lib.addResult( + Uri.file(path.join(this.resultFolder, entry.name, sub.name)), + ); + matched++; + } else if ( sub.isFile() && sub.name.startsWith('TestCaseResult-') && sub.name.endsWith('.json') diff --git a/src/protocol.ts b/src/protocol.ts index 210f136..cdbfed1 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -47,6 +47,13 @@ export interface ActionableMessage { data?: any; commands?: Command[]; } + +export interface VersionInfo { + translator?: string; + engine?: string; + clinicalReasoning?: string; + languageServer?: string; +} export namespace StatusNotification { export const type = new NotificationType('language/status'); } diff --git a/src/statusBar.ts b/src/statusBar.ts index d74d2e8..eb6baa6 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -1,5 +1,6 @@ import { MarkdownString, StatusBarAlignment, StatusBarItem, window } from 'vscode'; import { Disposable } from 'vscode-languageclient'; +import { VersionInfo } from './protocol'; class StatusBar implements Disposable { private statusBarItem: StatusBarItem; @@ -28,10 +29,15 @@ class StatusBar implements Disposable { this.statusBarItem.tooltip = StatusTooltip.Error; } - public setReady(version?: string): void { + public setReady(version?: string, componentVersions?: VersionInfo): void { this.statusBarItem.text = StatusIcon.Ready; const tooltip = new MarkdownString(StatusTooltip.Ready); - if (version) { + if (componentVersions) { + if (componentVersions.translator) tooltip.appendMarkdown(`\n\nTranslator: ${componentVersions.translator}`); + if (componentVersions.engine) tooltip.appendMarkdown(`\n\nEngine: ${componentVersions.engine}`); + if (componentVersions.clinicalReasoning) tooltip.appendMarkdown(`\n\nClinical Reasoning: ${componentVersions.clinicalReasoning}`); + if (componentVersions.languageServer) tooltip.appendMarkdown(`\n\nLanguage Server: ${componentVersions.languageServer}`); + } else if (version) { tooltip.appendMarkdown(`\n\nVersion: ${version}`); } this.statusBarItem.tooltip = tooltip; diff --git a/src/utils/astIndex.ts b/src/utils/astIndex.ts new file mode 100644 index 0000000..89b8ebd --- /dev/null +++ b/src/utils/astIndex.ts @@ -0,0 +1,142 @@ +const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; +const ID_REGEX = /\[id=(\d+)/; + +export function buildLocatorKey( + line: number, col: number, endLine: number, endCol: number, +): string { + return `${line}:${col}-${endLine}:${endCol}`; +} + +export interface AstLoc { + startLine: number; + startCol: number; + endLine: number; + endCol: number; +} + +export interface AstLineIndex { + astToCqlLoc: Map; + cqlToAstLines: Map; + locatorToAstLines: Map; + localIdToAstLines: Map; +} + +export function buildAstLineIndex(astContent: string): AstLineIndex { + const astToCqlLoc = new Map(); + const cqlToAstLines = new Map(); + const locatorToAstLines = new Map(); + const localIdToAstLines = new Map(); + + const lines = astContent.split('\n'); + for (let astLine = 0; astLine < lines.length; astLine++) { + const match = lines[astLine].match(LOC_REGEX); + if (match) { + const loc: AstLoc = { + startLine: parseInt(match[1], 10), + startCol: parseInt(match[2], 10), + endLine: match[3] ? parseInt(match[3], 10) : parseInt(match[1], 10), + endCol: match[4] ? parseInt(match[4], 10) : parseInt(match[2], 10), + }; + + astToCqlLoc.set(astLine, loc); + + for (let cqlLine = loc.startLine; cqlLine <= loc.endLine; cqlLine++) { + const cqlIndex = cqlLine - 1; + const existing = cqlToAstLines.get(cqlIndex) ?? []; + existing.push(astLine); + cqlToAstLines.set(cqlIndex, existing); + } + + const locKey = buildLocatorKey(loc.startLine, loc.startCol, loc.endLine, loc.endCol); + const existing2 = locatorToAstLines.get(locKey) ?? []; + existing2.push(astLine); + locatorToAstLines.set(locKey, existing2); + } + + const idMatch = lines[astLine].match(ID_REGEX); + if (idMatch) { + const id = idMatch[1]; + const existing3 = localIdToAstLines.get(id) ?? []; + existing3.push(astLine); + localIdToAstLines.set(id, existing3); + } + } + + return { astToCqlLoc, cqlToAstLines, locatorToAstLines, localIdToAstLines }; +} + +export function sortAstBySourceOrder(astContent: string): string { + const lines = astContent.split('\n'); + if (lines.length <= 1) return astContent; + + const rootLine = lines[0]; + + interface AstSegment { + lines: string[]; + cqlLine: number; + } + + const segments: AstSegment[] = []; + let current: string[] = []; + + function commitSegment(segLines: string[]) { + const first = segLines[0]; + const m = first.match(LOC_REGEX); + segments.push({ + lines: segLines, + cqlLine: m ? parseInt(m[1], 10) : -1, + }); + } + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const isTopLevel = /^[├└]──/.test(line); + if (isTopLevel && current.length > 0) { + commitSegment(current); + current = []; + } + current.push(line); + } + if (current.length > 0) commitSegment(current); + + const sortableSegs = segments.filter(s => s.cqlLine > 0); + const otherSegs = segments.filter(s => s.cqlLine <= 0); + sortableSegs.sort((a, b) => a.cqlLine - b.cqlLine); + const ordered = [...otherSegs, ...sortableSegs]; + + const result: string[] = [rootLine]; + for (let i = 0; i < ordered.length; i++) { + const connector = i === ordered.length - 1 ? '└──' : '├──'; + ordered[i].lines.forEach((l, idx) => { + result.push(idx === 0 ? l.replace(/^[├└]──/, connector) : l); + }); + } + + return result.join('\n'); +} + +export function findNearestForwardLoc( + lineIndex: AstLineIndex, + astLine: number, +): AstLoc | undefined { + const exact = lineIndex.astToCqlLoc.get(astLine); + if (exact) return exact; + + const sortedLines = [...lineIndex.astToCqlLoc.keys()].sort((a, b) => a - b); + const next = sortedLines.find(l => l > astLine); + return next !== undefined ? lineIndex.astToCqlLoc.get(next) : undefined; +} + +export function hasFullCoordinates( + span: { line: number; column: number; endLine: number; endColumn: number }, +): boolean { + return span.endLine !== span.line || span.endColumn !== span.column; +} + +export function formatJson(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} diff --git a/src/views/astSplitSession.ts b/src/views/astSplitSession.ts new file mode 100644 index 0000000..18d8fb3 --- /dev/null +++ b/src/views/astSplitSession.ts @@ -0,0 +1,287 @@ +import { + DecorationOptions, + OverviewRulerLane, + Position, + Range, + Selection, + TextEditor, + TextEditorDecorationType, + TextEditorRevealType, + ThemeColor, + Uri, + ViewColumn, + window, + workspace, +} from 'vscode'; +import { getElm } from '../cql-service/cqlService.getElm'; +import * as log from '../log-services/logger'; +import { + AstLineIndex, + buildAstLineIndex, + findNearestForwardLoc, + sortAstBySourceOrder, +} from '../utils/astIndex'; + +export class SplitViewSession { + private cqlUri: Uri; + private astEditor: TextEditor; + private cqlEditor: TextEditor; + private lineIndex: AstLineIndex; + private astDecoration: TextEditorDecorationType; + private cqlLineDecoration: TextEditorDecorationType; + private cqlSpanDecoration: TextEditorDecorationType; + private lastCqlRevealTime = 0; + private lastAstRevealTime = 0; + private disposed = false; + private visibleRangesListener!: { dispose(): void }; + private selectionListener!: { dispose(): void }; + private closeListener!: { dispose(): void }; + private docSaveListener!: { dispose(): void }; + private fetcher: (uri: Uri, type: 'ast') => Promise; + + private constructor( + cqlUri: Uri, + cqlEditor: TextEditor, + astEditor: TextEditor, + lineIndex: AstLineIndex, + astDecoration: TextEditorDecorationType, + cqlLineDecoration: TextEditorDecorationType, + cqlSpanDecoration: TextEditorDecorationType, + fetcher: (uri: Uri, type: 'ast') => Promise, + ) { + this.cqlUri = cqlUri; + this.cqlEditor = cqlEditor; + this.astEditor = astEditor; + this.lineIndex = lineIndex; + this.astDecoration = astDecoration; + this.cqlLineDecoration = cqlLineDecoration; + this.cqlSpanDecoration = cqlSpanDecoration; + this.fetcher = fetcher; + } + + static async create( + cqlUri: Uri, + fetcher: (uri: Uri, type: 'ast') => Promise = getElm, + ): Promise { + const astContent = await fetcher(cqlUri, 'ast'); + const sortedAst = sortAstBySourceOrder(astContent); + const lineIndex = buildAstLineIndex(sortedAst); + + const cqlEditor = await window.showTextDocument( + await workspace.openTextDocument(cqlUri), ViewColumn.One, + ); + + const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); + const astEditor = await window.showTextDocument(astDoc, ViewColumn.Two); + + const astDecoration = window.createTextEditorDecorationType({ + backgroundColor: new ThemeColor('editor.stackFrameHighlightBackground'), + overviewRulerColor: new ThemeColor('editorError.foreground'), + overviewRulerLane: OverviewRulerLane.Left, + isWholeLine: true, + }); + const cqlLineDecoration = window.createTextEditorDecorationType({ + backgroundColor: new ThemeColor('editor.findMatchHighlightBackground'), + overviewRulerColor: new ThemeColor('editor.findMatchHighlightBackground'), + overviewRulerLane: OverviewRulerLane.Center, + isWholeLine: true, + }); + const cqlSpanDecoration = window.createTextEditorDecorationType({ + backgroundColor: new ThemeColor('editor.findMatchBackground'), + border: '1px solid', + borderColor: new ThemeColor('editor.findMatchBorder'), + overviewRulerColor: new ThemeColor('editor.findMatchBackground'), + overviewRulerLane: OverviewRulerLane.Center, + isWholeLine: false, + }); + + const session = new SplitViewSession( + cqlUri, cqlEditor, astEditor, lineIndex, + astDecoration, cqlLineDecoration, cqlSpanDecoration, + fetcher, + ); + + session.setupSyncListeners(); + + return session; + } + + // ─── Lifecycle ─── + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.teardownListeners(); + this.cqlLineDecoration.dispose(); + this.cqlSpanDecoration.dispose(); + this.astDecoration.dispose(); + } + + // ─── Private sync helpers ─── + + private syncCqlToAst(cqlLine?: number): void { + const effectiveLine = cqlLine ?? this.cqlEditor.visibleRanges[0]?.start.line; + if (effectiveLine === undefined) return; + + const astLines = this.lineIndex.cqlToAstLines.get(effectiveLine); + if (!astLines || astLines.length === 0) { + this.astEditor.setDecorations(this.astDecoration, []); + return; + } + this.applyAstHighlight(astLines, this.astEditor, this.astDecoration); + } + + private syncAstToCql(astLine?: number): void { + const effectiveLine = astLine ?? this.astEditor.visibleRanges[0]?.start.line; + if (effectiveLine === undefined) return; + + const loc = findNearestForwardLoc(this.lineIndex, effectiveLine); + + if (!loc) { + this.cqlEditor.setDecorations(this.cqlLineDecoration, []); + this.cqlEditor.setDecorations(this.cqlSpanDecoration, []); + return; + } + + const start = new Position(loc.startLine - 1, loc.startCol - 1); + const end = new Position(loc.endLine - 1, loc.endCol); + const vsRange = new Range(start, end); + + const cqlVisibleRanges = this.cqlEditor.visibleRanges; + if (cqlVisibleRanges.length > 0) { + const cqlTop = cqlVisibleRanges[0].start.line; + const cqlBottom = cqlVisibleRanges[cqlVisibleRanges.length - 1].end.line; + if (start.line >= cqlTop && end.line <= cqlBottom) { + this.cqlEditor.selection = new Selection(start, end); + this.cqlEditor.setDecorations(this.cqlLineDecoration, [new Range(loc.startLine - 1, 0, loc.endLine - 1, 0)]); + this.cqlEditor.setDecorations(this.cqlSpanDecoration, [{ range: vsRange } as DecorationOptions]); + return; + } + } + + this.cqlEditor.revealRange(vsRange, TextEditorRevealType.InCenterIfOutsideViewport); + this.cqlEditor.selection = new Selection(start, end); + this.cqlEditor.setDecorations(this.cqlLineDecoration, [new Range(loc.startLine - 1, 0, loc.endLine - 1, 0)]); + this.cqlEditor.setDecorations(this.cqlSpanDecoration, [{ range: vsRange } as DecorationOptions]); + } + + private applyAstHighlight( + astLines: number[], + astEditor: TextEditor, + astDecoration: TextEditorDecorationType, + ): void { + if (astLines.length === 0) { + astEditor.setDecorations(astDecoration, []); + return; + } + + const targetAstLine = Math.min(...astLines); + const endAstLine = Math.max(...astLines); + + const astVisibleRanges = astEditor.visibleRanges; + if (astVisibleRanges.length > 0) { + const astTop = astVisibleRanges[0].start.line; + const astBottom = astVisibleRanges[astVisibleRanges.length - 1].end.line; + if (targetAstLine >= astTop && targetAstLine <= astBottom) { + astEditor.setDecorations( + astDecoration, + astLines.map(l => new Range(l, 0, l, 0)), + ); + return; + } + } + + astEditor.revealRange( + new Range(targetAstLine, 0, endAstLine, 0), + TextEditorRevealType.InCenterIfOutsideViewport, + ); + astEditor.setDecorations( + astDecoration, + astLines.map(l => new Range(l, 0, l, 0)), + ); + } + + // ─── Private lifecycle helpers ─── + + private setupSyncListeners(): void { + this.visibleRangesListener = window.onDidChangeTextEditorVisibleRanges(e => { + const now = performance.now(); + if (e.textEditor === this.cqlEditor) { + if (now - this.lastAstRevealTime < 100) return; + this.lastCqlRevealTime = now; + this.syncCqlToAst(); + } else if (e.textEditor === this.astEditor) { + if (now - this.lastCqlRevealTime < 100) return; + this.lastAstRevealTime = now; + this.syncAstToCql(); + } + }); + + this.selectionListener = window.onDidChangeTextEditorSelection(e => { + const now = performance.now(); + if (e.textEditor === this.cqlEditor) { + if (now - this.lastAstRevealTime < 100) return; + this.lastCqlRevealTime = now; + this.syncCqlToAst(e.selections[0].active.line); + } else if (e.textEditor === this.astEditor) { + if (now - this.lastCqlRevealTime < 100) return; + this.lastAstRevealTime = now; + this.syncAstToCql(e.selections[0].active.line); + } + }); + + this.docSaveListener = workspace.onDidSaveTextDocument(async saved => { + if (saved.uri.toString() !== this.cqlUri.toString()) return; + if (this.disposed) return; + try { + const newAst = await this.fetcher(this.cqlUri, 'ast'); + const newSorted = sortAstBySourceOrder(newAst); + this.lineIndex = buildAstLineIndex(newSorted); + await this.replaceDocumentContent(this.astEditor, newSorted); + } catch (error) { + log.debug(`Failed to refresh AST after save: ${error}`); + } + }); + + this.closeListener = window.onDidChangeVisibleTextEditors(editors => { + if (!editors.includes(this.cqlEditor) || !editors.includes(this.astEditor)) { + if (this.disposed) return; + this.dispose(); + } + }); + } + + private teardownListeners(): void { + this.visibleRangesListener?.dispose(); + this.selectionListener?.dispose(); + this.closeListener?.dispose(); + this.docSaveListener?.dispose(); + } + + private async replaceDocumentContent(editor: TextEditor, content: string): Promise { + const lastLine = editor.document.lineAt(editor.document.lineCount - 1); + const ok = (await editor.edit(edit => + edit.replace(new Range(0, 0, lastLine.lineNumber, lastLine.text.length), content), + )) ?? false; + log.debug('replaceDocumentContent: success={} uri={} ({} lines replaced)', + ok, editor.document.uri.toString(), content.split('\n').length); + return ok; + } + +} + +export class AstSplitSessionManager { + private static activeSession: SplitViewSession | undefined; + + static async createOrUpdateSession( + cqlUri: Uri, + fetcher?: (uri: Uri, type: 'ast') => Promise, + ): Promise { + AstSplitSessionManager.activeSession?.dispose(); + AstSplitSessionManager.activeSession = await SplitViewSession.create(cqlUri, fetcher); + } + + static clearSession(): void { + AstSplitSessionManager.activeSession = undefined; + } +} diff --git a/src/views/configEditorWebview.ts b/src/views/configEditorWebview.ts new file mode 100644 index 0000000..7fecd9b --- /dev/null +++ b/src/views/configEditorWebview.ts @@ -0,0 +1,507 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Disposable, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { Utils } from 'vscode-uri'; +import { loadTestConfig, resolveTestConfigPath, TestConfig } from '../helpers/cqlHelpers'; +import { CqlProject } from '../model/cqlProject'; +import * as log from '../log-services/logger'; + +export class ConfigEditorWebview { + private static readonly viewType = 'cql.configEditor'; + private static instances = new Map(); + + readonly panel: WebviewPanel; + private readonly project: CqlProject; + private readonly configPath: Uri; + private config: TestConfig; + private readonly disposables: Disposable[] = []; + + private constructor( + panel: WebviewPanel, + project: CqlProject, + configPath: Uri, + config: TestConfig, + ) { + this.panel = panel; + this.project = project; + this.configPath = configPath; + this.config = config; + panel.onDidDispose(() => this.dispose(), null, this.disposables); + panel.webview.onDidReceiveMessage( + msg => { this.handleMessage(msg).catch(err => log.error('handleMessage error', err)); }, + null, + this.disposables, + ); + } + + static createOrShow(project: CqlProject): void { + const existing = ConfigEditorWebview.instances.get(project.igRoot); + if (existing) { + existing.panel.reveal(); + return; + } + + const testDirectoryPath = Utils.resolvePath(Uri.file(project.igRoot), 'input', 'tests'); + const configPath = resolveTestConfigPath(testDirectoryPath); + const config = loadTestConfig(configPath); + + const panel = window.createWebviewPanel( + ConfigEditorWebview.viewType, + `Config: ${project.name}`, + ViewColumn.Active, + { enableScripts: true, retainContextWhenHidden: true }, + ); + + const editor = new ConfigEditorWebview(panel, project, configPath, config); + ConfigEditorWebview.instances.set(project.igRoot, editor); + editor.render(); + } + + private render(): void { + this.panel.webview.html = this.getHtml(); + } + + private getHtml(): string { + const isJsonc = this.configPath.fsPath.endsWith('.jsonc'); + const params = this.config.parameters ?? []; + const exclusions = this.config.testCasesToExclude; + + const exclusionRows = exclusions + .map( + (e, i) => ` + + + + + + `, + ) + .join(''); + + const paramTypes = [ + 'String', 'Integer', 'Decimal', 'Boolean', 'DateTime', 'Date', 'Time', + 'Interval', 'Interval', 'Quantity', + ]; + const typeOptions = (selected: string) => + paramTypes + .map(t => ``) + .join(''); + + const renderParamEntry = (p: { name: string; type: string; value: string }) => ` +
+ + + + +
`; + + const globalParams = params + .filter((e): e is { name: string; type: string; value: string } => !('library' in e)) + .map(renderParamEntry) + .join(''); + + const libBlocks = params + .filter((e): e is { library: string; version?: string; parameters?: any[]; testCases?: Record } => 'library' in e); + + const renderLibBlock = (b: typeof libBlocks[number], bi: number) => { + const libParams = (b.parameters ?? []).map(renderParamEntry).join(''); + const tcEntries = Object.entries(b.testCases ?? {}); + const tcBlocks = tcEntries + .map(([patientId, tcParams], ti) => ` +
+
+ + +
+
${tcParams.map(renderParamEntry).join('')}
+ +
`).join(''); + + return ` +
+
+ + + +
+
Library Parameters
+
${libParams}
+ +
Test Case Overrides
+
${tcBlocks}
+ +
`; + }; + + const libBlockHtml = libBlocks.map((b, i) => renderLibBlock(b, i)).join(''); + + return ` + + + + + + + + +

${this.esc(this.project.name)} Configuration

+ +${isJsonc ? '
⚠ Comments in config.jsonc will be removed on save.
' : ''} + +
+

Result Format

+ +
+ +
+
+ +
+ +
+

Test Case Exclusions

+ + + ${exclusionRows} +
LibraryPatient UUIDReason
+ +
+ +
+ +
+

Parameters

+ +

Global Parameters

+
${globalParams}
+ + +

Library-Scoped Parameters

+
${libBlockHtml}
+ +
+ +
+ + +
+ +