From 499ca07f06dc0a81e63af1fa7a34648d480f6da5 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 28 May 2026 18:27:49 -0600 Subject: [PATCH 01/38] bump version to 0.9.6 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4565290..fc97847 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", @@ -680,7 +680,7 @@ "cql-language-server": { "groupId": "org.opencds.cqf.cql.ls", "artifactId": "cql-ls-server", - "version": "4.7.0" + "version": "4.8.0" } }, "devDependencies": { From fe5f3a9fce0686a7071b7c76e709a940f6c1bc45 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Sun, 31 May 2026 08:23:59 -0600 Subject: [PATCH 02/38] add convert CQL to ELM as an AST --- language-configuration-ast.json | 10 ++ package.json | 44 ++++++++ src/__test__/suite/commands/view-elm.test.ts | 24 +++- src/__test__/suite/extension/commands.test.ts | 2 + src/commands/commands.ts | 1 + src/commands/view-elm.ts | 10 +- src/cql-explorer/cqlExplorer.ts | 9 ++ src/cql-service/cqlService.getElm.ts | 2 +- syntaxes/ast.tmLanguage.json | 105 ++++++++++++++++++ 9 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 language-configuration-ast.json create mode 100644 syntaxes/ast.tmLanguage.json 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.json b/package.json index fc97847..73426cb 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -87,6 +102,11 @@ "title": "View ELM - XML", "category": "CQL" }, + { + "command": "cql.editor.view-elm.ast", + "title": "View ELM - AST", + "category": "CQL" + }, { "command": "cql.execute.select-libraries", "title": "Execute CQL - Select Libraries", @@ -180,6 +200,12 @@ "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.execute", "title": "Execute Test Case", @@ -313,6 +339,10 @@ "command": "cql.editor.view-elm.xml", "when": "false" }, + { + "command": "cql.editor.view-elm.ast", + "when": "false" + }, { "command": "cql.explorer.refresh", "when": "false" @@ -329,6 +359,10 @@ "command": "cql.explorer.library.elm.xml", "when": "false" }, + { + "command": "cql.explorer.library.ast", + "when": "false" + }, { "command": "cql.explorer.test-case.execute", "when": "false" @@ -449,6 +483,11 @@ "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.execute", "when": "editorLangId == cql && cql.languageServerReady", @@ -476,6 +515,11 @@ "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", diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index af04112..93c904f 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; }; diff --git a/src/__test__/suite/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index 36a4758..116b7a6 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -7,6 +7,7 @@ const EXPECTED_COMMANDS = [ 'cql.editor.execute.select-test-cases', 'cql.editor.view-elm.xml', 'cql.editor.view-elm.json', + 'cql.editor.view-elm.ast', // Global commands 'cql.execute.select-libraries', 'cql.open.server-log', @@ -31,6 +32,7 @@ const EXPECTED_COMMANDS = [ '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', diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 3333836..8c7fd32 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -39,6 +39,7 @@ 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 = 'org.opencds.cqf.cql.ls.viewElm'; /* diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index 925eb04..bbd3669 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -11,19 +11,23 @@ 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'); + }), ); } 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 }); + const doc = await workspace.openTextDocument({ language: languageId, content: formatted }); await window.showTextDocument(doc); if (elmType === 'xml') { await commands.executeCommand('editor.action.formatDocument'); diff --git a/src/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index d8e3788..c9309c5 100644 --- a/src/cql-explorer/cqlExplorer.ts +++ b/src/cql-explorer/cqlExplorer.ts @@ -238,6 +238,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', 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/syntaxes/ast.tmLanguage.json b/syntaxes/ast.tmLanguage.json new file mode 100644 index 0000000..90f7e86 --- /dev/null +++ b/syntaxes/ast.tmLanguage.json @@ -0,0 +1,105 @@ +{ + "information_for_contributors": [ + "Initial AST highlighting grammar covering role labels, type names, strings, numbers, and metadata." + ], + "version": "0.0.0", + "name": "CQL ELM AST", + "scopeName": "source.ast", + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#treePrefix" + }, + { + "include": "#libraryDeclaration" + }, + { + "include": "#roleLabels" + }, + { + "include": "#attributes" + }, + { + "include": "#metadata" + }, + { + "include": "#strings" + }, + { + "include": "#numbers" + }, + { + "include": "#typeNames" + } + ], + "repository": { + "comments": { + "match": "//.*$", + "name": "comment.line.double-slash.ast" + }, + "treePrefix": { + "match": "^[│ ]*[├└]── ", + "name": "punctuation.separator.tree.ast" + }, + "libraryDeclaration": { + "match": "^Library\\b", + "name": "entity.name.type.library.ast" + }, + "roleLabels": { + "match": "(? Date: Sun, 31 May 2026 10:41:59 -0600 Subject: [PATCH 03/38] add cql/ast splitview and lock scrolling --- package.json | 14 + src/__test__/suite/commands/view-elm.test.ts | 222 ++++++++++++++- src/__test__/suite/extension/commands.test.ts | 1 + src/commands/commands.ts | 1 + src/commands/view-elm.ts | 252 +++++++++++++++++- 5 files changed, 488 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 73426cb..5e8e993 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,11 @@ "title": "View ELM - AST", "category": "CQL" }, + { + "command": "cql.editor.view-elm.ast.split", + "title": "View ELM - AST (Split)", + "category": "CQL" + }, { "command": "cql.execute.select-libraries", "title": "Execute CQL - Select Libraries", @@ -343,6 +348,10 @@ "command": "cql.editor.view-elm.ast", "when": "false" }, + { + "command": "cql.editor.view-elm.ast.split", + "when": "false" + }, { "command": "cql.explorer.refresh", "when": "false" @@ -488,6 +497,11 @@ "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", diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index 93c904f..a54a920 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { TextDocument, Uri, commands, window, workspace } from 'vscode'; -import { viewElm } from '../../../commands/view-elm'; +import { viewElm, buildAstLineIndex, sortAstBySourceOrder } from '../../../commands/view-elm'; const MOCK_JSON_ELM = JSON.stringify({ library: { @@ -136,3 +136,223 @@ suite('viewElm()', () => { expect(capturedUri?.fsPath).to.equal(cqlUri.fsPath); }); }); + +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('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" [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('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 ├── + expect(lines[lines.length - 3]).to.match(/^├── define/); + expect(lines[lines.length - 3]).to.include('"A"'); + expect(lines[lines.length - 2]).to.match(/^├── define/); + expect(lines[lines.length - 2]).to.include('"B"'); + }); + + 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/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index 116b7a6..86126e5 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -8,6 +8,7 @@ const EXPECTED_COMMANDS = [ 'cql.editor.view-elm.xml', 'cql.editor.view-elm.json', 'cql.editor.view-elm.ast', + 'cql.editor.view-elm.ast.split', // Global commands 'cql.execute.select-libraries', 'cql.open.server-log', diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 8c7fd32..e37da30 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -40,6 +40,7 @@ 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'; /* diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index bbd3669..db227bb 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -1,8 +1,34 @@ -import { commands, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { + commands, + DecorationOptions, + ExtensionContext, + Position, + Range, + Selection, + TextEditorRevealType, + Uri, + ViewColumn, + window, + workspace, +} from 'vscode'; import { getElm } from '../cql-service/cqlService.getElm'; import * as log from '../log-services/logger'; import { Commands } from './commands'; +const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; + +export interface AstLoc { + startLine: number; + startCol: number; + endLine: number; + endCol: number; +} + +export interface AstLineIndex { + astToCqlLoc: Map; + cqlToAstLines: Map; +} + export function register(context: ExtensionContext): void { context.subscriptions.push( commands.registerCommand(Commands.VIEW_ELM_COMMAND_XML, async (uri: Uri) => { @@ -14,6 +40,9 @@ export function register(context: ExtensionContext): void { commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST, async (uri: Uri) => { viewElm(uri, 'ast'); }), + commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST_SPLIT, async (uri: Uri) => { + await viewElmSplit(uri); + }), ); } @@ -37,6 +66,227 @@ export async function viewElm( } } +export function buildAstLineIndex(astContent: string): AstLineIndex { + const astToCqlLoc = new Map(); + const cqlToAstLines = new Map(); + + const lines = astContent.split('\n'); + for (let astLine = 0; astLine < lines.length; astLine++) { + const match = lines[astLine].match(LOC_REGEX); + if (!match) continue; + + 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); + } + } + + return { astToCqlLoc, cqlToAstLines }; +} + +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; + isDefine: boolean; + } + + 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, + isDefine: /define:/.test(first), + }); + } + + 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 defineSegs = segments.filter(s => s.isDefine && s.cqlLine > 0); + const otherSegs = segments.filter(s => !s.isDefine || s.cqlLine <= 0); + + defineSegs.sort((a, b) => a.cqlLine - b.cqlLine); + + const ordered = [...otherSegs, ...defineSegs]; + + 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'); +} + +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; +} + +async function viewElmSplit(cqlFileUri: Uri): Promise { + try { + const cqlDoc = await workspace.openTextDocument(cqlFileUri); + const astContent = await getElm(cqlFileUri, 'ast'); + const sortedAst = sortAstBySourceOrder(astContent); + const lineIndex = buildAstLineIndex(sortedAst); + + const cqlEditor = await window.showTextDocument(cqlDoc, ViewColumn.One); + const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); + const astEditor = await window.showTextDocument(astDoc, ViewColumn.Two); + + const cqlDecoration = window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 120, 212, 0.15)', + isWholeLine: false, + }); + const astDecoration = window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 120, 212, 0.15)', + isWholeLine: true, + }); + + let lastCqlRevealTime = 0; + let lastAstRevealTime = 0; + + function syncCqlToAst(cqlLine?: number): void { + const effectiveLine = cqlLine ?? cqlEditor.visibleRanges[0]?.start.line; + if (effectiveLine === undefined) return; + + const astLines = lineIndex.cqlToAstLines.get(effectiveLine); + if (!astLines || astLines.length === 0) 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.AtTop, + ); + astEditor.setDecorations( + astDecoration, + astLines.map(l => new Range(l, 0, l, 0)), + ); + } + + function syncAstToCql(astLine?: number): void { + const effectiveLine = astLine ?? astEditor.visibleRanges[0]?.start.line; + if (effectiveLine === undefined) return; + + const loc = findNearestForwardLoc(lineIndex, effectiveLine); + if (!loc) 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 = 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) { + cqlEditor.selection = new Selection(start, end); + cqlEditor.setDecorations(cqlDecoration, [{ range: vsRange } as DecorationOptions]); + return; + } + } + + cqlEditor.revealRange(vsRange, TextEditorRevealType.AtTop); + cqlEditor.selection = new Selection(start, end); + + cqlEditor.setDecorations(cqlDecoration, [{ range: vsRange } as DecorationOptions]); + } + + const visibleRangesListener = window.onDidChangeTextEditorVisibleRanges(e => { + const now = performance.now(); + if (e.textEditor === cqlEditor) { + if (now - lastAstRevealTime < 100) return; + lastCqlRevealTime = now; + syncCqlToAst(); + } else if (e.textEditor === astEditor) { + if (now - lastCqlRevealTime < 100) return; + lastAstRevealTime = now; + syncAstToCql(); + } + }); + + const selectionListener = window.onDidChangeTextEditorSelection(e => { + const now = performance.now(); + if (e.textEditor === cqlEditor) { + if (now - lastAstRevealTime < 100) return; + lastCqlRevealTime = now; + syncCqlToAst(e.selections[0].active.line); + } else if (e.textEditor === astEditor) { + if (now - lastCqlRevealTime < 100) return; + lastAstRevealTime = now; + syncAstToCql(e.selections[0].active.line); + } + }); + + const closeListener = window.onDidChangeVisibleTextEditors(editors => { + if (!editors.includes(cqlEditor) || !editors.includes(astEditor)) { + visibleRangesListener.dispose(); + selectionListener.dispose(); + closeListener.dispose(); + cqlDecoration.dispose(); + astDecoration.dispose(); + } + }); + } catch (error) { + window.showErrorMessage( + `Error opening CQL/AST split view for ${cqlFileUri.fsPath}. err: ${error}`, + ); + } +} + function formatJson(raw: string): string { try { return JSON.stringify(JSON.parse(raw), null, 2); From 83e9dd34f841e515e0e5d6e85b50a15826420990 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Sun, 31 May 2026 10:49:19 -0600 Subject: [PATCH 04/38] remove old execute cql (dead-code) --- src/commands/commands.ts | 5 - src/executeCql.ts | 215 --------------------------------------- 2 files changed, 220 deletions(-) delete mode 100644 src/executeCql.ts diff --git a/src/commands/commands.ts b/src/commands/commands.ts index e37da30..6577730 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 */ 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; -} From a13ca96783874dcf159339f1a75500c400fbe549 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 15 May 2026 10:44:21 -0400 Subject: [PATCH 05/38] adds cql deugging support --- package.json | 68 +++++++++++++++ .../simple-project/.vscode/launch.json | 7 ++ src/__test__/suite/extension/commands.test.ts | 1 + .../suite/extension/packageJson.test.ts | 2 + src/commands/commands.ts | 2 + src/commands/execute-cql.ts | 77 ++++++++++++++++- src/cql-explorer/cqlExplorer.ts | 83 ++++++++++++++++++- src/debug/cqlDebugAdapterDescriptorFactory.ts | 20 +++++ src/extension.ts | 9 +- 9 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 src/__test__/resources/simple-project/.vscode/launch.json create mode 100644 src/debug/cqlDebugAdapterDescriptorFactory.ts diff --git a/package.json b/package.json index 5e8e993..b6fabe9 100644 --- a/package.json +++ b/package.json @@ -92,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", @@ -211,6 +216,12 @@ "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", @@ -336,6 +347,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" @@ -372,6 +387,10 @@ "command": "cql.explorer.library.ast", "when": "false" }, + { + "command": "cql.explorer.test-case.debug", + "when": "false" + }, { "command": "cql.explorer.test-case.execute", "when": "false" @@ -511,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": [ @@ -539,6 +563,11 @@ "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", @@ -594,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/", @@ -716,6 +760,30 @@ } ] }, + "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" } + } + } + } + } + ], + "breakpoints": [ + { "language": "cql" } + ], "jsonValidation": [ { "fileMatch": "**/input/tests/config.jsonc", 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__/suite/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index 86126e5..08921c9 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -5,6 +5,7 @@ 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', 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/commands/commands.ts b/src/commands/commands.ts index 6577730..c00f2c4 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -47,6 +47,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/execute-cql.ts b/src/commands/execute-cql.ts index a834c4f..118457d 100644 --- a/src/commands/execute-cql.ts +++ b/src/commands/execute-cql.ts @@ -4,6 +4,7 @@ import { ParseError, parse as parseJsonc } from 'jsonc-parser'; import * as fs from 'node:fs'; import { commands, + debug, ExtensionContext, Position, ProgressLocation, @@ -92,6 +93,9 @@ export function register(context: ExtensionContext): void { commands.registerCommand(Commands.EXECUTE_CQL_COMMAND_SELECT_TEST_CASES, async (uri: Uri) => { selectTestCases(uri); }), + commands.registerCommand(Commands.DEBUG_TEST_CASE_COMMAND, async (uri: Uri) => { + debugTestCase(uri); + }), ); } @@ -238,6 +242,77 @@ export async function selectTestCases(cqlFileUri: Uri): Promise { } } +export async function debugTestCase(cqlFileUri: Uri): Promise { + const cqlPaths = getCqlPaths(cqlFileUri); + if (!cqlPaths) { + window.showErrorMessage('Unable to resolve needed CQL project paths.'); + return; + } + + const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; + + const allTestCases = getTestCases(cqlPaths.testDirectoryPath, libraryName, []); + const namedTestCases = allTestCases.filter( + (tc): tc is Required => tc.name !== undefined, + ); + + if (namedTestCases.length === 0) { + window.showInformationMessage('No test cases found.'); + return; + } + + const quickPickItems = namedTestCases.map(tc => ({ + label: tc.name, + testCase: tc, + })); + + const quickPick = window.createQuickPick<{ label: string; testCase: TestCase }>(); + quickPick.items = quickPickItems; + quickPick.canSelectMany = false; + quickPick.placeholder = 'Select a test case to debug'; + quickPick.title = `Debug — ${libraryName}`; + + const stateKey = `debugTestCase.selections.${libraryName}`; + const saved = _context?.workspaceState.get(stateKey); + if (saved) { + const match = quickPickItems.find(item => item.label === saved); + if (match) { + quickPick.selectedItems = [match]; + quickPick.activeItems = [match]; + } + } + + quickPick.show(); + + quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems[0]; + quickPick.hide(); + if (!selected) return; + + await _context?.workspaceState.update(stateKey, selected.label); + + const cqlSource = fs.readFileSync(cqlFileUri.fsPath, 'utf-8'); + const fhirVersion = getFhirVersion(cqlSource) ?? 'R4'; + + await debug.startDebugging( + workspace.getWorkspaceFolder(cqlFileUri), + { + type: 'cql', + request: 'launch', + name: `Debug ${libraryName} — ${selected.label}`, + libraryUri: cqlFileUri.toString(), + libraryName, + fhirVersion, + testCaseName: selected.label, + testCaseUri: selected.testCase.path!.toString(), + terminologyUri: cqlPaths.terminologyDirectoryPath?.toString(), + rootDir: cqlPaths.projectDirectoryPath?.toString(), + optionsPath: cqlPaths.optionsPath?.toString(), + }, + ); + }); +} + export async function executeCQLFile( cqlFileUri: Uri, testCases: Array | undefined = undefined, @@ -507,7 +582,7 @@ export function resolveTestConfigPath(testDirectoryPath: Uri): Uri { return Utils.resolvePath(testDirectoryPath, 'config.json'); } -function getCqlPaths(cqlFileUri: Uri): CqlPaths | undefined { +export function getCqlPaths(cqlFileUri: Uri): CqlPaths | undefined { const project = CqlSolution.getCurrent().findProjectForUri(cqlFileUri); if (!project) { window.showErrorMessage('Unable to determine path to project root.'); diff --git a/src/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index c9309c5..f46396a 100644 --- a/src/cql-explorer/cqlExplorer.ts +++ b/src/cql-explorer/cqlExplorer.ts @@ -1,7 +1,7 @@ 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, getCqlPaths, getFhirVersion } from '../commands/execute-cql'; import { viewElm } from '../commands/view-elm'; import { logger } from '../extensionLogger'; import { @@ -722,6 +722,87 @@ 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; + } + const libraryUri = library.uri; + const cqlPaths = getCqlPaths(libraryUri); + const cqlSource = await vscode.workspace.fs.readFile(libraryUri); + const fhirVersion = getFhirVersion(Buffer.from(cqlSource).toString()) ?? 'R4'; + await vscode.debug.startDebugging( + vscode.workspace.getWorkspaceFolder(libraryUri), + { + type: 'cql', + request: 'launch', + name: `Debug ${library.name} — ${item.cqlTestCase.name}`, + libraryUri: libraryUri.toString(), + libraryName: library.name, + fhirVersion, + testCaseName: item.cqlTestCase.name, + testCaseUri: item.cqlTestCase.uri.toString(), + terminologyUri: cqlPaths?.terminologyDirectoryPath?.toString(), + rootDir: cqlPaths?.projectDirectoryPath?.toString(), + optionsPath: cqlPaths?.optionsPath?.toString(), + }, + ); + return; + } + + const testCaseItems = item.children.filter( + (c): c is CqlTestCaseTreeItem => c instanceof CqlTestCaseTreeItem, + ); + if (testCaseItems.length === 0) { + vscode.window.showInformationMessage('No test cases found.'); + return; + } + + let selected: CqlTestCase; + if (testCaseItems.length === 1) { + selected = testCaseItems[0].cqlTestCase; + } else { + const pick = await vscode.window.showQuickPick( + testCaseItems.map(c => ({ label: c.cqlTestCase.name, testCase: c.cqlTestCase })), + { placeHolder: 'Select a test case to debug', title: `Debug — ${item.cqlLibrary.name}` }, + ); + if (!pick) return; + selected = pick.testCase; + } + + const libraryUri = item.cqlLibrary.uri; + const cqlPaths = getCqlPaths(libraryUri); + const cqlSource = await vscode.workspace.fs.readFile(libraryUri); + const fhirVersion = getFhirVersion(Buffer.from(cqlSource).toString()) ?? 'R4'; + + await vscode.debug.startDebugging( + vscode.workspace.getWorkspaceFolder(libraryUri), + { + type: 'cql', + request: 'launch', + name: `Debug ${item.cqlLibrary.name} — ${selected.name}`, + libraryUri: libraryUri.toString(), + libraryName: item.cqlLibrary.name, + fhirVersion, + testCaseName: selected.name, + testCaseUri: selected.uri.toString(), + terminologyUri: cqlPaths?.terminologyDirectoryPath?.toString(), + rootDir: cqlPaths?.projectDirectoryPath?.toString(), + optionsPath: cqlPaths?.optionsPath?.toString(), + }, + ); + }, + ), ); } diff --git a/src/debug/cqlDebugAdapterDescriptorFactory.ts b/src/debug/cqlDebugAdapterDescriptorFactory.ts new file mode 100644 index 0000000..5138aca --- /dev/null +++ b/src/debug/cqlDebugAdapterDescriptorFactory.ts @@ -0,0 +1,20 @@ +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 { + const port = await this.client.sendRequest(ExecuteCommandRequest.type, { + command: START_DEBUG_SESSION_CMD, + arguments: [], + }); + return new vscode.DebugAdapterServer(port as number); + } +} diff --git a/src/extension.ts b/src/extension.ts index f6c994e..771d69a 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, Uri, window, workspace } from 'vscode'; import { CloseAction, ErrorAction, @@ -13,6 +13,7 @@ import { register as registerExecuteCql } from './commands/execute-cql'; 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 { CqlExplorer } from './cql-explorer/cqlExplorer'; import { ClientStatus } from './extension.api'; import * as requirements from './java-support/requirements'; @@ -139,6 +140,12 @@ async function startServer( try { await cqlLanguageClientInstance.initialize(context, requirements, clientOptions, workspacePath); await cqlLanguageClientInstance.start(); + context.subscriptions.push( + debug.registerDebugAdapterDescriptorFactory( + 'cql', + new CqlDebugAdapterDescriptorFactory(cqlLanguageClientInstance.getClient()), + ), + ); statusBar.showStatusBar(); } catch (error) { log.error(`Failed to start server: ${error}`); From f637309c0b9bd1f8d9d6b01dd8f12f4134709208 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 18 May 2026 14:47:41 -0400 Subject: [PATCH 06/38] fixes issue with cql-ls jar getting deleted when running vscode-cql unit tests --- package.json | 2 +- scripts/clean-dist.js | 52 ++++++++++++++++++++++++ src/java-support/javaServiceInstaller.ts | 37 +++++++++++------ 3 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 scripts/clean-dist.js diff --git a/package.json b/package.json index b6fabe9..3ef6be9 100644 --- a/package.json +++ b/package.json @@ -798,7 +798,7 @@ "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", + "pretest": "node scripts/clean-dist.js && npm run compile", "test": "vscode-test", "changelog": "node scripts/update-changelog.js" }, 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/src/java-support/javaServiceInstaller.ts b/src/java-support/javaServiceInstaller.ts index ba435ac..8f09287 100644 --- a/src/java-support/javaServiceInstaller.ts +++ b/src/java-support/javaServiceInstaller.ts @@ -134,22 +134,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) { From ffe8b1ed779daed1c6c16b0ee5e1a2e633cf0ff5 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 19 May 2026 08:59:29 -0400 Subject: [PATCH 07/38] debug hover enhancements --- .../suite/commands/execute-cql-file.test.ts | 254 ++++++ .../cqlEvaluatableExpressionProvider.test.ts | 105 +++ src/__test__/suite/extension/commands.test.ts | 1 + .../cqlHelpers.test.ts} | 249 +----- src/commands/debug-test-case.ts | 111 +++ src/commands/execute-cql-file.ts | 310 ++++++++ src/commands/execute-cql.ts | 728 ------------------ src/commands/select-libraries.ts | 129 ++++ src/commands/select-test-cases.ts | 73 ++ src/cql-explorer/cqlExplorer.ts | 66 +- src/debug/cqlEvaluatableExpressionProvider.ts | 93 +++ src/extension.ts | 17 +- src/helpers/cqlHelpers.ts | 166 ++++ src/java-support/requirements.ts | 105 ++- 14 files changed, 1304 insertions(+), 1103 deletions(-) create mode 100644 src/__test__/suite/commands/execute-cql-file.test.ts create mode 100644 src/__test__/suite/debug/cqlEvaluatableExpressionProvider.test.ts rename src/__test__/suite/{commands/execute-cql.test.ts => helpers/cqlHelpers.test.ts} (51%) create mode 100644 src/commands/debug-test-case.ts create mode 100644 src/commands/execute-cql-file.ts delete mode 100644 src/commands/execute-cql.ts create mode 100644 src/commands/select-libraries.ts create mode 100644 src/commands/select-test-cases.ts create mode 100644 src/debug/cqlEvaluatableExpressionProvider.ts create mode 100644 src/helpers/cqlHelpers.ts 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..ea84c7b --- /dev/null +++ b/src/__test__/suite/commands/execute-cql-file.test.ts @@ -0,0 +1,254 @@ +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(''); + }); +}); + +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/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/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index 08921c9..6f6db36 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -46,6 +46,7 @@ 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', 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..c884313 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()', () => { @@ -249,77 +244,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 +286,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/commands/debug-test-case.ts b/src/commands/debug-test-case.ts new file mode 100644 index 0000000..27eae36 --- /dev/null +++ b/src/commands/debug-test-case.ts @@ -0,0 +1,111 @@ +import { commands, debug, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { Commands } from '../commands/commands'; +import { CqlLibrary, CqlTestCase } from '../model/cqlProject'; +import { CqlSolution } from '../model/cqlSolution'; +import { getCqlPaths, getFhirVersion, waitForTestCasesLoaded } from '../helpers/cqlHelpers'; + +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) => { + 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); + const raw = await workspace.fs.readFile(uri); + const cqlSource = Buffer.from(raw).toString(); + const fhirVersion = getFhirVersion(cqlSource) ?? 'R4'; + + 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(), + }, + ); +} + +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; + } + + if (testCases.length === 1) { + await startDebuggingForTestCase(library, testCases[0]); + return; + } + + const stateKey = `debugTestCase.selections.${library.name}`; + const saved = _context?.workspaceState.get(stateKey); + + const quickPick = window.createQuickPick(); + quickPick.items = testCases.map(tc => ({ label: tc.name })); + quickPick.canSelectMany = false; + quickPick.placeholder = 'Select a test case to debug'; + quickPick.title = `Debug — ${library.name}`; + + if (saved) { + const match = quickPick.items.find(item => item.label === saved); + if (match) { + quickPick.selectedItems = [match]; + quickPick.activeItems = [match]; + } + } + + const selectedTestCase = await new Promise(resolve => { + quickPick.onDidAccept(() => { + const selected = quickPick.selectedItems[0]; + quickPick.hide(); + if (selected) { + resolve(testCases.find(tc => tc.name === selected.label)); + } else { + resolve(undefined); + } + }); + quickPick.onDidHide(() => resolve(undefined)); + quickPick.show(); + }); + + if (!selectedTestCase) return; + await _context?.workspaceState.update(stateKey, selectedTestCase.name); + await startDebuggingForTestCase(library, selectedTestCase); +} diff --git a/src/commands/execute-cql-file.ts b/src/commands/execute-cql-file.ts new file mode 100644 index 0000000..37c2042 --- /dev/null +++ b/src/commands/execute-cql-file.ts @@ -0,0 +1,310 @@ +import * as fse from 'fs-extra'; +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 { CqlSolution } from '../model/cqlSolution'; +import { extractLibraryVersion } 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 } from '../model/testCase'; +import { + CqlPaths, + getCqlPaths, + getExcludedTestCases, + getFhirVersion, + loadTestConfig, + TestConfig, + waitForTestCasesLoaded, +} from '../helpers/cqlHelpers'; + +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[]; +} + +export function register(context: ExtensionContext): void { + context.subscriptions.push( + commands.registerCommand(Commands.EXECUTE_CQL_COMMAND, async (uri: Uri) => { + executeCQLFile(uri); + }), + ); +} + +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); + + 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: 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`); + 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); + 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 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 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)); + } +} + +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 }, + ); +} diff --git a/src/commands/execute-cql.ts b/src/commands/execute-cql.ts deleted file mode 100644 index 118457d..0000000 --- a/src/commands/execute-cql.ts +++ /dev/null @@ -1,728 +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, - debug, - 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); - }), - commands.registerCommand(Commands.DEBUG_TEST_CASE_COMMAND, async (uri: Uri) => { - debugTestCase(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 debugTestCase(cqlFileUri: Uri): Promise { - const cqlPaths = getCqlPaths(cqlFileUri); - if (!cqlPaths) { - window.showErrorMessage('Unable to resolve needed CQL project paths.'); - return; - } - - const libraryName = Utils.basename(cqlFileUri).replace('.cql', '').split('-')[0]; - - const allTestCases = getTestCases(cqlPaths.testDirectoryPath, libraryName, []); - const namedTestCases = allTestCases.filter( - (tc): tc is Required => tc.name !== undefined, - ); - - if (namedTestCases.length === 0) { - window.showInformationMessage('No test cases found.'); - return; - } - - const quickPickItems = namedTestCases.map(tc => ({ - label: tc.name, - testCase: tc, - })); - - const quickPick = window.createQuickPick<{ label: string; testCase: TestCase }>(); - quickPick.items = quickPickItems; - quickPick.canSelectMany = false; - quickPick.placeholder = 'Select a test case to debug'; - quickPick.title = `Debug — ${libraryName}`; - - const stateKey = `debugTestCase.selections.${libraryName}`; - const saved = _context?.workspaceState.get(stateKey); - if (saved) { - const match = quickPickItems.find(item => item.label === saved); - if (match) { - quickPick.selectedItems = [match]; - quickPick.activeItems = [match]; - } - } - - quickPick.show(); - - quickPick.onDidAccept(async () => { - const selected = quickPick.selectedItems[0]; - quickPick.hide(); - if (!selected) return; - - await _context?.workspaceState.update(stateKey, selected.label); - - const cqlSource = fs.readFileSync(cqlFileUri.fsPath, 'utf-8'); - const fhirVersion = getFhirVersion(cqlSource) ?? 'R4'; - - await debug.startDebugging( - workspace.getWorkspaceFolder(cqlFileUri), - { - type: 'cql', - request: 'launch', - name: `Debug ${libraryName} — ${selected.label}`, - libraryUri: cqlFileUri.toString(), - libraryName, - fhirVersion, - testCaseName: selected.label, - testCaseUri: selected.testCase.path!.toString(), - terminologyUri: cqlPaths.terminologyDirectoryPath?.toString(), - rootDir: cqlPaths.projectDirectoryPath?.toString(), - optionsPath: cqlPaths.optionsPath?.toString(), - }, - ); - }); -} - -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'); -} - -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 { - // 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/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index f46396a..d47e5e6 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, getCqlPaths, getFhirVersion } 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 { @@ -737,70 +738,11 @@ export class CqlExplorer { vscode.window.showErrorMessage('Unable to find parent library.'); return; } - const libraryUri = library.uri; - const cqlPaths = getCqlPaths(libraryUri); - const cqlSource = await vscode.workspace.fs.readFile(libraryUri); - const fhirVersion = getFhirVersion(Buffer.from(cqlSource).toString()) ?? 'R4'; - await vscode.debug.startDebugging( - vscode.workspace.getWorkspaceFolder(libraryUri), - { - type: 'cql', - request: 'launch', - name: `Debug ${library.name} — ${item.cqlTestCase.name}`, - libraryUri: libraryUri.toString(), - libraryName: library.name, - fhirVersion, - testCaseName: item.cqlTestCase.name, - testCaseUri: item.cqlTestCase.uri.toString(), - terminologyUri: cqlPaths?.terminologyDirectoryPath?.toString(), - rootDir: cqlPaths?.projectDirectoryPath?.toString(), - optionsPath: cqlPaths?.optionsPath?.toString(), - }, - ); - return; - } - - const testCaseItems = item.children.filter( - (c): c is CqlTestCaseTreeItem => c instanceof CqlTestCaseTreeItem, - ); - if (testCaseItems.length === 0) { - vscode.window.showInformationMessage('No test cases found.'); + await startDebuggingForTestCase(library, item.cqlTestCase); return; } - let selected: CqlTestCase; - if (testCaseItems.length === 1) { - selected = testCaseItems[0].cqlTestCase; - } else { - const pick = await vscode.window.showQuickPick( - testCaseItems.map(c => ({ label: c.cqlTestCase.name, testCase: c.cqlTestCase })), - { placeHolder: 'Select a test case to debug', title: `Debug — ${item.cqlLibrary.name}` }, - ); - if (!pick) return; - selected = pick.testCase; - } - - const libraryUri = item.cqlLibrary.uri; - const cqlPaths = getCqlPaths(libraryUri); - const cqlSource = await vscode.workspace.fs.readFile(libraryUri); - const fhirVersion = getFhirVersion(Buffer.from(cqlSource).toString()) ?? 'R4'; - - await vscode.debug.startDebugging( - vscode.workspace.getWorkspaceFolder(libraryUri), - { - type: 'cql', - request: 'launch', - name: `Debug ${item.cqlLibrary.name} — ${selected.name}`, - libraryUri: libraryUri.toString(), - libraryName: item.cqlLibrary.name, - fhirVersion, - testCaseName: selected.name, - testCaseUri: selected.uri.toString(), - terminologyUri: cqlPaths?.terminologyDirectoryPath?.toString(), - rootDir: cqlPaths?.projectDirectoryPath?.toString(), - optionsPath: cqlPaths?.optionsPath?.toString(), - }, - ); + await promptAndDebugTestCase(item.cqlLibrary); }, ), ); 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/extension.ts b/src/extension.ts index 771d69a..7b783c9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { commands, debug, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { commands, debug, ExtensionContext, languages, Uri, window, workspace } from 'vscode'; import { CloseAction, ErrorAction, @@ -9,11 +9,15 @@ 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 { CqlEvaluatableExpressionProvider } from './debug/cqlEvaluatableExpressionProvider'; import { CqlExplorer } from './cql-explorer/cqlExplorer'; import { ClientStatus } from './extension.api'; import * as requirements from './java-support/requirements'; @@ -118,7 +122,10 @@ export function activate(context: ExtensionContext): Promise { outputChannelName: EXTENSION_NAME, }; - registerExecuteCql(context); + registerSelectLibraries(context); + registerExecuteCqlFile(context); + registerSelectTestCases(context); + registerDebugTestCase(context); registerLogCommands(context); registerViewElmCommand(context); context.subscriptions.push(statusBar); @@ -145,6 +152,10 @@ async function startServer( 'cql', new CqlDebugAdapterDescriptorFactory(cqlLanguageClientInstance.getClient()), ), + languages.registerEvaluatableExpressionProvider( + { language: 'cql' }, + new CqlEvaluatableExpressionProvider(), + ), ); statusBar.showStatusBar(); } catch (error) { diff --git a/src/helpers/cqlHelpers.ts b/src/helpers/cqlHelpers.ts new file mode 100644 index 0000000..7b1fcdd --- /dev/null +++ b/src/helpers/cqlHelpers.ts @@ -0,0 +1,166 @@ +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'; +} + +const DEFAULT_TEST_CONFIG: TestConfig = { testCasesToExclude: [], resultFormat: 'flat' } + +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 + }; + } catch (error) { + log.error('Error reading config file', error); + return { testCasesToExclude: [], resultFormat: DEFAULT_TEST_CONFIG.resultFormat }; + } +} + +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/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, - }); + }; } From 99172a3c8f231905b25efc5c5f64cb1f2f151898 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 29 May 2026 16:22:54 -0600 Subject: [PATCH 08/38] update debug to support streaming --- .../input/cql/SimpleMeasure.cql | 19 ------------- src/commands/debug-test-case.ts | 28 ++++++++++++++++--- 2 files changed, 24 insertions(+), 23 deletions(-) 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/commands/debug-test-case.ts b/src/commands/debug-test-case.ts index 27eae36..80fe0e3 100644 --- a/src/commands/debug-test-case.ts +++ b/src/commands/debug-test-case.ts @@ -2,7 +2,8 @@ import { commands, debug, ExtensionContext, Uri, window, workspace } from 'vscod import { Commands } from '../commands/commands'; import { CqlLibrary, CqlTestCase } from '../model/cqlProject'; import { CqlSolution } from '../model/cqlSolution'; -import { getCqlPaths, getFhirVersion, waitForTestCasesLoaded } from '../helpers/cqlHelpers'; +import { getCqlPaths, getFhirVersion, loadTestConfig, waitForTestCasesLoaded } from '../helpers/cqlHelpers'; +import { resolveParameters } from '../helpers/parametersHelper'; let _context: ExtensionContext | undefined; @@ -36,10 +37,28 @@ export async function startDebuggingForTestCase( ): 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), { @@ -51,9 +70,10 @@ export async function startDebuggingForTestCase( fhirVersion, testCaseName: testCase.name, testCaseUri: testCase.uri.toString(), - terminologyUri: cqlPaths?.terminologyDirectoryPath?.toString(), - rootDir: cqlPaths?.projectDirectoryPath?.toString(), - optionsPath: cqlPaths?.optionsPath?.toString(), + terminologyUri: cqlPaths.terminologyDirectoryPath?.toString(), + rootDir: cqlPaths.projectDirectoryPath?.toString(), + optionsPath: cqlPaths.optionsPath?.toString(), + parameters: parameters.length > 0 ? parameters : undefined, }, ); } From 296640378853c528f06e2cff673ac1ff9bad3a17 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Sun, 31 May 2026 06:18:52 -0600 Subject: [PATCH 09/38] implement and test debug test case functionality --- package-lock.json | 162 +++++++++++++++++- package.json | 47 +++-- .../suite/commands/debug-test-case.test.ts | 125 ++++++++++++++ src/commands/debug-test-case.ts | 9 +- 4 files changed, 322 insertions(+), 21 deletions(-) create mode 100644 src/__test__/suite/commands/debug-test-case.test.ts 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 3ef6be9..687398f 100644 --- a/package.json +++ b/package.json @@ -176,13 +176,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" }, @@ -764,25 +764,46 @@ { "type": "cql", "label": "CQL Debug", - "languages": ["cql"], + "languages": [ + "cql" + ], "configurationAttributes": { "launch": { - "required": ["libraryUri", "fhirVersion"], + "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" } + "libraryUri": { + "type": "string" + }, + "fhirVersion": { + "type": "string" + }, + "testCaseName": { + "type": "string" + }, + "testCaseUri": { + "type": "string" + }, + "terminologyUri": { + "type": "string" + }, + "rootDir": { + "type": "string" + }, + "optionsPath": { + "type": "string" + } } } } } ], "breakpoints": [ - { "language": "cql" } + { + "language": "cql" + } ], "jsonValidation": [ { @@ -818,6 +839,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", @@ -827,6 +849,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/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/commands/debug-test-case.ts b/src/commands/debug-test-case.ts index 80fe0e3..f336fe7 100644 --- a/src/commands/debug-test-case.ts +++ b/src/commands/debug-test-case.ts @@ -12,7 +12,7 @@ export function register(context: ExtensionContext): void { context.subscriptions.push( commands.registerCommand(Commands.DEBUG_TEST_CASE_COMMAND, async (uri: Uri) => { - debugTestCase(uri); + await debugTestCase(uri); }), ); } @@ -89,11 +89,6 @@ export async function promptAndDebugTestCase(library: CqlLibrary): Promise return; } - if (testCases.length === 1) { - await startDebuggingForTestCase(library, testCases[0]); - return; - } - const stateKey = `debugTestCase.selections.${library.name}`; const saved = _context?.workspaceState.get(stateKey); @@ -128,4 +123,4 @@ export async function promptAndDebugTestCase(library: CqlLibrary): Promise if (!selectedTestCase) return; await _context?.workspaceState.update(stateKey, selectedTestCase.name); await startDebuggingForTestCase(library, selectedTestCase); -} +} \ No newline at end of file From 85a01ae3ad61d7778f7eadeabf3dfa8433ff511b Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 1 Jun 2026 04:24:24 -0600 Subject: [PATCH 10/38] catch and surface DAP session startup errors to the user --- .../cqlDebugAdapterDescriptorFactory.test.ts | 61 +++++++++++++++++++ src/debug/cqlDebugAdapterDescriptorFactory.ts | 16 +++-- 2 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 src/__test__/suite/debug/cqlDebugAdapterDescriptorFactory.test.ts 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/debug/cqlDebugAdapterDescriptorFactory.ts b/src/debug/cqlDebugAdapterDescriptorFactory.ts index 5138aca..c161ec2 100644 --- a/src/debug/cqlDebugAdapterDescriptorFactory.ts +++ b/src/debug/cqlDebugAdapterDescriptorFactory.ts @@ -11,10 +11,16 @@ export class CqlDebugAdapterDescriptorFactory async createDebugAdapterDescriptor( _session: vscode.DebugSession, ): Promise { - const port = await this.client.sendRequest(ExecuteCommandRequest.type, { - command: START_DEBUG_SESSION_CMD, - arguments: [], - }); - return new vscode.DebugAdapterServer(port as number); + 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; + } } } From 5e4310ed0d8d43920be5efc27882b3a66dca0ec7 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 1 Jun 2026 08:32:05 -0600 Subject: [PATCH 11/38] change AST indent to 2 spaces --- src/__test__/suite/commands/view-elm.test.ts | 89 +++++++++++++++++--- src/commands/view-elm.ts | 71 +++++++++++----- 2 files changed, 127 insertions(+), 33 deletions(-) diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index a54a920..7d16f0b 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -65,7 +65,7 @@ suite('viewElm()', () => { const MOCK_AST_ELM = `Library: SimpleMeasure (version 1.0.0) ├── define: "Patient" -│ └── Retrieve (dataType: Patient)`; +│ └── Retrieve (dataType: Patient)`; await viewElm(cqlUri, 'ast', async () => MOCK_AST_ELM); @@ -175,7 +175,7 @@ suite('buildAstLineIndex()', () => { 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]`; + └── Query [id=2, loc=4:1-5:10]`; const index = buildAstLineIndex(ast); @@ -241,13 +241,26 @@ suite('buildAstLineIndex()', () => { }); }); + 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" [id=208, loc=3:1-4:5] - └── Literal: 1 [id=209, loc=4:5]`; + └── Literal: 1 [id=209, loc=4:5]`; const index = buildAstLineIndex(ast); @@ -286,11 +299,11 @@ suite('sortAstBySourceOrder()', () => { 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[4]).to.include('loc=34:'); expect(lines[5]).to.include('define: "Initial Population"'); - expect(lines[5]).to.include('[loc=36:'); + expect(lines[5]).to.include('loc=36:'); expect(lines[6]).to.include('define: "Denominator"'); - expect(lines[6]).to.include('[loc=40:'); + expect(lines[6]).to.include('loc=40:'); // Last item uses └── expect(lines[6]).to.match(/^└── define/); }); @@ -299,9 +312,9 @@ suite('sortAstBySourceOrder()', () => { 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] +│ └── Literal: 2 [id=3, loc=10:5] └── define: "A" [id=1, loc=5:1-5:5] - └── Literal: 1 [id=4, loc=5:5]`; + └── Literal: 1 [id=4, loc=5:5]`; const sorted = sortAstBySourceOrder(ast); const lines = sorted.split('\n'); @@ -334,7 +347,7 @@ suite('sortAstBySourceOrder()', () => { 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] +│ └── 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]`; @@ -344,11 +357,59 @@ suite('sortAstBySourceOrder()', () => { // Last define (highest loc) gets └── expect(lines[lines.length - 1]).to.match(/^└── define/); expect(lines[lines.length - 1]).to.include('"C"'); - // Others get ├── - expect(lines[lines.length - 3]).to.match(/^├── define/); - expect(lines[lines.length - 3]).to.include('"A"'); - expect(lines[lines.length - 2]).to.match(/^├── define/); - expect(lines[lines.length - 2]).to.include('"B"'); + // 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', () => { diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index db227bb..031c038 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -17,6 +17,8 @@ import { Commands } from './commands'; const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; +let activeSplitSession: { dispose: () => void } | undefined; + export interface AstLoc { startLine: number; startCol: number; @@ -104,7 +106,6 @@ export function sortAstBySourceOrder(astContent: string): string { interface AstSegment { lines: string[]; cqlLine: number; - isDefine: boolean; } const segments: AstSegment[] = []; @@ -116,7 +117,6 @@ export function sortAstBySourceOrder(astContent: string): string { segments.push({ lines: segLines, cqlLine: m ? parseInt(m[1], 10) : -1, - isDefine: /define:/.test(first), }); } @@ -131,12 +131,10 @@ export function sortAstBySourceOrder(astContent: string): string { } if (current.length > 0) commitSegment(current); - const defineSegs = segments.filter(s => s.isDefine && s.cqlLine > 0); - const otherSegs = segments.filter(s => !s.isDefine || s.cqlLine <= 0); - - defineSegs.sort((a, b) => a.cqlLine - b.cqlLine); - - const ordered = [...otherSegs, ...defineSegs]; + 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++) { @@ -162,11 +160,13 @@ function findNearestForwardLoc( } async function viewElmSplit(cqlFileUri: Uri): Promise { + activeSplitSession?.dispose(); + try { const cqlDoc = await workspace.openTextDocument(cqlFileUri); const astContent = await getElm(cqlFileUri, 'ast'); const sortedAst = sortAstBySourceOrder(astContent); - const lineIndex = buildAstLineIndex(sortedAst); + let lineIndex = buildAstLineIndex(sortedAst); const cqlEditor = await window.showTextDocument(cqlDoc, ViewColumn.One); const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); @@ -183,13 +183,28 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { let lastCqlRevealTime = 0; let lastAstRevealTime = 0; + let disposed = false; + + function disposeSession(): void { + if (disposed) return; + disposed = true; + visibleRangesListener.dispose(); + selectionListener.dispose(); + closeListener.dispose(); + docSaveListener.dispose(); + cqlDecoration.dispose(); + astDecoration.dispose(); + } function syncCqlToAst(cqlLine?: number): void { const effectiveLine = cqlLine ?? cqlEditor.visibleRanges[0]?.start.line; if (effectiveLine === undefined) return; const astLines = lineIndex.cqlToAstLines.get(effectiveLine); - if (!astLines || astLines.length === 0) return; + if (!astLines || astLines.length === 0) { + astEditor.setDecorations(astDecoration, []); + return; + } const targetAstLine = Math.min(...astLines); const endAstLine = Math.max(...astLines); @@ -209,7 +224,7 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { astEditor.revealRange( new Range(targetAstLine, 0, endAstLine, 0), - TextEditorRevealType.AtTop, + TextEditorRevealType.InCenterIfOutsideViewport, ); astEditor.setDecorations( astDecoration, @@ -222,7 +237,10 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { if (effectiveLine === undefined) return; const loc = findNearestForwardLoc(lineIndex, effectiveLine); - if (!loc) return; + if (!loc) { + cqlEditor.setDecorations(cqlDecoration, []); + return; + } const start = new Position(loc.startLine - 1, loc.startCol - 1); const end = new Position(loc.endLine - 1, loc.endCol); @@ -239,9 +257,8 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { } } - cqlEditor.revealRange(vsRange, TextEditorRevealType.AtTop); + cqlEditor.revealRange(vsRange, TextEditorRevealType.InCenterIfOutsideViewport); cqlEditor.selection = new Selection(start, end); - cqlEditor.setDecorations(cqlDecoration, [{ range: vsRange } as DecorationOptions]); } @@ -271,15 +288,31 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { } }); + const docSaveListener = workspace.onDidSaveTextDocument(async saved => { + if (saved.uri.toString() !== cqlFileUri.toString()) return; + if (disposed) return; + try { + const newAst = await getElm(cqlFileUri, 'ast'); + const newSorted = sortAstBySourceOrder(newAst); + lineIndex = buildAstLineIndex(newSorted); + await astEditor.edit(edit => { + const lastLine = astEditor.document.lineAt(astEditor.document.lineCount - 1); + const fullRange = new Range(0, 0, lastLine.lineNumber, lastLine.text.length); + edit.replace(fullRange, newSorted); + }); + } catch (error) { + log.debug(`Failed to refresh AST after save: ${error}`); + } + }); + const closeListener = window.onDidChangeVisibleTextEditors(editors => { if (!editors.includes(cqlEditor) || !editors.includes(astEditor)) { - visibleRangesListener.dispose(); - selectionListener.dispose(); - closeListener.dispose(); - cqlDecoration.dispose(); - astDecoration.dispose(); + if (disposed) return; + disposeSession(); } }); + + activeSplitSession = { dispose: disposeSession }; } catch (error) { window.showErrorMessage( `Error opening CQL/AST split view for ${cqlFileUri.fsPath}. err: ${error}`, From 631a0bc0aee6df3e315a607eb1a73633157dbfd1 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 1 Jun 2026 10:48:02 -0600 Subject: [PATCH 12/38] Update view-elm test mock for 2-space indent and return type format --- src/__test__/suite/commands/view-elm.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index 7d16f0b..e74ca09 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -259,7 +259,7 @@ suite('buildAstLineIndex()', () => { ├── translator: CQL-to-ELM ? ├── schema: urn:hl7-org:elm r1 ├── using: System (urn:hl7-org:elm-types:r1) -└── define: "One" [id=208, loc=3:1-4:5] +└── define: "One" returns System.Integer [id=208, loc=3:1-4:5] └── Literal: 1 [id=209, loc=4:5]`; const index = buildAstLineIndex(ast); From b8af21a78d3c81b01d73f67abfa418524d5c2528 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 1 Jun 2026 14:38:27 -0600 Subject: [PATCH 13/38] update the cql/ast lock-step tracking --- .../suite/debug/cqlDebugAstTracker.test.ts | 125 ++++++++++++++++++ src/commands/view-elm.ts | 86 ++++++++++-- src/debug/cqlDebugAstTracker.ts | 55 ++++++++ src/extension.ts | 2 + 4 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 src/__test__/suite/debug/cqlDebugAstTracker.test.ts create mode 100644 src/debug/cqlDebugAstTracker.ts diff --git a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts new file mode 100644 index 0000000..c0a9907 --- /dev/null +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -0,0 +1,125 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { CqlDebugAstTrackerFactory } from '../../../debug/cqlDebugAstTracker'; +import * as viewElm from '../../../commands/view-elm'; + +suite('CqlDebugAstTracker', () => { + let sandbox: sinon.SinonSandbox; + let factory: CqlDebugAstTrackerFactory; + let hookSpy: { highlightCqlLine: sinon.SinonSpy; noteExternalReveal: sinon.SinonSpy }; + let getActiveSplitDebugHookStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + factory = new CqlDebugAstTrackerFactory(); + hookSpy = { + highlightCqlLine: sandbox.spy(), + noteExternalReveal: sandbox.spy(), + }; + getActiveSplitDebugHookStub = sandbox.stub(viewElm, 'getActiveSplitDebugHook'); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('stopped event triggers stackTrace and hook call with 0-indexed line', async () => { + getActiveSplitDebugHookStub.returns(hookSpy); + + 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(hookSpy.highlightCqlLine.calledOnce).to.be.true; + expect(hookSpy.highlightCqlLine.firstCall.args[0]).to.equal(6); + }); + + test('piggy-backs on stackTrace response from UI-issued request', () => { + getActiveSplitDebugHookStub.returns(hookSpy); + + const sessionMock = {} as any; + const tracker = factory.createDebugAdapterTracker(sessionMock); + + tracker.onDidSendMessage!({ + type: 'response', + command: 'stackTrace', + success: true, + body: { stackFrames: [{ line: 4 }] }, + }); + + expect(hookSpy.highlightCqlLine.calledOnce).to.be.true; + expect(hookSpy.highlightCqlLine.firstCall.args[0]).to.equal(3); + }); + + test('no active split view: tracker swallows without throwing', async () => { + getActiveSplitDebugHookStub.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; + // No error should be thrown + }); + + test('stopped event without threadId: does not call customRequest', async () => { + getActiveSplitDebugHookStub.returns(hookSpy); + + 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(hookSpy.highlightCqlLine.called).to.be.false; + }); + + test('customRequest rejection is caught silently', async () => { + getActiveSplitDebugHookStub.returns(hookSpy); + + 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; + // Should not throw + }); +}); diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index 031c038..0e519a5 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -1,11 +1,14 @@ import { commands, + debug, DecorationOptions, ExtensionContext, + OverviewRulerLane, Position, Range, Selection, TextEditorRevealType, + ThemeColor, Uri, ViewColumn, window, @@ -19,6 +22,17 @@ const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; let activeSplitSession: { dispose: () => void } | undefined; +export interface ActiveSplitDebugHook { + highlightCqlLine(cqlLine0Indexed: number): void; + noteExternalReveal(): void; +} + +let activeSplitDebugHook: ActiveSplitDebugHook | undefined; + +export function getActiveSplitDebugHook(): ActiveSplitDebugHook | undefined { + return activeSplitDebugHook; +} + export interface AstLoc { startLine: number; startCol: number; @@ -172,14 +186,28 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); const astEditor = await window.showTextDocument(astDoc, ViewColumn.Two); - const cqlDecoration = window.createTextEditorDecorationType({ - backgroundColor: 'rgba(0, 120, 212, 0.15)', - isWholeLine: false, - }); const astDecoration = window.createTextEditorDecorationType({ - backgroundColor: 'rgba(0, 120, 212, 0.15)', + backgroundColor: new ThemeColor('editor.findMatchHighlightBackground'), + border: '1px solid', + borderColor: new ThemeColor('editor.findMatchBorder'), + overviewRulerColor: new ThemeColor('editor.findMatchHighlightBackground'), + overviewRulerLane: OverviewRulerLane.Center, + 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, + }); let lastCqlRevealTime = 0; let lastAstRevealTime = 0; @@ -192,8 +220,10 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { selectionListener.dispose(); closeListener.dispose(); docSaveListener.dispose(); - cqlDecoration.dispose(); + cqlLineDecoration.dispose(); + cqlSpanDecoration.dispose(); astDecoration.dispose(); + activeSplitDebugHook = undefined; } function syncCqlToAst(cqlLine?: number): void { @@ -238,7 +268,8 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { const loc = findNearestForwardLoc(lineIndex, effectiveLine); if (!loc) { - cqlEditor.setDecorations(cqlDecoration, []); + cqlEditor.setDecorations(cqlLineDecoration, []); + cqlEditor.setDecorations(cqlSpanDecoration, []); return; } @@ -252,14 +283,16 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { const cqlBottom = cqlVisibleRanges[cqlVisibleRanges.length - 1].end.line; if (start.line >= cqlTop && end.line <= cqlBottom) { cqlEditor.selection = new Selection(start, end); - cqlEditor.setDecorations(cqlDecoration, [{ range: vsRange } as DecorationOptions]); + cqlEditor.setDecorations(cqlLineDecoration, [new Range(loc.startLine - 1, 0, loc.endLine - 1, 0)]); + cqlEditor.setDecorations(cqlSpanDecoration, [{ range: vsRange } as DecorationOptions]); return; } } cqlEditor.revealRange(vsRange, TextEditorRevealType.InCenterIfOutsideViewport); cqlEditor.selection = new Selection(start, end); - cqlEditor.setDecorations(cqlDecoration, [{ range: vsRange } as DecorationOptions]); + cqlEditor.setDecorations(cqlLineDecoration, [new Range(loc.startLine - 1, 0, loc.endLine - 1, 0)]); + cqlEditor.setDecorations(cqlSpanDecoration, [{ range: vsRange } as DecorationOptions]); } const visibleRangesListener = window.onDidChangeTextEditorVisibleRanges(e => { @@ -312,7 +345,42 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { } }); + activeSplitDebugHook = { + highlightCqlLine(cqlLine: number) { + lastAstRevealTime = performance.now(); + syncCqlToAst(cqlLine); + }, + noteExternalReveal() { + lastAstRevealTime = performance.now(); + }, + }; + activeSplitSession = { dispose: disposeSession }; + + // Catch up if debugger is already paused when split view opens + (async () => { + const session = debug.activeDebugSession; + if (!session || session.type !== 'cql') return; + try { + const threadsResp = await session.customRequest('threads'); + const threads: any[] = threadsResp?.threads ?? []; + for (const thread of threads) { + if (thread.id === undefined) continue; + try { + const resp = await session.customRequest('stackTrace', { + threadId: thread.id, + startFrame: 0, + levels: 1, + }); + const top = resp?.stackFrames?.[0]; + if (top && typeof top.line === 'number') { + activeSplitDebugHook?.highlightCqlLine(top.line - 1); + return; + } + } catch { /* try next thread */ } + } + } catch { /* session ended */ } + })(); } catch (error) { window.showErrorMessage( `Error opening CQL/AST split view for ${cqlFileUri.fsPath}. err: ${error}`, diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts new file mode 100644 index 0000000..329a716 --- /dev/null +++ b/src/debug/cqlDebugAstTracker.ts @@ -0,0 +1,55 @@ +import * as vscode from 'vscode'; +import { getActiveSplitDebugHook } from '../commands/view-elm'; + +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 === 'stopped') { + lastStoppedThreadId = message.body?.threadId; + await syncFromStackTrace(session, lastStoppedThreadId); + return; + } + + if ( + message.type === 'response' && + message.command === 'stackTrace' && + message.success && + Array.isArray(message.body?.stackFrames) && + message.body.stackFrames.length > 0 + ) { + applyTopFrame(message.body.stackFrames[0]); + } + }, + }; + } +} + +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]; + if (top) applyTopFrame(top); + } catch { + // Session may have ended between stopped event and our request. + } +} + +function applyTopFrame(frame: { line?: number }): void { + const hook = getActiveSplitDebugHook(); + if (!hook) return; + if (typeof frame.line !== 'number') return; + hook.highlightCqlLine(frame.line - 1); +} diff --git a/src/extension.ts b/src/extension.ts index 7b783c9..a2954a4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,7 @@ 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 { CqlExplorer } from './cql-explorer/cqlExplorer'; import { ClientStatus } from './extension.api'; @@ -152,6 +153,7 @@ async function startServer( 'cql', new CqlDebugAdapterDescriptorFactory(cqlLanguageClientInstance.getClient()), ), + debug.registerDebugAdapterTrackerFactory('cql', new CqlDebugAstTrackerFactory()), languages.registerEvaluatableExpressionProvider( { language: 'cql' }, new CqlEvaluatableExpressionProvider(), From 3287fda20a4a4c383c0abb4fe1a3372b043dd23a Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 1 Jun 2026 16:25:08 -0600 Subject: [PATCH 14/38] add AST debug walking support --- package.json | 11 +++ .../suite/debug/stepGranularityToggle.test.ts | 69 +++++++++++++++++++ src/debug/cqlDebugAstTracker.ts | 3 +- src/debug/stepGranularityToggle.ts | 41 +++++++++++ src/extension.ts | 2 + 5 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/__test__/suite/debug/stepGranularityToggle.test.ts create mode 100644 src/debug/stepGranularityToggle.ts diff --git a/package.json b/package.json index 687398f..ec5cd7d 100644 --- a/package.json +++ b/package.json @@ -339,6 +339,11 @@ "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": { @@ -794,6 +799,12 @@ }, "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." } } } diff --git a/src/__test__/suite/debug/stepGranularityToggle.test.ts b/src/__test__/suite/debug/stepGranularityToggle.test.ts new file mode 100644 index 0000000..a2858b1 --- /dev/null +++ b/src/__test__/suite/debug/stepGranularityToggle.test.ts @@ -0,0 +1,69 @@ +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); + }); + + 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() }; + }); + + activateStepGranularityToggle(context); + + const toggleCommand = context.subscriptions.find( + (sub) => (sub as any).command === 'cql.debug.toggle-step-granularity', + ); + 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; + }); +}); \ No newline at end of file diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index 329a716..9334ea1 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -47,9 +47,10 @@ async function syncFromStackTrace( } } -function applyTopFrame(frame: { line?: number }): void { +function applyTopFrame(frame: { line?: number; source?: { path?: string } }): void { const hook = getActiveSplitDebugHook(); if (!hook) return; if (typeof frame.line !== 'number') return; + if (frame.source?.path && !frame.source.path.toLowerCase().endsWith('.cql')) return; hook.highlightCqlLine(frame.line - 1); } diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts new file mode 100644 index 0000000..11f3be9 --- /dev/null +++ b/src/debug/stepGranularityToggle.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; + +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); + }), + vscode.debug.onDidTerminateDebugSession(() => 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); + }), + ); +} + +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(); +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index a2954a4..60c840d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { cqlLanguageClientInstance } from './cql-language-server/cqlLanguageClie import { CqlDebugAdapterDescriptorFactory } from './debug/cqlDebugAdapterDescriptorFactory'; import { CqlDebugAstTrackerFactory } from './debug/cqlDebugAstTracker'; import { CqlEvaluatableExpressionProvider } from './debug/cqlEvaluatableExpressionProvider'; +import { activateStepGranularityToggle } from './debug/stepGranularityToggle'; import { CqlExplorer } from './cql-explorer/cqlExplorer'; import { ClientStatus } from './extension.api'; import * as requirements from './java-support/requirements'; @@ -129,6 +130,7 @@ export function activate(context: ExtensionContext): Promise { registerDebugTestCase(context); registerLogCommands(context); registerViewElmCommand(context); + activateStepGranularityToggle(context); context.subscriptions.push(statusBar); await startServer(context, requirements, clientOptions, workspacePath); From 66473d96a0110e4ca998aac77d9851a67f679a42 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 2 Jun 2026 06:37:38 -0600 Subject: [PATCH 15/38] fix issues with how AST is displayed while debugging --- src/__test__/suite/commands/view-elm.test.ts | 40 ++++++++++ .../suite/debug/stepGranularityToggle.test.ts | 73 ++++++++++++++++++- src/__test__/suite/extension/commands.test.ts | 2 + src/commands/view-elm.ts | 70 +++++++++++++++--- src/debug/stepGranularityToggle.ts | 13 ++++ 5 files changed, 188 insertions(+), 10 deletions(-) diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index e74ca09..1575bf3 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -417,3 +417,43 @@ suite('sortAstBySourceOrder()', () => { expect(sortAstBySourceOrder('Library: Test (version 1.0.0)')).to.equal('Library: Test (version 1.0.0)'); }); }); + +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/debug/stepGranularityToggle.test.ts b/src/__test__/suite/debug/stepGranularityToggle.test.ts index a2858b1..5c8bae7 100644 --- a/src/__test__/suite/debug/stepGranularityToggle.test.ts +++ b/src/__test__/suite/debug/stepGranularityToggle.test.ts @@ -1,6 +1,8 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { Commands } from '../../../commands/commands'; +import * as viewElm from '../../../commands/view-elm'; import { activateStepGranularityToggle } from '../../../debug/stepGranularityToggle'; suite('stepGranularityToggle', () => { @@ -21,6 +23,10 @@ suite('stepGranularityToggle', () => { 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(() => { @@ -38,11 +44,12 @@ suite('stepGranularityToggle', () => { callback(sessionMock); return { dispose: sandbox.spy() }; }); + sandbox.stub(vscode.debug, 'activeDebugSession').get(() => sessionMock); activateStepGranularityToggle(context); const toggleCommand = context.subscriptions.find( - (sub) => (sub as any).command === 'cql.debug.toggle-step-granularity', + (sub: any) => sub.command === 'cql.debug.toggle-step-granularity' && typeof sub.execute === 'function', ); expect(toggleCommand).to.exist; @@ -66,4 +73,68 @@ suite('stepGranularityToggle', () => { expect(statusBarItemStub.hide.called).to.be.true; }); + + test('opens split view when toggling to ast with no active split session', async () => { + sandbox.stub(viewElm, 'getActiveSplitDebugHook').returns(undefined); + sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [ + { document: { uri: { fsPath: '/test/file.cql' } } } as any, + ]); + 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(Commands.VIEW_ELM_COMMAND_AST_SPLIT); + expect(executeCommandStub.firstCall.args[1].fsPath).to.equal('/test/file.cql'); + }); + + test('does not open split view when toggling to ast if split session already active', async () => { + sandbox.stub(viewElm, 'getActiveSplitDebugHook').returns({ + highlightCqlLine: sandbox.spy(), + noteExternalReveal: sandbox.spy(), + }); + 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.called).to.be.false; + }); }); \ No newline at end of file diff --git a/src/__test__/suite/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index 6f6db36..dee288c 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -10,6 +10,8 @@ const EXPECTED_COMMANDS = [ 'cql.editor.view-elm.json', 'cql.editor.view-elm.ast', 'cql.editor.view-elm.ast.split', + // Debug commands + 'cql.debug.toggle-step-granularity', // Global commands 'cql.execute.select-libraries', 'cql.open.server-log', diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index 0e519a5..fbaf8ae 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -7,6 +7,8 @@ import { Position, Range, Selection, + TextDocument, + TextEditor, TextEditorRevealType, ThemeColor, Uri, @@ -20,7 +22,9 @@ import { Commands } from './commands'; const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; -let activeSplitSession: { dispose: () => void } | undefined; +let activeSplitSession: + | { dispose: () => void; astEditor: TextEditor; cqlUri: string } + | undefined; export interface ActiveSplitDebugHook { highlightCqlLine(cqlLine0Indexed: number): void; @@ -33,6 +37,8 @@ export function getActiveSplitDebugHook(): ActiveSplitDebugHook | undefined { return activeSplitDebugHook; } +const openAstDocs = new Map(); + export interface AstLoc { startLine: number; startCol: number; @@ -59,6 +65,14 @@ export function register(context: ExtensionContext): void { commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST_SPLIT, async (uri: Uri) => { await viewElmSplit(uri); }), + workspace.onDidCloseTextDocument(doc => { + for (const [key, tracked] of openAstDocs) { + if (tracked === doc) { + openAstDocs.delete(key); + break; + } + } + }), ); } @@ -72,6 +86,28 @@ export async function viewElm( const elm: string = await elmFetcher(cqlFileUri, elmType); const languageId = elmType === 'ast' ? 'ast' : elmType; const formatted = elmType === 'json' ? formatJson(elm) : elm; + 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); + reused = await replaceDocumentContent(editor, formatted); + 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') { @@ -173,8 +209,18 @@ function findNearestForwardLoc( return next !== undefined ? lineIndex.astToCqlLoc.get(next) : undefined; } +async function replaceDocumentContent(editor: TextEditor, content: string): Promise { + const lastLine = editor.document.lineAt(editor.document.lineCount - 1); + return editor.edit(edit => + edit.replace(new Range(0, 0, lastLine.lineNumber, lastLine.text.length), content), + ); +} + async function viewElmSplit(cqlFileUri: Uri): Promise { + const prevAstEditor = activeSplitSession?.astEditor; + const prevCqlUri = activeSplitSession?.cqlUri; activeSplitSession?.dispose(); + activeSplitSession = undefined; try { const cqlDoc = await workspace.openTextDocument(cqlFileUri); @@ -183,8 +229,18 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { let lineIndex = buildAstLineIndex(sortedAst); const cqlEditor = await window.showTextDocument(cqlDoc, ViewColumn.One); - const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); - const astEditor = await window.showTextDocument(astDoc, ViewColumn.Two); + const canReuse = prevAstEditor !== undefined && prevCqlUri === cqlFileUri.toString() && !prevAstEditor.document.isClosed; + const reuseOk = canReuse + ? await replaceDocumentContent(prevAstEditor!, sortedAst).catch(() => false) + : false; + let astEditor: TextEditor; + if (reuseOk) { + astEditor = prevAstEditor!; + await window.showTextDocument(astEditor.document, ViewColumn.Two); + } else { + const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); + astEditor = await window.showTextDocument(astDoc, ViewColumn.Two); + } const astDecoration = window.createTextEditorDecorationType({ backgroundColor: new ThemeColor('editor.findMatchHighlightBackground'), @@ -328,11 +384,7 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { const newAst = await getElm(cqlFileUri, 'ast'); const newSorted = sortAstBySourceOrder(newAst); lineIndex = buildAstLineIndex(newSorted); - await astEditor.edit(edit => { - const lastLine = astEditor.document.lineAt(astEditor.document.lineCount - 1); - const fullRange = new Range(0, 0, lastLine.lineNumber, lastLine.text.length); - edit.replace(fullRange, newSorted); - }); + await replaceDocumentContent(astEditor, newSorted); } catch (error) { log.debug(`Failed to refresh AST after save: ${error}`); } @@ -355,7 +407,7 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { }, }; - activeSplitSession = { dispose: disposeSession }; + activeSplitSession = { dispose: disposeSession, astEditor, cqlUri: cqlFileUri.toString() }; // Catch up if debugger is already paused when split view opens (async () => { diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts index 11f3be9..70df4e6 100644 --- a/src/debug/stepGranularityToggle.ts +++ b/src/debug/stepGranularityToggle.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import { Commands } from '../commands/commands'; +import { getActiveSplitDebugHook } from '../commands/view-elm'; type Granularity = 'cql' | 'ast'; const sessionGranularity = new WeakMap(); @@ -27,6 +29,17 @@ export function activateStepGranularityToggle(context: vscode.ExtensionContext) await s.customRequest('setStepGranularity', { granularity: next }); sessionGranularity.set(s, next); refresh(s); + if (next === 'ast' && !getActiveSplitDebugHook()) { + const cqlEditor = vscode.window.visibleTextEditors.find( + e => e.document.uri.fsPath.toLowerCase().endsWith('.cql'), + ); + if (cqlEditor) { + await vscode.commands.executeCommand( + Commands.VIEW_ELM_COMMAND_AST_SPLIT, + cqlEditor.document.uri, + ); + } + } }), ); } From 8e8f3491557123ea5734bade5d4287f6784c18e4 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 2 Jun 2026 08:30:34 -0600 Subject: [PATCH 16/38] add debug session shutdown enhancements --- src/debug/cqlDebugAstTracker.ts | 9 +++++++++ src/debug/stepGranularityToggle.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index 9334ea1..a9618a4 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -9,6 +9,15 @@ export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFact onDidSendMessage: async (message: any) => { if (!message || typeof message !== 'object') return; + if (message.type === 'event' && message.event === 'terminated') { + console.log(`[cql-debug] ${Date.now()} DAP terminated event received`); + return; + } + if (message.type === 'event' && message.event === 'exited') { + console.log(`[cql-debug] ${Date.now()} DAP exited event received (exitCode=${message.body?.exitCode})`); + return; + } + if (message.type === 'event' && message.event === 'stopped') { lastStoppedThreadId = message.body?.threadId; await syncFromStackTrace(session, lastStoppedThreadId); diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts index 70df4e6..f089a67 100644 --- a/src/debug/stepGranularityToggle.ts +++ b/src/debug/stepGranularityToggle.ts @@ -19,7 +19,10 @@ export function activateStepGranularityToggle(context: vscode.ExtensionContext) sessionGranularity.set(s, initial); refresh(s); }), - vscode.debug.onDidTerminateDebugSession(() => refresh(vscode.debug.activeDebugSession)), + vscode.debug.onDidTerminateDebugSession(() => { + console.log(`[cql-debug] ${Date.now()} 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; From 18f41240504aa0eefe5ee8ea33086aa0150da59f Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 2 Jun 2026 12:38:02 -0600 Subject: [PATCH 17/38] enhance AST highlighting with in a debug session --- src/__test__/suite/commands/view-elm.test.ts | 33 ++++- .../suite/debug/cqlDebugAstTracker.test.ts | 26 ++-- .../suite/debug/stepGranularityToggle.test.ts | 2 +- src/commands/view-elm.ts | 124 +++++++++++++----- src/debug/cqlDebugAstTracker.ts | 15 ++- 5 files changed, 153 insertions(+), 47 deletions(-) diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index 1575bf3..0a146f2 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { TextDocument, Uri, commands, window, workspace } from 'vscode'; -import { viewElm, buildAstLineIndex, sortAstBySourceOrder } from '../../../commands/view-elm'; +import { viewElm, buildAstLineIndex, buildLocatorKey, sortAstBySourceOrder } from '../../../commands/view-elm'; const MOCK_JSON_ELM = JSON.stringify({ library: { @@ -277,6 +277,37 @@ suite('buildAstLineIndex()', () => { // 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('sortAstBySourceOrder()', () => { diff --git a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts index c0a9907..c8a66d3 100644 --- a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -6,14 +6,14 @@ import * as viewElm from '../../../commands/view-elm'; suite('CqlDebugAstTracker', () => { let sandbox: sinon.SinonSandbox; let factory: CqlDebugAstTrackerFactory; - let hookSpy: { highlightCqlLine: sinon.SinonSpy; noteExternalReveal: sinon.SinonSpy }; + let hookSpy: { highlightCqlSpan: sinon.SinonSpy; noteExternalReveal: sinon.SinonSpy }; let getActiveSplitDebugHookStub: sinon.SinonStub; setup(() => { sandbox = sinon.createSandbox(); factory = new CqlDebugAstTrackerFactory(); hookSpy = { - highlightCqlLine: sandbox.spy(), + highlightCqlSpan: sandbox.spy(), noteExternalReveal: sandbox.spy(), }; getActiveSplitDebugHookStub = sandbox.stub(viewElm, 'getActiveSplitDebugHook'); @@ -23,7 +23,7 @@ suite('CqlDebugAstTracker', () => { sandbox.restore(); }); - test('stopped event triggers stackTrace and hook call with 0-indexed line', async () => { + test('stopped event triggers stackTrace and hook call with 1-indexed span', async () => { getActiveSplitDebugHookStub.returns(hookSpy); const sessionMock = { @@ -45,8 +45,13 @@ suite('CqlDebugAstTracker', () => { startFrame: 0, levels: 1, }); - expect(hookSpy.highlightCqlLine.calledOnce).to.be.true; - expect(hookSpy.highlightCqlLine.firstCall.args[0]).to.equal(6); + expect(hookSpy.highlightCqlSpan.calledOnce).to.be.true; + expect(hookSpy.highlightCqlSpan.firstCall.args[0]).to.deep.equal({ + line: 7, + column: 1, + endLine: 7, + endColumn: 1, + }); }); test('piggy-backs on stackTrace response from UI-issued request', () => { @@ -62,8 +67,13 @@ suite('CqlDebugAstTracker', () => { body: { stackFrames: [{ line: 4 }] }, }); - expect(hookSpy.highlightCqlLine.calledOnce).to.be.true; - expect(hookSpy.highlightCqlLine.firstCall.args[0]).to.equal(3); + expect(hookSpy.highlightCqlSpan.calledOnce).to.be.true; + expect(hookSpy.highlightCqlSpan.firstCall.args[0]).to.deep.equal({ + line: 4, + column: 1, + endLine: 4, + endColumn: 1, + }); }); test('no active split view: tracker swallows without throwing', async () => { @@ -101,7 +111,7 @@ suite('CqlDebugAstTracker', () => { }); expect(sessionMock.customRequest.called).to.be.false; - expect(hookSpy.highlightCqlLine.called).to.be.false; + expect(hookSpy.highlightCqlSpan.called).to.be.false; }); test('customRequest rejection is caught silently', async () => { diff --git a/src/__test__/suite/debug/stepGranularityToggle.test.ts b/src/__test__/suite/debug/stepGranularityToggle.test.ts index 5c8bae7..87db027 100644 --- a/src/__test__/suite/debug/stepGranularityToggle.test.ts +++ b/src/__test__/suite/debug/stepGranularityToggle.test.ts @@ -109,7 +109,7 @@ suite('stepGranularityToggle', () => { test('does not open split view when toggling to ast if split session already active', async () => { sandbox.stub(viewElm, 'getActiveSplitDebugHook').returns({ - highlightCqlLine: sandbox.spy(), + highlightCqlSpan: sandbox.spy(), noteExternalReveal: sandbox.spy(), }); const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index fbaf8ae..f205e43 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -9,6 +9,7 @@ import { Selection, TextDocument, TextEditor, + TextEditorDecorationType, TextEditorRevealType, ThemeColor, Uri, @@ -22,12 +23,25 @@ import { Commands } from './commands'; const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; +export function buildLocatorKey( + line: number, col: number, endLine: number, endCol: number, +): string { + return `${line}:${col}-${endLine}:${endCol}`; +} + +export interface CqlSpan { + line: number; + column: number; + endLine: number; + endColumn: number; +} + let activeSplitSession: | { dispose: () => void; astEditor: TextEditor; cqlUri: string } | undefined; export interface ActiveSplitDebugHook { - highlightCqlLine(cqlLine0Indexed: number): void; + highlightCqlSpan(span: CqlSpan): void; noteExternalReveal(): void; } @@ -49,6 +63,7 @@ export interface AstLoc { export interface AstLineIndex { astToCqlLoc: Map; cqlToAstLines: Map; + locatorToAstLines: Map; } export function register(context: ExtensionContext): void { @@ -121,6 +136,7 @@ export async function viewElm( export function buildAstLineIndex(astContent: string): AstLineIndex { const astToCqlLoc = new Map(); const cqlToAstLines = new Map(); + const locatorToAstLines = new Map(); const lines = astContent.split('\n'); for (let astLine = 0; astLine < lines.length; astLine++) { @@ -142,9 +158,14 @@ export function buildAstLineIndex(astContent: string): AstLineIndex { 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); } - return { astToCqlLoc, cqlToAstLines }; + return { astToCqlLoc, cqlToAstLines, locatorToAstLines }; } export function sortAstBySourceOrder(astContent: string): string { @@ -216,6 +237,60 @@ async function replaceDocumentContent(editor: TextEditor, content: string): Prom ); } +export function 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)), + ); +} + +export function syncCqlToAstBySpan( + span: CqlSpan, + lineIndex: AstLineIndex, + astEditor: TextEditor, + astDecoration: TextEditorDecorationType, +): void { + const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); + let astLines = lineIndex.locatorToAstLines.get(key); + if (!astLines || astLines.length === 0) { + astLines = lineIndex.cqlToAstLines.get(span.line - 1); + } + if (!astLines || astLines.length === 0) { + astEditor.setDecorations(astDecoration, []); + return; + } + applyAstHighlight(astLines, astEditor, astDecoration); +} + async function viewElmSplit(cqlFileUri: Uri): Promise { const prevAstEditor = activeSplitSession?.astEditor; const prevCqlUri = activeSplitSession?.cqlUri; @@ -243,11 +318,9 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { } const astDecoration = window.createTextEditorDecorationType({ - backgroundColor: new ThemeColor('editor.findMatchHighlightBackground'), - border: '1px solid', - borderColor: new ThemeColor('editor.findMatchBorder'), - overviewRulerColor: new ThemeColor('editor.findMatchHighlightBackground'), - overviewRulerLane: OverviewRulerLane.Center, + backgroundColor: new ThemeColor('editor.stackFrameHighlightBackground'), + overviewRulerColor: new ThemeColor('editorError.foreground'), + overviewRulerLane: OverviewRulerLane.Left, isWholeLine: true, }); const cqlLineDecoration = window.createTextEditorDecorationType({ @@ -291,31 +364,7 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { 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)), - ); + applyAstHighlight(astLines, astEditor, astDecoration); } function syncAstToCql(astLine?: number): void { @@ -398,9 +447,9 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { }); activeSplitDebugHook = { - highlightCqlLine(cqlLine: number) { + highlightCqlSpan(span: CqlSpan) { lastAstRevealTime = performance.now(); - syncCqlToAst(cqlLine); + syncCqlToAstBySpan(span, lineIndex, astEditor, astDecoration); }, noteExternalReveal() { lastAstRevealTime = performance.now(); @@ -426,7 +475,12 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { }); const top = resp?.stackFrames?.[0]; if (top && typeof top.line === 'number') { - activeSplitDebugHook?.highlightCqlLine(top.line - 1); + activeSplitDebugHook?.highlightCqlSpan({ + line: top.line, + column: top.column ?? 1, + endLine: top.endLine ?? top.line, + endColumn: top.endColumn ?? top.column ?? 1, + }); return; } } catch { /* try next thread */ } diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index a9618a4..fb3e8f8 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -56,10 +56,21 @@ async function syncFromStackTrace( } } -function applyTopFrame(frame: { line?: number; source?: { path?: string } }): void { +function applyTopFrame(frame: { + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + source?: { path?: string }; +}): void { const hook = getActiveSplitDebugHook(); if (!hook) return; if (typeof frame.line !== 'number') return; if (frame.source?.path && !frame.source.path.toLowerCase().endsWith('.cql')) return; - hook.highlightCqlLine(frame.line - 1); + hook.highlightCqlSpan({ + line: frame.line, + column: frame.column ?? 1, + endLine: frame.endLine ?? frame.line, + endColumn: frame.endColumn ?? frame.column ?? 1, + }); } From acd6ca7d7c8a2141c1e3c1e23e8b7aab47601426 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Wed, 3 Jun 2026 08:20:44 -0600 Subject: [PATCH 18/38] clean up issue with stepping through AST --- src/__test__/suite/commands/view-elm.test.ts | 24 +++++++ .../suite/debug/cqlDebugAstTracker.test.ts | 27 ++++++++ src/commands/view-elm.ts | 64 ++++++++++++------- src/debug/cqlDebugAstTracker.ts | 8 ++- src/debug/stepGranularityToggle.ts | 4 +- 5 files changed, 102 insertions(+), 25 deletions(-) diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index 0a146f2..acbba02 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -308,6 +308,30 @@ suite('buildAstLineIndex()', () => { 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()', () => { diff --git a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts index c8a66d3..81cd670 100644 --- a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -54,6 +54,33 @@ suite('CqlDebugAstTracker', () => { }); }); + test('passes instructionPointerReference as localId on span', async () => { + getActiveSplitDebugHookStub.returns(hookSpy); + + const sessionMock = { + customRequest: sandbox.stub().resolves({ + stackFrames: [{ line: 5, column: 3, endLine: 8, endColumn: 10, instructionPointerReference: '208' }], + }), + } as any; + + const tracker = factory.createDebugAdapterTracker(sessionMock); + + await tracker.onDidSendMessage!({ + type: 'event', + event: 'stopped', + body: { threadId: 1 }, + }); + + expect(hookSpy.highlightCqlSpan.calledOnce).to.be.true; + expect(hookSpy.highlightCqlSpan.firstCall.args[0]).to.deep.equal({ + line: 5, + column: 3, + endLine: 8, + endColumn: 10, + localId: '208', + }); + }); + test('piggy-backs on stackTrace response from UI-issued request', () => { getActiveSplitDebugHookStub.returns(hookSpy); diff --git a/src/commands/view-elm.ts b/src/commands/view-elm.ts index f205e43..b98c2e7 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -22,6 +22,7 @@ import * as log from '../log-services/logger'; import { Commands } from './commands'; const LOC_REGEX = /\[.*?loc=(\d+):(\d+)(?:-(\d+):(\d+))?\]/; +const ID_REGEX = /\[id=(\d+)/; export function buildLocatorKey( line: number, col: number, endLine: number, endCol: number, @@ -34,6 +35,7 @@ export interface CqlSpan { column: number; endLine: number; endColumn: number; + localId?: string; } let activeSplitSession: @@ -64,6 +66,7 @@ export interface AstLineIndex { astToCqlLoc: Map; cqlToAstLines: Map; locatorToAstLines: Map; + localIdToAstLines: Map; } export function register(context: ExtensionContext): void { @@ -137,35 +140,44 @@ 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) continue; - - 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); + 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); + } - 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 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 }; + return { astToCqlLoc, cqlToAstLines, locatorToAstLines, localIdToAstLines }; } export function sortAstBySourceOrder(astContent: string): string { @@ -279,8 +291,15 @@ export function syncCqlToAstBySpan( astEditor: TextEditor, astDecoration: TextEditorDecorationType, ): void { - const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); - let astLines = lineIndex.locatorToAstLines.get(key); + let astLines: number[] | undefined; + + if (span.localId) { + astLines = lineIndex.localIdToAstLines.get(span.localId); + } + if (!astLines || astLines.length === 0) { + const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); + astLines = lineIndex.locatorToAstLines.get(key); + } if (!astLines || astLines.length === 0) { astLines = lineIndex.cqlToAstLines.get(span.line - 1); } @@ -480,6 +499,7 @@ async function viewElmSplit(cqlFileUri: Uri): Promise { column: top.column ?? 1, endLine: top.endLine ?? top.line, endColumn: top.endColumn ?? top.column ?? 1, + ...(top.instructionPointerReference ? { localId: top.instructionPointerReference } : {}), }); return; } diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index fb3e8f8..912dc22 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { getActiveSplitDebugHook } from '../commands/view-elm'; +import * as log from '../log-services/logger'; let lastStoppedThreadId: number | undefined; @@ -10,17 +11,18 @@ export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFact if (!message || typeof message !== 'object') return; if (message.type === 'event' && message.event === 'terminated') { - console.log(`[cql-debug] ${Date.now()} DAP terminated event received`); + log.debug('DAP terminated event received'); return; } if (message.type === 'event' && message.event === 'exited') { - console.log(`[cql-debug] ${Date.now()} DAP exited event received (exitCode=${message.body?.exitCode})`); + log.debug('DAP exited event received', { exitCode: message.body?.exitCode }); return; } if (message.type === 'event' && message.event === 'stopped') { lastStoppedThreadId = message.body?.threadId; await syncFromStackTrace(session, lastStoppedThreadId); + log.debug('DAP stopped event received'); return; } @@ -61,6 +63,7 @@ function applyTopFrame(frame: { column?: number; endLine?: number; endColumn?: number; + instructionPointerReference?: string; source?: { path?: string }; }): void { const hook = getActiveSplitDebugHook(); @@ -72,5 +75,6 @@ function applyTopFrame(frame: { column: frame.column ?? 1, endLine: frame.endLine ?? frame.line, endColumn: frame.endColumn ?? frame.column ?? 1, + ...(frame.instructionPointerReference ? { localId: frame.instructionPointerReference } : {}), }); } diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts index f089a67..21f4753 100644 --- a/src/debug/stepGranularityToggle.ts +++ b/src/debug/stepGranularityToggle.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { Commands } from '../commands/commands'; import { getActiveSplitDebugHook } from '../commands/view-elm'; +import * as log from '../log-services/logger'; type Granularity = 'cql' | 'ast'; const sessionGranularity = new WeakMap(); @@ -18,9 +19,10 @@ export function activateStepGranularityToggle(context: vscode.ExtensionContext) const initial: Granularity = (s.configuration.stepGranularity as Granularity) ?? 'cql'; sessionGranularity.set(s, initial); refresh(s); + log.debug('onDidStartDebugSession fired', {sessionGranularity: s}); }), vscode.debug.onDidTerminateDebugSession(() => { - console.log(`[cql-debug] ${Date.now()} onDidTerminateDebugSession fired`); + log.debug('onDidTerminateDebugSession fired'); refresh(vscode.debug.activeDebugSession); }), vscode.debug.onDidChangeActiveDebugSession((s) => refresh(s)), From 723abeab0b87ca1fc38e65e337118e82fe7e24ed Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 5 Jun 2026 12:36:27 -0600 Subject: [PATCH 19/38] resurrect package:local script with javaServiceInstaller path fix --- .vscodeignore | 1 + .vscodeignore.local | 19 ++++++ package.json | 1 + scripts/download-jar.js | 79 ++++++++++++++++++++++++ src/java-support/javaServiceInstaller.ts | 40 +++++++----- 5 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 .vscodeignore.local create mode 100644 scripts/download-jar.js 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..1030924 --- /dev/null +++ b/.vscodeignore.local @@ -0,0 +1,19 @@ +.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 +node_modules diff --git a/package.json b/package.json index ec5cd7d..4c0107f 100644 --- a/package.json +++ b/package.json @@ -830,6 +830,7 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", + "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" 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/java-support/javaServiceInstaller.ts b/src/java-support/javaServiceInstaller.ts index 8f09287..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` }); From 47af1cf90651f2b1307394b451694877e6c0a766 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 5 Jun 2026 16:07:01 -0600 Subject: [PATCH 20/38] fix issue with initial debug test case quickpick race condition --- src/commands/debug-test-case.ts | 44 ++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/commands/debug-test-case.ts b/src/commands/debug-test-case.ts index f336fe7..f1174c2 100644 --- a/src/commands/debug-test-case.ts +++ b/src/commands/debug-test-case.ts @@ -1,9 +1,9 @@ import { commands, debug, ExtensionContext, Uri, window, workspace } from 'vscode'; import { Commands } from '../commands/commands'; -import { CqlLibrary, CqlTestCase } from '../model/cqlProject'; -import { CqlSolution } from '../model/cqlSolution'; 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; @@ -93,31 +93,41 @@ export async function promptAndDebugTestCase(library: CqlLibrary): Promise const saved = _context?.workspaceState.get(stateKey); const quickPick = window.createQuickPick(); - quickPick.items = testCases.map(tc => ({ label: tc.name })); + 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}`; - if (saved) { - const match = quickPick.items.find(item => item.label === saved); - if (match) { - quickPick.selectedItems = [match]; - quickPick.activeItems = [match]; - } - } + 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(); - if (selected) { - resolve(testCases.find(tc => tc.name === selected.label)); - } else { - resolve(undefined); - } + resolve( + selected + ? testCases.find(tc => tc.name === selected.label) + : undefined, + ); + }); + + quickPick.onDidHide(() => { + if (!accepted) resolve(undefined); }); - quickPick.onDidHide(() => resolve(undefined)); - quickPick.show(); + + setTimeout(() => { + quickPick.show(); + if (savedMatch) { + quickPick.activeItems = [savedMatch]; + } + }, 0); }); if (!selectedTestCase) return; From 8be76d926accf195c0979c5239e3537ddf106a2c Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 8 Jun 2026 07:49:15 -0600 Subject: [PATCH 21/38] fix sorting issues in CQL Explorer --- .../cqlLibraryRootTreeItem.test.ts | 84 +++++++++++++++++++ .../cqlTestCaseRootTreeItem.test.ts | 17 ++++ .../suite/cql-explorer/getChildren.test.ts | 83 ++++++++++++++++++ .../cqlProjectTreeDataProvider.ts | 65 ++++++++++---- 4 files changed, 234 insertions(+), 15 deletions(-) diff --git a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts index 632beda..fb388ed 100644 --- a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts +++ b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts @@ -91,6 +91,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 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..c356f7c 100644 --- a/src/__test__/suite/cql-explorer/getChildren.test.ts +++ b/src/__test__/suite/cql-explorer/getChildren.test.ts @@ -211,6 +211,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 CMS libraries before non-CMS libraries', () => { + 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(['CMS2', 'Apple', 'Banana']); + }); + + 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/cql-explorer/cqlProjectTreeDataProvider.ts b/src/cql-explorer/cqlProjectTreeDataProvider.ts index a5a41ef..f735057 100644 --- a/src/cql-explorer/cqlProjectTreeDataProvider.ts +++ b/src/cql-explorer/cqlProjectTreeDataProvider.ts @@ -11,6 +11,47 @@ import { } from '../model/cqlProject'; import { DeviationKind } from '../model/igLayoutDetector'; +interface LibraryNameParts { + isCms: boolean; + measureNumber: number; + suffix: string; +} + +function parseLibraryName(name: string): LibraryNameParts { + 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 compareLibraryNames(a: string, b: string, descending: boolean): number { + const pa = parseLibraryName(a); + const pb = parseLibraryName(b); + + // CMS group always comes first (ascending and descending) + if (pa.isCms !== pb.isCms) return pa.isCms ? -1 : 1; + + if (pa.isCms) { + // 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); + } + + // Neither is CMS: 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 +129,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) => a.name.localeCompare(b.name)); + testCases.forEach(tc => this.addTestCase(tc)); } public get children(): vscode.TreeItem[] { @@ -174,8 +215,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) => a.name.localeCompare(b.name)); + testCases.forEach(tc => this.addTestCase(tc)); if (cqlLibrary.resultUris.length > 0) { this._children.push(new CqlResultsRootTreeItem(cqlLibrary.resultUris)); @@ -219,8 +260,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) => a.name.localeCompare(b.name)); + testCases.forEach(tc => this.addTestCase(tc)); if (this.cqlLibrary.resultUris.length > 0) { this._children.push(new CqlResultsRootTreeItem(this.cqlLibrary.resultUris)); @@ -564,9 +605,7 @@ export class CqlProjectTreeDataProvider implements vscode.TreeDataProvider { - if (this.sortDescending) { - this.sortRootItemsInPlace(); - } + this.sortRootItemsInPlace(); this.refresh(); }); @@ -592,9 +631,7 @@ export class CqlProjectTreeDataProvider implements vscode.TreeDataProvider - this.sortDescending - ? b.cqlLibrary.name.localeCompare(a.cqlLibrary.name) - : a.cqlLibrary.name.localeCompare(b.cqlLibrary.name), + compareLibraryNames(a.cqlLibrary.name, b.cqlLibrary.name, this.sortDescending), ); } else { // Multi-project: full rebuild is acceptable (rare case) @@ -808,9 +845,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) => compareLibraryNames(a.name, b.name, sortDescending)) .map( lib => new CqlLibraryRootTreeItem( From ad702ba0623f8a7cf83378249f1f0533d406c913 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Mon, 8 Jun 2026 09:15:29 -0600 Subject: [PATCH 22/38] fix issue taht affected packaging extension --- .vscodeignore.local | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscodeignore.local b/.vscodeignore.local index 1030924..848f24a 100644 --- a/.vscodeignore.local +++ b/.vscodeignore.local @@ -16,4 +16,3 @@ tests .eslintrc.js test-workspaces scripts -node_modules From ae6b42561adb82743b8aa82765be4ba39cbac910 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 9 Jun 2026 08:09:40 -0600 Subject: [PATCH 23/38] refactor view-elm logic --- package.json | 2 +- src/__test__/suite/commands/view-elm.test.ts | 338 +------------ .../suite/debug/cqlDebugAstTracker.test.ts | 4 +- .../suite/debug/stepGranularityToggle.test.ts | 7 +- src/__test__/suite/utils/astIndex.test.ts | 338 +++++++++++++ src/commands/view-elm.ts | 451 +----------------- src/debug/cqlDebugAstTracker.ts | 41 +- src/debug/stepGranularityToggle.ts | 4 +- src/debug/types.ts | 13 + src/utils/astIndex.ts | 136 ++++++ src/views/astSplitSession.ts | 385 +++++++++++++++ 11 files changed, 917 insertions(+), 802 deletions(-) create mode 100644 src/__test__/suite/utils/astIndex.test.ts create mode 100644 src/debug/types.ts create mode 100644 src/utils/astIndex.ts create mode 100644 src/views/astSplitSession.ts diff --git a/package.json b/package.json index 4c0107f..a4f9662 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cql", - "version": "0.9.6", + "version": "0.9.6-dev", "displayName": "Clinical Quality Language (CQL)", "description": "Syntax highlighting, linting, and execution for the HL7 Clinical Quality Language (CQL) for VS Code", "publisher": "cqframework", diff --git a/src/__test__/suite/commands/view-elm.test.ts b/src/__test__/suite/commands/view-elm.test.ts index acbba02..abab42d 100644 --- a/src/__test__/suite/commands/view-elm.test.ts +++ b/src/__test__/suite/commands/view-elm.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { TextDocument, Uri, commands, window, workspace } from 'vscode'; -import { viewElm, buildAstLineIndex, buildLocatorKey, sortAstBySourceOrder } from '../../../commands/view-elm'; +import { viewElm } from '../../../commands/view-elm'; const MOCK_JSON_ELM = JSON.stringify({ library: { @@ -137,342 +137,6 @@ suite('viewElm()', () => { }); }); -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)'); - }); -}); - suite('viewElm() AST reuse', () => { const MOCK_AST = `Library: SimpleMeasure (version 1.0.0) ├── define: "Patient" diff --git a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts index 81cd670..7bf5e16 100644 --- a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { CqlDebugAstTrackerFactory } from '../../../debug/cqlDebugAstTracker'; -import * as viewElm from '../../../commands/view-elm'; +import * as sessionMgr from '../../../views/astSplitSession'; suite('CqlDebugAstTracker', () => { let sandbox: sinon.SinonSandbox; @@ -16,7 +16,7 @@ suite('CqlDebugAstTracker', () => { highlightCqlSpan: sandbox.spy(), noteExternalReveal: sandbox.spy(), }; - getActiveSplitDebugHookStub = sandbox.stub(viewElm, 'getActiveSplitDebugHook'); + getActiveSplitDebugHookStub = sandbox.stub(sessionMgr.AstSplitSessionManager, 'getActiveSession'); }); teardown(() => { diff --git a/src/__test__/suite/debug/stepGranularityToggle.test.ts b/src/__test__/suite/debug/stepGranularityToggle.test.ts index 87db027..e5a7d59 100644 --- a/src/__test__/suite/debug/stepGranularityToggle.test.ts +++ b/src/__test__/suite/debug/stepGranularityToggle.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { Commands } from '../../../commands/commands'; -import * as viewElm from '../../../commands/view-elm'; +import * as sessionMgr from '../../../views/astSplitSession'; import { activateStepGranularityToggle } from '../../../debug/stepGranularityToggle'; suite('stepGranularityToggle', () => { @@ -75,7 +75,7 @@ suite('stepGranularityToggle', () => { }); test('opens split view when toggling to ast with no active split session', async () => { - sandbox.stub(viewElm, 'getActiveSplitDebugHook').returns(undefined); + sandbox.stub(sessionMgr.AstSplitSessionManager, 'getActiveSession').returns(undefined); sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [ { document: { uri: { fsPath: '/test/file.cql' } } } as any, ]); @@ -108,9 +108,10 @@ suite('stepGranularityToggle', () => { }); test('does not open split view when toggling to ast if split session already active', async () => { - sandbox.stub(viewElm, 'getActiveSplitDebugHook').returns({ + sandbox.stub(sessionMgr.AstSplitSessionManager, 'getActiveSession').returns({ highlightCqlSpan: sandbox.spy(), noteExternalReveal: sandbox.spy(), + swapLibrary: sandbox.spy(), }); const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); 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/commands/view-elm.ts b/src/commands/view-elm.ts index b98c2e7..d7631d5 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -1,74 +1,20 @@ import { commands, - debug, - DecorationOptions, ExtensionContext, - OverviewRulerLane, - Position, Range, - Selection, TextDocument, - 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 { Commands } from './commands'; - -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 CqlSpan { - line: number; - column: number; - endLine: number; - endColumn: number; - localId?: string; -} - -let activeSplitSession: - | { dispose: () => void; astEditor: TextEditor; cqlUri: string } - | undefined; - -export interface ActiveSplitDebugHook { - highlightCqlSpan(span: CqlSpan): void; - noteExternalReveal(): void; -} - -let activeSplitDebugHook: ActiveSplitDebugHook | undefined; - -export function getActiveSplitDebugHook(): ActiveSplitDebugHook | undefined { - return activeSplitDebugHook; -} +import { formatJson } from '../utils/astIndex'; +import { AstSplitSessionManager } from '../views/astSplitSession'; const openAstDocs = new Map(); -export interface AstLoc { - startLine: number; - startCol: number; - endLine: number; - endCol: number; -} - -export interface AstLineIndex { - astToCqlLoc: Map; - cqlToAstLines: Map; - locatorToAstLines: Map; - localIdToAstLines: Map; -} - export function register(context: ExtensionContext): void { context.subscriptions.push( commands.registerCommand(Commands.VIEW_ELM_COMMAND_XML, async (uri: Uri) => { @@ -81,7 +27,7 @@ export function register(context: ExtensionContext): void { viewElm(uri, 'ast'); }), commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST_SPLIT, async (uri: Uri) => { - await viewElmSplit(uri); + await AstSplitSessionManager.createOrUpdateSession(uri); }), workspace.onDidCloseTextDocument(doc => { for (const [key, tracked] of openAstDocs) { @@ -111,7 +57,10 @@ export async function viewElm( if (existing && !existing.isClosed) { try { const editor = await window.showTextDocument(existing); - reused = await replaceDocumentContent(editor, formatted); + 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); } @@ -135,389 +84,3 @@ export async function viewElm( window.showErrorMessage(`Error while converting ${cqlFileUri.fsPath} to ELM. err: ${error}`); } } - -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'); -} - -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; -} - -async function replaceDocumentContent(editor: TextEditor, content: string): Promise { - const lastLine = editor.document.lineAt(editor.document.lineCount - 1); - return editor.edit(edit => - edit.replace(new Range(0, 0, lastLine.lineNumber, lastLine.text.length), content), - ); -} - -export function 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)), - ); -} - -export function syncCqlToAstBySpan( - span: CqlSpan, - lineIndex: AstLineIndex, - astEditor: TextEditor, - astDecoration: TextEditorDecorationType, -): void { - let astLines: number[] | undefined; - - if (span.localId) { - astLines = lineIndex.localIdToAstLines.get(span.localId); - } - if (!astLines || astLines.length === 0) { - const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); - astLines = lineIndex.locatorToAstLines.get(key); - } - if (!astLines || astLines.length === 0) { - astLines = lineIndex.cqlToAstLines.get(span.line - 1); - } - if (!astLines || astLines.length === 0) { - astEditor.setDecorations(astDecoration, []); - return; - } - applyAstHighlight(astLines, astEditor, astDecoration); -} - -async function viewElmSplit(cqlFileUri: Uri): Promise { - const prevAstEditor = activeSplitSession?.astEditor; - const prevCqlUri = activeSplitSession?.cqlUri; - activeSplitSession?.dispose(); - activeSplitSession = undefined; - - try { - const cqlDoc = await workspace.openTextDocument(cqlFileUri); - const astContent = await getElm(cqlFileUri, 'ast'); - const sortedAst = sortAstBySourceOrder(astContent); - let lineIndex = buildAstLineIndex(sortedAst); - - const cqlEditor = await window.showTextDocument(cqlDoc, ViewColumn.One); - const canReuse = prevAstEditor !== undefined && prevCqlUri === cqlFileUri.toString() && !prevAstEditor.document.isClosed; - const reuseOk = canReuse - ? await replaceDocumentContent(prevAstEditor!, sortedAst).catch(() => false) - : false; - let astEditor: TextEditor; - if (reuseOk) { - astEditor = prevAstEditor!; - await window.showTextDocument(astEditor.document, ViewColumn.Two); - } else { - const astDoc = await workspace.openTextDocument({ language: 'ast', content: sortedAst }); - 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, - }); - - let lastCqlRevealTime = 0; - let lastAstRevealTime = 0; - let disposed = false; - - function disposeSession(): void { - if (disposed) return; - disposed = true; - visibleRangesListener.dispose(); - selectionListener.dispose(); - closeListener.dispose(); - docSaveListener.dispose(); - cqlLineDecoration.dispose(); - cqlSpanDecoration.dispose(); - astDecoration.dispose(); - activeSplitDebugHook = undefined; - } - - function syncCqlToAst(cqlLine?: number): void { - const effectiveLine = cqlLine ?? cqlEditor.visibleRanges[0]?.start.line; - if (effectiveLine === undefined) return; - - const astLines = lineIndex.cqlToAstLines.get(effectiveLine); - if (!astLines || astLines.length === 0) { - astEditor.setDecorations(astDecoration, []); - return; - } - applyAstHighlight(astLines, astEditor, astDecoration); - } - - function syncAstToCql(astLine?: number): void { - const effectiveLine = astLine ?? astEditor.visibleRanges[0]?.start.line; - if (effectiveLine === undefined) return; - - const loc = findNearestForwardLoc(lineIndex, effectiveLine); - if (!loc) { - cqlEditor.setDecorations(cqlLineDecoration, []); - cqlEditor.setDecorations(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 = 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) { - cqlEditor.selection = new Selection(start, end); - cqlEditor.setDecorations(cqlLineDecoration, [new Range(loc.startLine - 1, 0, loc.endLine - 1, 0)]); - cqlEditor.setDecorations(cqlSpanDecoration, [{ range: vsRange } as DecorationOptions]); - return; - } - } - - cqlEditor.revealRange(vsRange, TextEditorRevealType.InCenterIfOutsideViewport); - cqlEditor.selection = new Selection(start, end); - cqlEditor.setDecorations(cqlLineDecoration, [new Range(loc.startLine - 1, 0, loc.endLine - 1, 0)]); - cqlEditor.setDecorations(cqlSpanDecoration, [{ range: vsRange } as DecorationOptions]); - } - - const visibleRangesListener = window.onDidChangeTextEditorVisibleRanges(e => { - const now = performance.now(); - if (e.textEditor === cqlEditor) { - if (now - lastAstRevealTime < 100) return; - lastCqlRevealTime = now; - syncCqlToAst(); - } else if (e.textEditor === astEditor) { - if (now - lastCqlRevealTime < 100) return; - lastAstRevealTime = now; - syncAstToCql(); - } - }); - - const selectionListener = window.onDidChangeTextEditorSelection(e => { - const now = performance.now(); - if (e.textEditor === cqlEditor) { - if (now - lastAstRevealTime < 100) return; - lastCqlRevealTime = now; - syncCqlToAst(e.selections[0].active.line); - } else if (e.textEditor === astEditor) { - if (now - lastCqlRevealTime < 100) return; - lastAstRevealTime = now; - syncAstToCql(e.selections[0].active.line); - } - }); - - const docSaveListener = workspace.onDidSaveTextDocument(async saved => { - if (saved.uri.toString() !== cqlFileUri.toString()) return; - if (disposed) return; - try { - const newAst = await getElm(cqlFileUri, 'ast'); - const newSorted = sortAstBySourceOrder(newAst); - lineIndex = buildAstLineIndex(newSorted); - await replaceDocumentContent(astEditor, newSorted); - } catch (error) { - log.debug(`Failed to refresh AST after save: ${error}`); - } - }); - - const closeListener = window.onDidChangeVisibleTextEditors(editors => { - if (!editors.includes(cqlEditor) || !editors.includes(astEditor)) { - if (disposed) return; - disposeSession(); - } - }); - - activeSplitDebugHook = { - highlightCqlSpan(span: CqlSpan) { - lastAstRevealTime = performance.now(); - syncCqlToAstBySpan(span, lineIndex, astEditor, astDecoration); - }, - noteExternalReveal() { - lastAstRevealTime = performance.now(); - }, - }; - - activeSplitSession = { dispose: disposeSession, astEditor, cqlUri: cqlFileUri.toString() }; - - // Catch up if debugger is already paused when split view opens - (async () => { - const session = debug.activeDebugSession; - if (!session || session.type !== 'cql') return; - try { - const threadsResp = await session.customRequest('threads'); - const threads: any[] = threadsResp?.threads ?? []; - for (const thread of threads) { - if (thread.id === undefined) continue; - try { - const resp = await session.customRequest('stackTrace', { - threadId: thread.id, - startFrame: 0, - levels: 1, - }); - const top = resp?.stackFrames?.[0]; - if (top && typeof top.line === 'number') { - activeSplitDebugHook?.highlightCqlSpan({ - line: top.line, - column: top.column ?? 1, - endLine: top.endLine ?? top.line, - endColumn: top.endColumn ?? top.column ?? 1, - ...(top.instructionPointerReference ? { localId: top.instructionPointerReference } : {}), - }); - return; - } - } catch { /* try next thread */ } - } - } catch { /* session ended */ } - })(); - } catch (error) { - window.showErrorMessage( - `Error opening CQL/AST split view for ${cqlFileUri.fsPath}. err: ${error}`, - ); - } -} - -function formatJson(raw: string): string { - try { - return JSON.stringify(JSON.parse(raw), null, 2); - } catch { - return raw; - } -} diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index 912dc22..9c89440 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -1,8 +1,9 @@ import * as vscode from 'vscode'; -import { getActiveSplitDebugHook } from '../commands/view-elm'; import * as log from '../log-services/logger'; +import { AstSplitSessionManager } from '../views/astSplitSession'; let lastStoppedThreadId: number | undefined; +let activeCqlPath: string | undefined; export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFactory { createDebugAdapterTracker(session: vscode.DebugSession): vscode.DebugAdapterTracker { @@ -12,10 +13,12 @@ export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFact if (message.type === 'event' && message.event === 'terminated') { log.debug('DAP terminated event received'); + activeCqlPath = undefined; return; } if (message.type === 'event' && message.event === 'exited') { log.debug('DAP exited event received', { exitCode: message.body?.exitCode }); + activeCqlPath = undefined; return; } @@ -33,7 +36,7 @@ export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFact Array.isArray(message.body?.stackFrames) && message.body.stackFrames.length > 0 ) { - applyTopFrame(message.body.stackFrames[0]); + await applyTopFrame(message.body.stackFrames[0], session); } }, }; @@ -52,24 +55,36 @@ async function syncFromStackTrace( levels: 1, }); const top = resp?.stackFrames?.[0]; - if (top) applyTopFrame(top); + if (top) await applyTopFrame(top, session); } catch { // Session may have ended between stopped event and our request. } } -function applyTopFrame(frame: { - line?: number; - column?: number; - endLine?: number; - endColumn?: number; - instructionPointerReference?: string; - source?: { path?: string }; -}): void { - const hook = getActiveSplitDebugHook(); +async function applyTopFrame( + frame: { + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + instructionPointerReference?: string; + source?: { path?: string }; + }, + session: vscode.DebugSession, +): Promise { + const hook = AstSplitSessionManager.getActiveSession(); if (!hook) return; if (typeof frame.line !== 'number') return; - if (frame.source?.path && !frame.source.path.toLowerCase().endsWith('.cql')) return; + + const sourcePath = frame.source?.path; + if (sourcePath && !sourcePath.toLowerCase().endsWith('.cql')) return; + + if (sourcePath && sourcePath !== activeCqlPath) { + const swapped = await hook.swapLibrary(sourcePath); + if (!swapped) return; // swap failed — don't highlight stale content + activeCqlPath = sourcePath; + } + hook.highlightCqlSpan({ line: frame.line, column: frame.column ?? 1, diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts index 21f4753..1070d3a 100644 --- a/src/debug/stepGranularityToggle.ts +++ b/src/debug/stepGranularityToggle.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { Commands } from '../commands/commands'; -import { getActiveSplitDebugHook } from '../commands/view-elm'; import * as log from '../log-services/logger'; +import { AstSplitSessionManager } from '../views/astSplitSession'; type Granularity = 'cql' | 'ast'; const sessionGranularity = new WeakMap(); @@ -34,7 +34,7 @@ export function activateStepGranularityToggle(context: vscode.ExtensionContext) await s.customRequest('setStepGranularity', { granularity: next }); sessionGranularity.set(s, next); refresh(s); - if (next === 'ast' && !getActiveSplitDebugHook()) { + if (next === 'ast' && !AstSplitSessionManager.getActiveSession()) { const cqlEditor = vscode.window.visibleTextEditors.find( e => e.document.uri.fsPath.toLowerCase().endsWith('.cql'), ); diff --git a/src/debug/types.ts b/src/debug/types.ts new file mode 100644 index 0000000..c9a14f1 --- /dev/null +++ b/src/debug/types.ts @@ -0,0 +1,13 @@ +export interface CqlSpan { + line: number; + column: number; + endLine: number; + endColumn: number; + localId?: string; +} + +export interface ActiveSplitDebugHook { + highlightCqlSpan(span: CqlSpan): void; + noteExternalReveal(): void; + swapLibrary(newCqlPath: string): Promise; +} diff --git a/src/utils/astIndex.ts b/src/utils/astIndex.ts new file mode 100644 index 0000000..d31475e --- /dev/null +++ b/src/utils/astIndex.ts @@ -0,0 +1,136 @@ +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 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..1cbfc8b --- /dev/null +++ b/src/views/astSplitSession.ts @@ -0,0 +1,385 @@ +import * as path from 'path'; +import { + commands, + debug, + DecorationOptions, + OverviewRulerLane, + Position, + Range, + Selection, + TextEditor, + TextEditorDecorationType, + TextEditorRevealType, + ThemeColor, + Uri, + ViewColumn, + window, + workspace, +} from 'vscode'; +import { ActiveSplitDebugHook, CqlSpan } from '../debug/types'; +import { getElm } from '../cql-service/cqlService.getElm'; +import * as log from '../log-services/logger'; +import { + AstLineIndex, + buildAstLineIndex, + buildLocatorKey, + findNearestForwardLoc, + sortAstBySourceOrder, +} from '../utils/astIndex'; + +export class SplitViewSession implements ActiveSplitDebugHook { + private cqlUri: Uri; + private currentCqlUri: 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 constructor( + cqlUri: Uri, + currentCqlUri: Uri, + cqlEditor: TextEditor, + astEditor: TextEditor, + lineIndex: AstLineIndex, + astDecoration: TextEditorDecorationType, + cqlLineDecoration: TextEditorDecorationType, + cqlSpanDecoration: TextEditorDecorationType, + ) { + this.cqlUri = cqlUri; + this.currentCqlUri = currentCqlUri; + this.cqlEditor = cqlEditor; + this.astEditor = astEditor; + this.lineIndex = lineIndex; + this.astDecoration = astDecoration; + this.cqlLineDecoration = cqlLineDecoration; + this.cqlSpanDecoration = cqlSpanDecoration; + } + + 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, cqlUri, cqlEditor, astEditor, lineIndex, + astDecoration, cqlLineDecoration, cqlSpanDecoration, + ); + + session.setupSyncListeners(); + session.catchUpToDebugger(); + + return session; + } + + // ─── ActiveSplitDebugHook implementation ─── + + highlightCqlSpan(span: CqlSpan): void { + this.lastAstRevealTime = performance.now(); + this.syncCqlToAstBySpan(span); + } + + noteExternalReveal(): void { + this.lastAstRevealTime = performance.now(); + } + + async swapLibrary(newCqlPath: string): Promise { + const newCqlUri = Uri.file(newCqlPath); + + let newAst: string; + try { + newAst = await getElm(newCqlUri, 'ast'); + } catch (error) { + log.debug(`swapLibrary: getElm failed for ${newCqlPath}: ${error}`); + window.showWarningMessage( + `CQL debugger: could not load AST for ${path.basename(newCqlPath)}`, + ); + return false; + } + + const newSorted = sortAstBySourceOrder(newAst); + this.lineIndex = buildAstLineIndex(newSorted); + await this.replaceDocumentContent(this.astEditor, newSorted); + + const newCqlDoc = await workspace.openTextDocument(newCqlUri); + const newCqlEditor = await window.showTextDocument( + newCqlDoc, + { viewColumn: this.cqlEditor.viewColumn, preserveFocus: true, preview: false }, + ); + + this.teardownListeners(); + this.cqlEditor = newCqlEditor; + this.currentCqlUri = newCqlUri; + this.setupSyncListeners(); + + return true; + } + + // ─── Lifecycle ─── + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.teardownListeners(); + this.closeListener.dispose(); + this.docSaveListener.dispose(); + this.cqlLineDecoration.dispose(); + this.cqlSpanDecoration.dispose(); + this.astDecoration.dispose(); + AstSplitSessionManager.clearSession(); + } + + // ─── 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 syncCqlToAstBySpan(span: CqlSpan): void { + let astLines: number[] | undefined; + + if (span.localId) { + astLines = this.lineIndex.localIdToAstLines.get(span.localId); + } + if (!astLines || astLines.length === 0) { + const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); + astLines = this.lineIndex.locatorToAstLines.get(key); + } + if (!astLines || astLines.length === 0) { + astLines = this.lineIndex.cqlToAstLines.get(span.line - 1); + } + if (!astLines || astLines.length === 0) { + this.astEditor.setDecorations(this.astDecoration, []); + return; + } + this.applyAstHighlight(astLines, this.astEditor, this.astDecoration); + } + + // ─── 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.currentCqlUri.toString()) return; + if (this.disposed) return; + try { + const newAst = await getElm(this.currentCqlUri, '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(); + } + + private async replaceDocumentContent(editor: TextEditor, content: string): Promise { + const lastLine = editor.document.lineAt(editor.document.lineCount - 1); + return (await editor.edit(edit => + edit.replace(new Range(0, 0, lastLine.lineNumber, lastLine.text.length), content), + )) ?? false; + } + + private async catchUpToDebugger(): Promise { + const session = debug.activeDebugSession; + if (!session || session.type !== 'cql') return; + try { + const threadsResp = await session.customRequest('threads'); + const threads: any[] = threadsResp?.threads ?? []; + for (const thread of threads) { + if (thread.id === undefined) continue; + try { + const resp = await session.customRequest('stackTrace', { + threadId: thread.id, + startFrame: 0, + levels: 1, + }); + const top = resp?.stackFrames?.[0]; + if (top && typeof top.line === 'number') { + this.highlightCqlSpan({ + line: top.line, + column: top.column ?? 1, + endLine: top.endLine ?? top.line, + endColumn: top.endColumn ?? top.column ?? 1, + ...(top.instructionPointerReference ? { localId: top.instructionPointerReference } : {}), + }); + return; + } + } catch { /* try next thread */ } + } + } catch { /* session ended */ } + } +} + +export class AstSplitSessionManager { + private static activeSession: SplitViewSession | undefined; + + static getActiveSession(): ActiveSplitDebugHook | undefined { + return AstSplitSessionManager.activeSession; + } + + static async createOrUpdateSession(cqlUri: Uri): Promise { + AstSplitSessionManager.activeSession?.dispose(); + const session = await SplitViewSession.create(cqlUri); + AstSplitSessionManager.activeSession = session; + return session; + } + + static clearSession(): void { + AstSplitSessionManager.activeSession = undefined; + } +} From c99b99de740e31d109c890cf20635e8929de119f Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 9 Jun 2026 13:15:44 -0600 Subject: [PATCH 24/38] add timestamp to server logs --- src/log-services/multi-transport-logger.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 6d30df25e5d2a4dce1677096561c735610b50e82 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 11 Jun 2026 16:09:22 -0600 Subject: [PATCH 25/38] debugger ui updates --- coverage/base.css | 224 ++++++++++++++++++ coverage/block-navigation.js | 87 +++++++ coverage/favicon.png | Bin 0 -> 445 bytes coverage/index.html | 101 ++++++++ coverage/prettify.css | 1 + coverage/prettify.js | 2 + coverage/sort-arrow-sprite.png | Bin 0 -> 138 bytes coverage/sorter.js | 210 ++++++++++++++++ src/__test__/suite/debug/astIndex.test.ts | 20 ++ .../suite/debug/cqlDebugAstTracker.test.ts | 33 +++ src/commands/view-elm.ts | 4 +- src/debug/cqlDebugAstTracker.ts | 37 ++- src/debug/debugAstFetcher.ts | 23 ++ src/debug/stepGranularityToggle.ts | 2 + src/debug/types.ts | 18 ++ src/utils/astIndex.ts | 6 + src/views/astSplitSession.ts | 78 ++++-- 17 files changed, 810 insertions(+), 36 deletions(-) create mode 100644 coverage/base.css create mode 100644 coverage/block-navigation.js create mode 100644 coverage/favicon.png create mode 100644 coverage/index.html create mode 100644 coverage/prettify.css create mode 100644 coverage/prettify.js create mode 100644 coverage/sort-arrow-sprite.png create mode 100644 coverage/sorter.js create mode 100644 src/__test__/suite/debug/astIndex.test.ts create mode 100644 src/debug/debugAstFetcher.ts diff --git a/coverage/base.css b/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/favicon.png b/coverage/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ Unknown% + Statements + 0/0 +
+ + +
+ Unknown% + Branches + 0/0 +
+ + +
+ Unknown% + Functions + 0/0 +
+ + +
+ Unknown% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/coverage/sorter.js b/coverage/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); 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/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts index 7bf5e16..7c688a3 100644 --- a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { CqlDebugAstTrackerFactory } from '../../../debug/cqlDebugAstTracker'; +import { normalizeSpan } from '../../../debug/types'; import * as sessionMgr from '../../../views/astSplitSession'; suite('CqlDebugAstTracker', () => { @@ -160,3 +161,35 @@ suite('CqlDebugAstTracker', () => { // Should not throw }); }); + +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/commands/view-elm.ts b/src/commands/view-elm.ts index d7631d5..5957f09 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -26,8 +26,8 @@ export function register(context: ExtensionContext): void { commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST, async (uri: Uri) => { viewElm(uri, 'ast'); }), - commands.registerCommand(Commands.VIEW_ELM_COMMAND_AST_SPLIT, async (uri: Uri) => { - await AstSplitSessionManager.createOrUpdateSession(uri); + 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) { diff --git a/src/debug/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index 9c89440..968b705 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import * as log from '../log-services/logger'; +import { normalizeSpan } from './types'; import { AstSplitSessionManager } from '../views/astSplitSession'; let lastStoppedThreadId: number | undefined; @@ -55,9 +56,12 @@ async function syncFromStackTrace( 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 { - // Session may have ended between stopped event and our request. + } catch (e) { + log.debug('syncFromStackTrace: error threadId={} err={}', threadId, e); } } @@ -73,23 +77,32 @@ async function applyTopFrame( session: vscode.DebugSession, ): Promise { const hook = AstSplitSessionManager.getActiveSession(); - if (!hook) return; - if (typeof frame.line !== 'number') return; + if (!hook) { + log.debug('applyTopFrame: no active split session'); + 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')) return; + if (sourcePath && !sourcePath.toLowerCase().endsWith('.cql')) { + log.debug('applyTopFrame: non-CQL source path={}', sourcePath); + return; + } + + log.debug('applyTopFrame: sourcePath={} line={} activeCqlPath={} needsSwap={}', + sourcePath, frame.line, activeCqlPath, sourcePath && sourcePath !== activeCqlPath); if (sourcePath && sourcePath !== activeCqlPath) { const swapped = await hook.swapLibrary(sourcePath); + log.debug('applyTopFrame: swapLibrary result={} for path={}', swapped, sourcePath); if (!swapped) return; // swap failed — don't highlight stale content activeCqlPath = sourcePath; } - hook.highlightCqlSpan({ - line: frame.line, - column: frame.column ?? 1, - endLine: frame.endLine ?? frame.line, - endColumn: frame.endColumn ?? frame.column ?? 1, - ...(frame.instructionPointerReference ? { localId: frame.instructionPointerReference } : {}), - }); + const span = normalizeSpan(frame); + log.debug('applyTopFrame: highlighting span={}', span); + hook.highlightCqlSpan(span); } 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 index 1070d3a..1625f45 100644 --- a/src/debug/stepGranularityToggle.ts +++ b/src/debug/stepGranularityToggle.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { Commands } from '../commands/commands'; +import { fetchAstViaDap } from './debugAstFetcher'; import * as log from '../log-services/logger'; import { AstSplitSessionManager } from '../views/astSplitSession'; @@ -42,6 +43,7 @@ export function activateStepGranularityToggle(context: vscode.ExtensionContext) await vscode.commands.executeCommand( Commands.VIEW_ELM_COMMAND_AST_SPLIT, cqlEditor.document.uri, + fetchAstViaDap, ); } } diff --git a/src/debug/types.ts b/src/debug/types.ts index c9a14f1..3071572 100644 --- a/src/debug/types.ts +++ b/src/debug/types.ts @@ -11,3 +11,21 @@ export interface ActiveSplitDebugHook { noteExternalReveal(): void; swapLibrary(newCqlPath: string): Promise; } + +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/utils/astIndex.ts b/src/utils/astIndex.ts index d31475e..89b8ebd 100644 --- a/src/utils/astIndex.ts +++ b/src/utils/astIndex.ts @@ -127,6 +127,12 @@ export function findNearestForwardLoc( 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); diff --git a/src/views/astSplitSession.ts b/src/views/astSplitSession.ts index 1cbfc8b..40cade7 100644 --- a/src/views/astSplitSession.ts +++ b/src/views/astSplitSession.ts @@ -16,7 +16,7 @@ import { window, workspace, } from 'vscode'; -import { ActiveSplitDebugHook, CqlSpan } from '../debug/types'; +import { ActiveSplitDebugHook, CqlSpan, normalizeSpan } from '../debug/types'; import { getElm } from '../cql-service/cqlService.getElm'; import * as log from '../log-services/logger'; import { @@ -24,6 +24,7 @@ import { buildAstLineIndex, buildLocatorKey, findNearestForwardLoc, + hasFullCoordinates, sortAstBySourceOrder, } from '../utils/astIndex'; @@ -43,6 +44,7 @@ export class SplitViewSession implements ActiveSplitDebugHook { private selectionListener!: { dispose(): void }; private closeListener!: { dispose(): void }; private docSaveListener!: { dispose(): void }; + private fetcher: (uri: Uri, type: 'ast') => Promise; private constructor( cqlUri: Uri, @@ -53,6 +55,7 @@ export class SplitViewSession implements ActiveSplitDebugHook { astDecoration: TextEditorDecorationType, cqlLineDecoration: TextEditorDecorationType, cqlSpanDecoration: TextEditorDecorationType, + fetcher: (uri: Uri, type: 'ast') => Promise, ) { this.cqlUri = cqlUri; this.currentCqlUri = currentCqlUri; @@ -62,6 +65,7 @@ export class SplitViewSession implements ActiveSplitDebugHook { this.astDecoration = astDecoration; this.cqlLineDecoration = cqlLineDecoration; this.cqlSpanDecoration = cqlSpanDecoration; + this.fetcher = fetcher; } static async create( @@ -103,6 +107,7 @@ export class SplitViewSession implements ActiveSplitDebugHook { const session = new SplitViewSession( cqlUri, cqlUri, cqlEditor, astEditor, lineIndex, astDecoration, cqlLineDecoration, cqlSpanDecoration, + fetcher, ); session.setupSyncListeners(); @@ -124,20 +129,25 @@ export class SplitViewSession implements ActiveSplitDebugHook { async swapLibrary(newCqlPath: string): Promise { const newCqlUri = Uri.file(newCqlPath); + log.debug('swapLibrary: enter newCqlPath={} currentCqlUri={}', newCqlPath, this.currentCqlUri.toString()); let newAst: string; try { - newAst = await getElm(newCqlUri, 'ast'); + newAst = await this.fetcher(newCqlUri, 'ast'); + log.debug('swapLibrary: AST fetched successfully ({} chars) for {}', newAst.length, newCqlPath); } catch (error) { - log.debug(`swapLibrary: getElm failed for ${newCqlPath}: ${error}`); + log.debug(`swapLibrary: fetcher failed for ${newCqlPath}: ${error}`); window.showWarningMessage( `CQL debugger: could not load AST for ${path.basename(newCqlPath)}`, ); return false; } + this.teardownListeners(); + const newSorted = sortAstBySourceOrder(newAst); this.lineIndex = buildAstLineIndex(newSorted); + log.debug('swapLibrary: AST sorted, lineIndex built ({} cqlToAstLines)', this.lineIndex.cqlToAstLines.size); await this.replaceDocumentContent(this.astEditor, newSorted); const newCqlDoc = await workspace.openTextDocument(newCqlUri); @@ -146,11 +156,11 @@ export class SplitViewSession implements ActiveSplitDebugHook { { viewColumn: this.cqlEditor.viewColumn, preserveFocus: true, preview: false }, ); - this.teardownListeners(); this.cqlEditor = newCqlEditor; this.currentCqlUri = newCqlUri; this.setupSyncListeners(); + log.debug('swapLibrary: completed successfully for {}', newCqlPath); return true; } @@ -160,8 +170,6 @@ export class SplitViewSession implements ActiveSplitDebugHook { if (this.disposed) return; this.disposed = true; this.teardownListeners(); - this.closeListener.dispose(); - this.docSaveListener.dispose(); this.cqlLineDecoration.dispose(); this.cqlSpanDecoration.dispose(); this.astDecoration.dispose(); @@ -254,21 +262,30 @@ export class SplitViewSession implements ActiveSplitDebugHook { private syncCqlToAstBySpan(span: CqlSpan): void { let astLines: number[] | undefined; + let matchSource: string | undefined; if (span.localId) { astLines = this.lineIndex.localIdToAstLines.get(span.localId); + if (astLines && astLines.length > 0) matchSource = 'localId'; } if (!astLines || astLines.length === 0) { - const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); - astLines = this.lineIndex.locatorToAstLines.get(key); + if (hasFullCoordinates(span)) { + const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); + astLines = this.lineIndex.locatorToAstLines.get(key); + if (astLines && astLines.length > 0) matchSource = `locator:${key}`; + } } if (!astLines || astLines.length === 0) { astLines = this.lineIndex.cqlToAstLines.get(span.line - 1); + if (astLines && astLines.length > 0) matchSource = 'cqlLine'; } if (!astLines || astLines.length === 0) { + log.debug('syncCqlToAstBySpan: no match for span={} (localId={}, locator, cqlLine all failed)', + span, span.localId); this.astEditor.setDecorations(this.astDecoration, []); return; } + log.debug('syncCqlToAstBySpan: match source={} span={} astLines={}', matchSource, span, astLines); this.applyAstHighlight(astLines, this.astEditor, this.astDecoration); } @@ -305,7 +322,7 @@ export class SplitViewSession implements ActiveSplitDebugHook { if (saved.uri.toString() !== this.currentCqlUri.toString()) return; if (this.disposed) return; try { - const newAst = await getElm(this.currentCqlUri, 'ast'); + const newAst = await this.fetcher(this.currentCqlUri, 'ast'); const newSorted = sortAstBySourceOrder(newAst); this.lineIndex = buildAstLineIndex(newSorted); await this.replaceDocumentContent(this.astEditor, newSorted); @@ -325,21 +342,30 @@ export class SplitViewSession implements ActiveSplitDebugHook { 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); - return (await editor.edit(edit => + 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; } private async catchUpToDebugger(): Promise { const session = debug.activeDebugSession; - if (!session || session.type !== 'cql') return; + if (!session || session.type !== 'cql') { + log.debug('catchUpToDebugger: no active CQL session'); + return; + } try { const threadsResp = await session.customRequest('threads'); const threads: any[] = threadsResp?.threads ?? []; + log.debug('catchUpToDebugger: {} threads found', threads.length); for (const thread of threads) { if (thread.id === undefined) continue; try { @@ -350,18 +376,23 @@ export class SplitViewSession implements ActiveSplitDebugHook { }); const top = resp?.stackFrames?.[0]; if (top && typeof top.line === 'number') { - this.highlightCqlSpan({ - line: top.line, - column: top.column ?? 1, - endLine: top.endLine ?? top.line, - endColumn: top.endColumn ?? top.column ?? 1, - ...(top.instructionPointerReference ? { localId: top.instructionPointerReference } : {}), - }); + const sourcePath = top.source?.path; + log.debug('catchUpToDebugger: thread={} line={} source={} localId={}', + thread.id, top.line, sourcePath, top.instructionPointerReference); + if (sourcePath && sourcePath.toLowerCase().endsWith('.cql') && + Uri.file(sourcePath).toString() !== this.currentCqlUri.toString()) { + await this.swapLibrary(sourcePath); + } + this.highlightCqlSpan(normalizeSpan(top)); return; } - } catch { /* try next thread */ } + } catch (e) { + log.debug('catchUpToDebugger: stackTrace error thread={} err={}', thread.id, e); + } } - } catch { /* session ended */ } + } catch (e) { + log.debug('catchUpToDebugger: threads error err={}', e); + } } } @@ -372,9 +403,12 @@ export class AstSplitSessionManager { return AstSplitSessionManager.activeSession; } - static async createOrUpdateSession(cqlUri: Uri): Promise { + static async createOrUpdateSession( + cqlUri: Uri, + fetcher?: (uri: Uri, type: 'ast') => Promise, + ): Promise { AstSplitSessionManager.activeSession?.dispose(); - const session = await SplitViewSession.create(cqlUri); + const session = await SplitViewSession.create(cqlUri, fetcher); AstSplitSessionManager.activeSession = session; return session; } From 2b724bc8dbdfe66a2f3de374081627eccfea1f20 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 12 Jun 2026 03:37:11 -0600 Subject: [PATCH 26/38] replace debug ast split view with treeview --- .gitignore | 1 + .vscode-test.js | 1 + coverage/base.css | 224 ------------ coverage/block-navigation.js | 87 ----- coverage/favicon.png | Bin 445 -> 0 bytes coverage/index.html | 101 ------ coverage/prettify.css | 1 - coverage/prettify.js | 2 - coverage/sort-arrow-sprite.png | Bin 138 -> 0 bytes coverage/sorter.js | 210 ----------- package.json | 16 + .../debug/cqlAstDebugViewController.test.ts | 174 +++++++++ .../debug/cqlAstTreeDataProvider.test.ts | 201 +++++++++++ .../suite/debug/cqlDebugAstTracker.test.ts | 78 ++-- .../suite/debug/stepGranularityToggle.test.ts | 34 +- src/__test__/suite/extension/commands.test.ts | 5 + src/commands/view-elm.ts | 9 +- src/debug/cqlAstDebugViewController.ts | 334 ++++++++++++++++++ src/debug/cqlAstTreeDataProvider.ts | 118 +++++++ src/debug/cqlAstTreeNode.ts | 178 ++++++++++ src/debug/cqlDebugAstTracker.ts | 29 +- src/debug/stepGranularityToggle.ts | 20 +- src/debug/types.ts | 6 - src/extension.ts | 3 + src/views/astSplitSession.ts | 144 +------- 25 files changed, 1094 insertions(+), 882 deletions(-) delete mode 100644 coverage/base.css delete mode 100644 coverage/block-navigation.js delete mode 100644 coverage/favicon.png delete mode 100644 coverage/index.html delete mode 100644 coverage/prettify.css delete mode 100644 coverage/prettify.js delete mode 100644 coverage/sort-arrow-sprite.png delete mode 100644 coverage/sorter.js create mode 100644 src/__test__/suite/debug/cqlAstDebugViewController.test.ts create mode 100644 src/__test__/suite/debug/cqlAstTreeDataProvider.test.ts create mode 100644 src/debug/cqlAstDebugViewController.ts create mode 100644 src/debug/cqlAstTreeDataProvider.ts create mode 100644 src/debug/cqlAstTreeNode.ts 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/coverage/base.css b/coverage/base.css deleted file mode 100644 index f418035..0000000 --- a/coverage/base.css +++ /dev/null @@ -1,224 +0,0 @@ -body, html { - margin:0; padding: 0; - height: 100%; -} -body { - font-family: Helvetica Neue, Helvetica, Arial; - font-size: 14px; - color:#333; -} -.small { font-size: 12px; } -*, *:after, *:before { - -webkit-box-sizing:border-box; - -moz-box-sizing:border-box; - box-sizing:border-box; - } -h1 { font-size: 20px; margin: 0;} -h2 { font-size: 14px; } -pre { - font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; - margin: 0; - padding: 0; - -moz-tab-size: 2; - -o-tab-size: 2; - tab-size: 2; -} -a { color:#0074D9; text-decoration:none; } -a:hover { text-decoration:underline; } -.strong { font-weight: bold; } -.space-top1 { padding: 10px 0 0 0; } -.pad2y { padding: 20px 0; } -.pad1y { padding: 10px 0; } -.pad2x { padding: 0 20px; } -.pad2 { padding: 20px; } -.pad1 { padding: 10px; } -.space-left2 { padding-left:55px; } -.space-right2 { padding-right:20px; } -.center { text-align:center; } -.clearfix { display:block; } -.clearfix:after { - content:''; - display:block; - height:0; - clear:both; - visibility:hidden; - } -.fl { float: left; } -@media only screen and (max-width:640px) { - .col3 { width:100%; max-width:100%; } - .hide-mobile { display:none!important; } -} - -.quiet { - color: #7f7f7f; - color: rgba(0,0,0,0.5); -} -.quiet a { opacity: 0.7; } - -.fraction { - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: 10px; - color: #555; - background: #E8E8E8; - padding: 4px 5px; - border-radius: 3px; - vertical-align: middle; -} - -div.path a:link, div.path a:visited { color: #333; } -table.coverage { - border-collapse: collapse; - margin: 10px 0 0 0; - padding: 0; -} - -table.coverage td { - margin: 0; - padding: 0; - vertical-align: top; -} -table.coverage td.line-count { - text-align: right; - padding: 0 5px 0 20px; -} -table.coverage td.line-coverage { - text-align: right; - padding-right: 10px; - min-width:20px; -} - -table.coverage td span.cline-any { - display: inline-block; - padding: 0 5px; - width: 100%; -} -.missing-if-branch { - display: inline-block; - margin-right: 5px; - border-radius: 3px; - position: relative; - padding: 0 4px; - background: #333; - color: yellow; -} - -.skip-if-branch { - display: none; - margin-right: 10px; - position: relative; - padding: 0 4px; - background: #ccc; - color: white; -} -.missing-if-branch .typ, .skip-if-branch .typ { - color: inherit !important; -} -.coverage-summary { - border-collapse: collapse; - width: 100%; -} -.coverage-summary tr { border-bottom: 1px solid #bbb; } -.keyline-all { border: 1px solid #ddd; } -.coverage-summary td, .coverage-summary th { padding: 10px; } -.coverage-summary tbody { border: 1px solid #bbb; } -.coverage-summary td { border-right: 1px solid #bbb; } -.coverage-summary td:last-child { border-right: none; } -.coverage-summary th { - text-align: left; - font-weight: normal; - white-space: nowrap; -} -.coverage-summary th.file { border-right: none !important; } -.coverage-summary th.pct { } -.coverage-summary th.pic, -.coverage-summary th.abs, -.coverage-summary td.pct, -.coverage-summary td.abs { text-align: right; } -.coverage-summary td.file { white-space: nowrap; } -.coverage-summary td.pic { min-width: 120px !important; } -.coverage-summary tfoot td { } - -.coverage-summary .sorter { - height: 10px; - width: 7px; - display: inline-block; - margin-left: 0.5em; - background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; -} -.coverage-summary .sorted .sorter { - background-position: 0 -20px; -} -.coverage-summary .sorted-desc .sorter { - background-position: 0 -10px; -} -.status-line { height: 10px; } -/* yellow */ -.cbranch-no { background: yellow !important; color: #111; } -/* dark red */ -.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } -.low .chart { border:1px solid #C21F39 } -.highlighted, -.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ - background: #C21F39 !important; -} -/* medium red */ -.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } -/* light red */ -.low, .cline-no { background:#FCE1E5 } -/* light green */ -.high, .cline-yes { background:rgb(230,245,208) } -/* medium green */ -.cstat-yes { background:rgb(161,215,106) } -/* dark green */ -.status-line.high, .high .cover-fill { background:rgb(77,146,33) } -.high .chart { border:1px solid rgb(77,146,33) } -/* dark yellow (gold) */ -.status-line.medium, .medium .cover-fill { background: #f9cd0b; } -.medium .chart { border:1px solid #f9cd0b; } -/* light yellow */ -.medium { background: #fff4c2; } - -.cstat-skip { background: #ddd; color: #111; } -.fstat-skip { background: #ddd; color: #111 !important; } -.cbranch-skip { background: #ddd !important; color: #111; } - -span.cline-neutral { background: #eaeaea; } - -.coverage-summary td.empty { - opacity: .5; - padding-top: 4px; - padding-bottom: 4px; - line-height: 1; - color: #888; -} - -.cover-fill, .cover-empty { - display:inline-block; - height: 12px; -} -.chart { - line-height: 0; -} -.cover-empty { - background: white; -} -.cover-full { - border-right: none !important; -} -pre.prettyprint { - border: none !important; - padding: 0 !important; - margin: 0 !important; -} -.com { color: #999 !important; } -.ignore-none { color: #999; font-weight: normal; } - -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -48px; -} -.footer, .push { - height: 48px; -} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js deleted file mode 100644 index 530d1ed..0000000 --- a/coverage/block-navigation.js +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable */ -var jumpToCode = (function init() { - // Classes of code we would like to highlight in the file view - var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; - - // Elements to highlight in the file listing view - var fileListingElements = ['td.pct.low']; - - // We don't want to select elements that are direct descendants of another match - var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` - - // Selector that finds elements on the page to which we can jump - var selector = - fileListingElements.join(', ') + - ', ' + - notSelector + - missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` - - // The NodeList of matching elements - var missingCoverageElements = document.querySelectorAll(selector); - - var currentIndex; - - function toggleClass(index) { - missingCoverageElements - .item(currentIndex) - .classList.remove('highlighted'); - missingCoverageElements.item(index).classList.add('highlighted'); - } - - function makeCurrent(index) { - toggleClass(index); - currentIndex = index; - missingCoverageElements.item(index).scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }); - } - - function goToPrevious() { - var nextIndex = 0; - if (typeof currentIndex !== 'number' || currentIndex === 0) { - nextIndex = missingCoverageElements.length - 1; - } else if (missingCoverageElements.length > 1) { - nextIndex = currentIndex - 1; - } - - makeCurrent(nextIndex); - } - - function goToNext() { - var nextIndex = 0; - - if ( - typeof currentIndex === 'number' && - currentIndex < missingCoverageElements.length - 1 - ) { - nextIndex = currentIndex + 1; - } - - makeCurrent(nextIndex); - } - - return function jump(event) { - if ( - document.getElementById('fileSearch') === document.activeElement && - document.activeElement != null - ) { - // if we're currently focused on the search input, we don't want to navigate - return; - } - - switch (event.which) { - case 78: // n - case 74: // j - goToNext(); - break; - case 66: // b - case 75: // k - case 80: // p - goToPrevious(); - break; - } - }; -})(); -window.addEventListener('keydown', jumpToCode); diff --git a/coverage/favicon.png b/coverage/favicon.png deleted file mode 100644 index c1525b811a167671e9de1fa78aab9f5c0b61cef7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> - - - - Code coverage report for All files - - - - - - - - - -
-
-

All files

-
- -
- Unknown% - Statements - 0/0 -
- - -
- Unknown% - Branches - 0/0 -
- - -
- Unknown% - Functions - 0/0 -
- - -
- Unknown% - Lines - 0/0 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css deleted file mode 100644 index b317a7c..0000000 --- a/coverage/prettify.css +++ /dev/null @@ -1 +0,0 @@ -.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js deleted file mode 100644 index b322523..0000000 --- a/coverage/prettify.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png deleted file mode 100644 index 6ed68316eb3f65dec9063332d2f69bf3093bbfab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc diff --git a/coverage/sorter.js b/coverage/sorter.js deleted file mode 100644 index 4ed70ae..0000000 --- a/coverage/sorter.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable */ -var addSorting = (function() { - 'use strict'; - var cols, - currentSort = { - index: 0, - desc: false - }; - - // returns the summary table element - function getTable() { - return document.querySelector('.coverage-summary'); - } - // returns the thead element of the summary table - function getTableHeader() { - return getTable().querySelector('thead tr'); - } - // returns the tbody element of the summary table - function getTableBody() { - return getTable().querySelector('tbody'); - } - // returns the th element for nth column - function getNthColumn(n) { - return getTableHeader().querySelectorAll('th')[n]; - } - - function onFilterInput() { - const searchValue = document.getElementById('fileSearch').value; - const rows = document.getElementsByTagName('tbody')[0].children; - - // Try to create a RegExp from the searchValue. If it fails (invalid regex), - // it will be treated as a plain text search - let searchRegex; - try { - searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive - } catch (error) { - searchRegex = null; - } - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - let isMatch = false; - - if (searchRegex) { - // If a valid regex was created, use it for matching - isMatch = searchRegex.test(row.textContent); - } else { - // Otherwise, fall back to the original plain text search - isMatch = row.textContent - .toLowerCase() - .includes(searchValue.toLowerCase()); - } - - row.style.display = isMatch ? '' : 'none'; - } - } - - // loads the search box - function addSearchBox() { - var template = document.getElementById('filterTemplate'); - var templateClone = template.content.cloneNode(true); - templateClone.getElementById('fileSearch').oninput = onFilterInput; - template.parentElement.appendChild(templateClone); - } - - // loads all columns - function loadColumns() { - var colNodes = getTableHeader().querySelectorAll('th'), - colNode, - cols = [], - col, - i; - - for (i = 0; i < colNodes.length; i += 1) { - colNode = colNodes[i]; - col = { - key: colNode.getAttribute('data-col'), - sortable: !colNode.getAttribute('data-nosort'), - type: colNode.getAttribute('data-type') || 'string' - }; - cols.push(col); - if (col.sortable) { - col.defaultDescSort = col.type === 'number'; - colNode.innerHTML = - colNode.innerHTML + ''; - } - } - return cols; - } - // attaches a data attribute to every tr element with an object - // of data values keyed by column name - function loadRowData(tableRow) { - var tableCols = tableRow.querySelectorAll('td'), - colNode, - col, - data = {}, - i, - val; - for (i = 0; i < tableCols.length; i += 1) { - colNode = tableCols[i]; - col = cols[i]; - val = colNode.getAttribute('data-value'); - if (col.type === 'number') { - val = Number(val); - } - data[col.key] = val; - } - return data; - } - // loads all row data - function loadData() { - var rows = getTableBody().querySelectorAll('tr'), - i; - - for (i = 0; i < rows.length; i += 1) { - rows[i].data = loadRowData(rows[i]); - } - } - // sorts the table using the data for the ith column - function sortByIndex(index, desc) { - var key = cols[index].key, - sorter = function(a, b) { - a = a.data[key]; - b = b.data[key]; - return a < b ? -1 : a > b ? 1 : 0; - }, - finalSorter = sorter, - tableBody = document.querySelector('.coverage-summary tbody'), - rowNodes = tableBody.querySelectorAll('tr'), - rows = [], - i; - - if (desc) { - finalSorter = function(a, b) { - return -1 * sorter(a, b); - }; - } - - for (i = 0; i < rowNodes.length; i += 1) { - rows.push(rowNodes[i]); - tableBody.removeChild(rowNodes[i]); - } - - rows.sort(finalSorter); - - for (i = 0; i < rows.length; i += 1) { - tableBody.appendChild(rows[i]); - } - } - // removes sort indicators for current column being sorted - function removeSortIndicators() { - var col = getNthColumn(currentSort.index), - cls = col.className; - - cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); - col.className = cls; - } - // adds sort indicators for current column being sorted - function addSortIndicators() { - getNthColumn(currentSort.index).className += currentSort.desc - ? ' sorted-desc' - : ' sorted'; - } - // adds event listeners for all sorter widgets - function enableUI() { - var i, - el, - ithSorter = function ithSorter(i) { - var col = cols[i]; - - return function() { - var desc = col.defaultDescSort; - - if (currentSort.index === i) { - desc = !currentSort.desc; - } - sortByIndex(i, desc); - removeSortIndicators(); - currentSort.index = i; - currentSort.desc = desc; - addSortIndicators(); - }; - }; - for (i = 0; i < cols.length; i += 1) { - if (cols[i].sortable) { - // add the click event handler on the th so users - // dont have to click on those tiny arrows - el = getNthColumn(i).querySelector('.sorter').parentElement; - if (el.addEventListener) { - el.addEventListener('click', ithSorter(i)); - } else { - el.attachEvent('onclick', ithSorter(i)); - } - } - } - } - // adds sorting functionality to the UI - return function() { - if (!getTable()) { - return; - } - cols = loadColumns(); - loadData(); - addSearchBox(); - addSortIndicators(); - enableUI(); - }; -})(); - -window.addEventListener('load', addSorting); diff --git a/package.json b/package.json index a4f9662..adcf668 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,11 @@ "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", @@ -368,6 +373,10 @@ "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" @@ -763,6 +772,13 @@ "id": "cql-libraries", "name": "CQL Explorer" } + ], + "debug": [ + { + "id": "cql.debug.ast", + "name": "CQL AST", + "when": "cql.debug.active" + } ] }, "debuggers": [ 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/cqlDebugAstTracker.test.ts b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts index 7c688a3..91a21fe 100644 --- a/src/__test__/suite/debug/cqlDebugAstTracker.test.ts +++ b/src/__test__/suite/debug/cqlDebugAstTracker.test.ts @@ -2,31 +2,28 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { CqlDebugAstTrackerFactory } from '../../../debug/cqlDebugAstTracker'; import { normalizeSpan } from '../../../debug/types'; -import * as sessionMgr from '../../../views/astSplitSession'; +import * as controllerModule from '../../../debug/cqlAstDebugViewController'; suite('CqlDebugAstTracker', () => { let sandbox: sinon.SinonSandbox; let factory: CqlDebugAstTrackerFactory; - let hookSpy: { highlightCqlSpan: sinon.SinonSpy; noteExternalReveal: sinon.SinonSpy }; - let getActiveSplitDebugHookStub: sinon.SinonStub; + let controllerStub: { onFrameStopped: sinon.SinonStub }; + let getControllerInstanceStub: sinon.SinonStub; setup(() => { sandbox = sinon.createSandbox(); factory = new CqlDebugAstTrackerFactory(); - hookSpy = { - highlightCqlSpan: sandbox.spy(), - noteExternalReveal: sandbox.spy(), + controllerStub = { + onFrameStopped: sandbox.stub().resolves(undefined), }; - getActiveSplitDebugHookStub = sandbox.stub(sessionMgr.AstSplitSessionManager, 'getActiveSession'); + getControllerInstanceStub = sandbox.stub(controllerModule, 'getControllerInstance').returns(controllerStub as any); }); teardown(() => { sandbox.restore(); }); - test('stopped event triggers stackTrace and hook call with 1-indexed span', async () => { - getActiveSplitDebugHookStub.returns(hookSpy); - + test('stopped event triggers stackTrace and controller call with frame', async () => { const sessionMock = { customRequest: sandbox.stub().resolves({ stackFrames: [{ line: 7 }] }), } as any; @@ -46,21 +43,20 @@ suite('CqlDebugAstTracker', () => { startFrame: 0, levels: 1, }); - expect(hookSpy.highlightCqlSpan.calledOnce).to.be.true; - expect(hookSpy.highlightCqlSpan.firstCall.args[0]).to.deep.equal({ - line: 7, - column: 1, - endLine: 7, - endColumn: 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 instructionPointerReference as localId on span', async () => { - getActiveSplitDebugHookStub.returns(hookSpy); - + 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' }], + stackFrames: [{ + line: 5, column: 3, endLine: 8, endColumn: 10, + instructionPointerReference: '208', + source: { path: '/test/file.cql' }, + }], }), } as any; @@ -72,19 +68,17 @@ suite('CqlDebugAstTracker', () => { body: { threadId: 1 }, }); - expect(hookSpy.highlightCqlSpan.calledOnce).to.be.true; - expect(hookSpy.highlightCqlSpan.firstCall.args[0]).to.deep.equal({ - line: 5, - column: 3, - endLine: 8, - endColumn: 10, - localId: '208', - }); + 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', () => { - getActiveSplitDebugHookStub.returns(hookSpy); - const sessionMock = {} as any; const tracker = factory.createDebugAdapterTracker(sessionMock); @@ -95,17 +89,13 @@ suite('CqlDebugAstTracker', () => { body: { stackFrames: [{ line: 4 }] }, }); - expect(hookSpy.highlightCqlSpan.calledOnce).to.be.true; - expect(hookSpy.highlightCqlSpan.firstCall.args[0]).to.deep.equal({ - line: 4, - column: 1, - endLine: 4, - endColumn: 1, - }); + expect(controllerStub.onFrameStopped.calledOnce).to.be.true; + expect(controllerStub.onFrameStopped.firstCall.args[0].line).to.equal(4); }); - test('no active split view: tracker swallows without throwing', async () => { - getActiveSplitDebugHookStub.returns(undefined); + 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 }] }), @@ -120,12 +110,9 @@ suite('CqlDebugAstTracker', () => { }); expect(sessionMock.customRequest.calledOnce).to.be.true; - // No error should be thrown }); test('stopped event without threadId: does not call customRequest', async () => { - getActiveSplitDebugHookStub.returns(hookSpy); - const sessionMock = { customRequest: sandbox.stub(), } as any; @@ -139,12 +126,10 @@ suite('CqlDebugAstTracker', () => { }); expect(sessionMock.customRequest.called).to.be.false; - expect(hookSpy.highlightCqlSpan.called).to.be.false; + expect(controllerStub.onFrameStopped.called).to.be.false; }); test('customRequest rejection is caught silently', async () => { - getActiveSplitDebugHookStub.returns(hookSpy); - const sessionMock = { customRequest: sandbox.stub().rejects(new Error('session ended')), } as any; @@ -158,7 +143,6 @@ suite('CqlDebugAstTracker', () => { }); expect(sessionMock.customRequest.calledOnce).to.be.true; - // Should not throw }); }); diff --git a/src/__test__/suite/debug/stepGranularityToggle.test.ts b/src/__test__/suite/debug/stepGranularityToggle.test.ts index e5a7d59..39f32ea 100644 --- a/src/__test__/suite/debug/stepGranularityToggle.test.ts +++ b/src/__test__/suite/debug/stepGranularityToggle.test.ts @@ -1,8 +1,6 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { Commands } from '../../../commands/commands'; -import * as sessionMgr from '../../../views/astSplitSession'; import { activateStepGranularityToggle } from '../../../debug/stepGranularityToggle'; suite('stepGranularityToggle', () => { @@ -74,11 +72,7 @@ suite('stepGranularityToggle', () => { expect(statusBarItemStub.hide.called).to.be.true; }); - test('opens split view when toggling to ast with no active split session', async () => { - sandbox.stub(sessionMgr.AstSplitSessionManager, 'getActiveSession').returns(undefined); - sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [ - { document: { uri: { fsPath: '/test/file.cql' } } } as any, - ]); + test('focuses AST tree view when toggling to ast', async () => { const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); const sessionMock = { @@ -103,39 +97,25 @@ suite('stepGranularityToggle', () => { await (toggleCommand as any).execute(); expect(executeCommandStub.calledOnce).to.be.true; - expect(executeCommandStub.firstCall.args[0]).to.equal(Commands.VIEW_ELM_COMMAND_AST_SPLIT); - expect(executeCommandStub.firstCall.args[1].fsPath).to.equal('/test/file.cql'); + expect(executeCommandStub.firstCall.args[0]).to.equal('cql.debug.ast.focus'); }); - test('does not open split view when toggling to ast if split session already active', async () => { - sandbox.stub(sessionMgr.AstSplitSessionManager, 'getActiveSession').returns({ - highlightCqlSpan: sandbox.spy(), - noteExternalReveal: sandbox.spy(), - swapLibrary: sandbox.spy(), - }); + test('non-CQL session does not trigger focus', async () => { const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); const sessionMock = { - type: 'cql', - configuration: { stepGranularity: 'cql' }, + type: 'not-cql', + configuration: {}, 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); + sandbox.stub(vscode.debug, 'activeDebugSession').get(() => undefined); 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.called).to.be.false; }); -}); \ No newline at end of file +}); diff --git a/src/__test__/suite/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index dee288c..ef6f280 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -12,6 +12,7 @@ const EXPECTED_COMMANDS = [ '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', @@ -55,6 +56,10 @@ const EXPECTED_COMMANDS = [ '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/commands/view-elm.ts b/src/commands/view-elm.ts index 5957f09..44f685e 100644 --- a/src/commands/view-elm.ts +++ b/src/commands/view-elm.ts @@ -26,9 +26,12 @@ export function register(context: ExtensionContext): void { 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); - }), + 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) { 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/cqlDebugAstTracker.ts b/src/debug/cqlDebugAstTracker.ts index 968b705..bc99d64 100644 --- a/src/debug/cqlDebugAstTracker.ts +++ b/src/debug/cqlDebugAstTracker.ts @@ -1,10 +1,8 @@ import * as vscode from 'vscode'; import * as log from '../log-services/logger'; -import { normalizeSpan } from './types'; -import { AstSplitSessionManager } from '../views/astSplitSession'; +import { getControllerInstance } from './cqlAstDebugViewController'; let lastStoppedThreadId: number | undefined; -let activeCqlPath: string | undefined; export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFactory { createDebugAdapterTracker(session: vscode.DebugSession): vscode.DebugAdapterTracker { @@ -14,19 +12,17 @@ export class CqlDebugAstTrackerFactory implements vscode.DebugAdapterTrackerFact if (message.type === 'event' && message.event === 'terminated') { log.debug('DAP terminated event received'); - activeCqlPath = undefined; return; } if (message.type === 'event' && message.event === 'exited') { log.debug('DAP exited event received', { exitCode: message.body?.exitCode }); - activeCqlPath = undefined; return; } if (message.type === 'event' && message.event === 'stopped') { lastStoppedThreadId = message.body?.threadId; + log.debug('DAP stopped event received threadId={}', lastStoppedThreadId); await syncFromStackTrace(session, lastStoppedThreadId); - log.debug('DAP stopped event received'); return; } @@ -76,9 +72,9 @@ async function applyTopFrame( }, session: vscode.DebugSession, ): Promise { - const hook = AstSplitSessionManager.getActiveSession(); - if (!hook) { - log.debug('applyTopFrame: no active split session'); + const controller = getControllerInstance(); + if (!controller) { + log.debug('applyTopFrame: no controller instance'); return; } if (typeof frame.line !== 'number') { @@ -92,17 +88,6 @@ async function applyTopFrame( return; } - log.debug('applyTopFrame: sourcePath={} line={} activeCqlPath={} needsSwap={}', - sourcePath, frame.line, activeCqlPath, sourcePath && sourcePath !== activeCqlPath); - - if (sourcePath && sourcePath !== activeCqlPath) { - const swapped = await hook.swapLibrary(sourcePath); - log.debug('applyTopFrame: swapLibrary result={} for path={}', swapped, sourcePath); - if (!swapped) return; // swap failed — don't highlight stale content - activeCqlPath = sourcePath; - } - - const span = normalizeSpan(frame); - log.debug('applyTopFrame: highlighting span={}', span); - hook.highlightCqlSpan(span); + log.debug('applyTopFrame: sourcePath={} line={}', sourcePath, frame.line); + await controller.onFrameStopped(frame, session); } diff --git a/src/debug/stepGranularityToggle.ts b/src/debug/stepGranularityToggle.ts index 1625f45..b7b6813 100644 --- a/src/debug/stepGranularityToggle.ts +++ b/src/debug/stepGranularityToggle.ts @@ -1,8 +1,5 @@ import * as vscode from 'vscode'; -import { Commands } from '../commands/commands'; -import { fetchAstViaDap } from './debugAstFetcher'; import * as log from '../log-services/logger'; -import { AstSplitSessionManager } from '../views/astSplitSession'; type Granularity = 'cql' | 'ast'; const sessionGranularity = new WeakMap(); @@ -35,16 +32,11 @@ export function activateStepGranularityToggle(context: vscode.ExtensionContext) await s.customRequest('setStepGranularity', { granularity: next }); sessionGranularity.set(s, next); refresh(s); - if (next === 'ast' && !AstSplitSessionManager.getActiveSession()) { - const cqlEditor = vscode.window.visibleTextEditors.find( - e => e.document.uri.fsPath.toLowerCase().endsWith('.cql'), - ); - if (cqlEditor) { - await vscode.commands.executeCommand( - Commands.VIEW_ELM_COMMAND_AST_SPLIT, - cqlEditor.document.uri, - fetchAstViaDap, - ); + 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); } } }), @@ -58,4 +50,4 @@ function refresh(s: vscode.DebugSession | undefined) { statusItem.text = `Step: ${g.toUpperCase()}`; statusItem.tooltip = 'Click to toggle CQL ↔ AST stepping'; statusItem.show(); -} \ No newline at end of file +} diff --git a/src/debug/types.ts b/src/debug/types.ts index 3071572..c94a22e 100644 --- a/src/debug/types.ts +++ b/src/debug/types.ts @@ -6,12 +6,6 @@ export interface CqlSpan { localId?: string; } -export interface ActiveSplitDebugHook { - highlightCqlSpan(span: CqlSpan): void; - noteExternalReveal(): void; - swapLibrary(newCqlPath: string): Promise; -} - export function normalizeSpan(frame: { line?: number; column?: number; diff --git a/src/extension.ts b/src/extension.ts index 60c840d..58ab22e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { CqlDebugAdapterDescriptorFactory } from './debug/cqlDebugAdapterDescrip 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'; @@ -131,6 +132,8 @@ export function activate(context: ExtensionContext): Promise { registerLogCommands(context); registerViewElmCommand(context); activateStepGranularityToggle(context); + const controller = activateController(context); + context.subscriptions.push(controller); context.subscriptions.push(statusBar); await startServer(context, requirements, clientOptions, workspacePath); diff --git a/src/views/astSplitSession.ts b/src/views/astSplitSession.ts index 40cade7..18d8fb3 100644 --- a/src/views/astSplitSession.ts +++ b/src/views/astSplitSession.ts @@ -1,7 +1,4 @@ -import * as path from 'path'; import { - commands, - debug, DecorationOptions, OverviewRulerLane, Position, @@ -16,21 +13,17 @@ import { window, workspace, } from 'vscode'; -import { ActiveSplitDebugHook, CqlSpan, normalizeSpan } from '../debug/types'; import { getElm } from '../cql-service/cqlService.getElm'; import * as log from '../log-services/logger'; import { AstLineIndex, buildAstLineIndex, - buildLocatorKey, findNearestForwardLoc, - hasFullCoordinates, sortAstBySourceOrder, } from '../utils/astIndex'; -export class SplitViewSession implements ActiveSplitDebugHook { +export class SplitViewSession { private cqlUri: Uri; - private currentCqlUri: Uri; private astEditor: TextEditor; private cqlEditor: TextEditor; private lineIndex: AstLineIndex; @@ -48,7 +41,6 @@ export class SplitViewSession implements ActiveSplitDebugHook { private constructor( cqlUri: Uri, - currentCqlUri: Uri, cqlEditor: TextEditor, astEditor: TextEditor, lineIndex: AstLineIndex, @@ -58,7 +50,6 @@ export class SplitViewSession implements ActiveSplitDebugHook { fetcher: (uri: Uri, type: 'ast') => Promise, ) { this.cqlUri = cqlUri; - this.currentCqlUri = currentCqlUri; this.cqlEditor = cqlEditor; this.astEditor = astEditor; this.lineIndex = lineIndex; @@ -105,65 +96,16 @@ export class SplitViewSession implements ActiveSplitDebugHook { }); const session = new SplitViewSession( - cqlUri, cqlUri, cqlEditor, astEditor, lineIndex, + cqlUri, cqlEditor, astEditor, lineIndex, astDecoration, cqlLineDecoration, cqlSpanDecoration, fetcher, ); session.setupSyncListeners(); - session.catchUpToDebugger(); return session; } - // ─── ActiveSplitDebugHook implementation ─── - - highlightCqlSpan(span: CqlSpan): void { - this.lastAstRevealTime = performance.now(); - this.syncCqlToAstBySpan(span); - } - - noteExternalReveal(): void { - this.lastAstRevealTime = performance.now(); - } - - async swapLibrary(newCqlPath: string): Promise { - const newCqlUri = Uri.file(newCqlPath); - log.debug('swapLibrary: enter newCqlPath={} currentCqlUri={}', newCqlPath, this.currentCqlUri.toString()); - - let newAst: string; - try { - newAst = await this.fetcher(newCqlUri, 'ast'); - log.debug('swapLibrary: AST fetched successfully ({} chars) for {}', newAst.length, newCqlPath); - } catch (error) { - log.debug(`swapLibrary: fetcher failed for ${newCqlPath}: ${error}`); - window.showWarningMessage( - `CQL debugger: could not load AST for ${path.basename(newCqlPath)}`, - ); - return false; - } - - this.teardownListeners(); - - const newSorted = sortAstBySourceOrder(newAst); - this.lineIndex = buildAstLineIndex(newSorted); - log.debug('swapLibrary: AST sorted, lineIndex built ({} cqlToAstLines)', this.lineIndex.cqlToAstLines.size); - await this.replaceDocumentContent(this.astEditor, newSorted); - - const newCqlDoc = await workspace.openTextDocument(newCqlUri); - const newCqlEditor = await window.showTextDocument( - newCqlDoc, - { viewColumn: this.cqlEditor.viewColumn, preserveFocus: true, preview: false }, - ); - - this.cqlEditor = newCqlEditor; - this.currentCqlUri = newCqlUri; - this.setupSyncListeners(); - - log.debug('swapLibrary: completed successfully for {}', newCqlPath); - return true; - } - // ─── Lifecycle ─── dispose(): void { @@ -173,7 +115,6 @@ export class SplitViewSession implements ActiveSplitDebugHook { this.cqlLineDecoration.dispose(); this.cqlSpanDecoration.dispose(); this.astDecoration.dispose(); - AstSplitSessionManager.clearSession(); } // ─── Private sync helpers ─── @@ -260,35 +201,6 @@ export class SplitViewSession implements ActiveSplitDebugHook { ); } - private syncCqlToAstBySpan(span: CqlSpan): void { - let astLines: number[] | undefined; - let matchSource: string | undefined; - - if (span.localId) { - astLines = this.lineIndex.localIdToAstLines.get(span.localId); - if (astLines && astLines.length > 0) matchSource = 'localId'; - } - if (!astLines || astLines.length === 0) { - if (hasFullCoordinates(span)) { - const key = buildLocatorKey(span.line, span.column, span.endLine, span.endColumn); - astLines = this.lineIndex.locatorToAstLines.get(key); - if (astLines && astLines.length > 0) matchSource = `locator:${key}`; - } - } - if (!astLines || astLines.length === 0) { - astLines = this.lineIndex.cqlToAstLines.get(span.line - 1); - if (astLines && astLines.length > 0) matchSource = 'cqlLine'; - } - if (!astLines || astLines.length === 0) { - log.debug('syncCqlToAstBySpan: no match for span={} (localId={}, locator, cqlLine all failed)', - span, span.localId); - this.astEditor.setDecorations(this.astDecoration, []); - return; - } - log.debug('syncCqlToAstBySpan: match source={} span={} astLines={}', matchSource, span, astLines); - this.applyAstHighlight(astLines, this.astEditor, this.astDecoration); - } - // ─── Private lifecycle helpers ─── private setupSyncListeners(): void { @@ -319,10 +231,10 @@ export class SplitViewSession implements ActiveSplitDebugHook { }); this.docSaveListener = workspace.onDidSaveTextDocument(async saved => { - if (saved.uri.toString() !== this.currentCqlUri.toString()) return; + if (saved.uri.toString() !== this.cqlUri.toString()) return; if (this.disposed) return; try { - const newAst = await this.fetcher(this.currentCqlUri, 'ast'); + const newAst = await this.fetcher(this.cqlUri, 'ast'); const newSorted = sortAstBySourceOrder(newAst); this.lineIndex = buildAstLineIndex(newSorted); await this.replaceDocumentContent(this.astEditor, newSorted); @@ -356,61 +268,17 @@ export class SplitViewSession implements ActiveSplitDebugHook { return ok; } - private async catchUpToDebugger(): Promise { - const session = debug.activeDebugSession; - if (!session || session.type !== 'cql') { - log.debug('catchUpToDebugger: no active CQL session'); - return; - } - try { - const threadsResp = await session.customRequest('threads'); - const threads: any[] = threadsResp?.threads ?? []; - log.debug('catchUpToDebugger: {} threads found', threads.length); - for (const thread of threads) { - if (thread.id === undefined) continue; - try { - const resp = await session.customRequest('stackTrace', { - threadId: thread.id, - startFrame: 0, - levels: 1, - }); - const top = resp?.stackFrames?.[0]; - if (top && typeof top.line === 'number') { - const sourcePath = top.source?.path; - log.debug('catchUpToDebugger: thread={} line={} source={} localId={}', - thread.id, top.line, sourcePath, top.instructionPointerReference); - if (sourcePath && sourcePath.toLowerCase().endsWith('.cql') && - Uri.file(sourcePath).toString() !== this.currentCqlUri.toString()) { - await this.swapLibrary(sourcePath); - } - this.highlightCqlSpan(normalizeSpan(top)); - return; - } - } catch (e) { - log.debug('catchUpToDebugger: stackTrace error thread={} err={}', thread.id, e); - } - } - } catch (e) { - log.debug('catchUpToDebugger: threads error err={}', e); - } - } } export class AstSplitSessionManager { private static activeSession: SplitViewSession | undefined; - static getActiveSession(): ActiveSplitDebugHook | undefined { - return AstSplitSessionManager.activeSession; - } - static async createOrUpdateSession( cqlUri: Uri, fetcher?: (uri: Uri, type: 'ast') => Promise, - ): Promise { + ): Promise { AstSplitSessionManager.activeSession?.dispose(); - const session = await SplitViewSession.create(cqlUri, fetcher); - AstSplitSessionManager.activeSession = session; - return session; + AstSplitSessionManager.activeSession = await SplitViewSession.create(cqlUri, fetcher); } static clearSession(): void { From 1d90486b6f77b43304833baa141b66662f31911b Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 12 Jun 2026 05:32:14 -0600 Subject: [PATCH 27/38] add version info --- .../suite/commands/execute-cql-file.test.ts | 80 +++++++++++++++++++ src/__test__/suite/statusBar.test.ts | 27 +++++++ src/commands/commands.ts | 5 ++ src/commands/execute-cql-file.ts | 16 ++++ src/cql-language-server/cqlLanguageClient.ts | 8 ++ src/cql-service/cqlService.executeCql.ts | 2 + src/protocol.ts | 7 ++ src/statusBar.ts | 10 ++- 8 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/__test__/suite/commands/execute-cql-file.test.ts b/src/__test__/suite/commands/execute-cql-file.test.ts index ea84c7b..5015ca8 100644 --- a/src/__test__/suite/commands/execute-cql-file.test.ts +++ b/src/__test__/suite/commands/execute-cql-file.test.ts @@ -9,6 +9,7 @@ import { writeIndividualResultFiles, } from '../../../commands/execute-cql-file'; import { ExecuteCqlResponse } from '../../../cql-service/cqlService.executeCql'; +import { VersionInfo } from '../../../protocol'; import { CqlParametersConfig } from '../../../model/parameters'; suite('formatResponse()', () => { @@ -80,6 +81,54 @@ suite('formatResponse()', () => { const response: ExecuteCqlResponse = { results: [], logs: [] }; expect(formatResponse(response)).to.equal(''); }); + + test('prepends version header when all versions are present', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[Encounter]' }] }], + logs: [], + versions: { + translator: '4.9.0', + engine: '4.9.0', + clinicalReasoning: '4.7.0', + languageServer: '4.8.0', + }, + }; + const output = formatResponse(response); + expect(output).to.equal( + 'Translator version: 4.9.0\nEngine version: 4.9.0\nClinical Reasoning version: 4.7.0\nLanguage Server version: 4.8.0\n\nIPP=[Encounter]', + ); + }); + + test('omits version lines that are undefined', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[Encounter]' }] }], + logs: [], + versions: { translator: '4.9.0', engine: undefined, clinicalReasoning: undefined, languageServer: undefined }, + }; + const output = formatResponse(response); + expect(output).to.equal('Translator version: 4.9.0\n\nIPP=[Encounter]'); + }); + + 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]'); + }); + + test('version header appears before evaluation logs', () => { + const response: ExecuteCqlResponse = { + results: [{ libraryName: 'MyLib', expressions: [{ name: 'IPP', value: '[]' }] }], + logs: ['INFO some log'], + versions: { translator: '4.9.0', engine: '4.9.0', clinicalReasoning: '4.7.0', languageServer: '4.8.0' }, + }; + const output = formatResponse(response); + expect(output).to.include('Translator version:'); + expect(output).to.include('Evaluation logs:'); + expect(output.indexOf('Translator version:')).to.be.lessThan(output.indexOf('Evaluation logs:')); + }); }); suite('writeIndividualResultFiles()', () => { @@ -234,6 +283,37 @@ suite('writeIndividualResultFiles()', () => { 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)]' }] }], 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/commands/commands.ts b/src/commands/commands.ts index c00f2c4..30878c5 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -38,6 +38,11 @@ export namespace Commands { 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 */ diff --git a/src/commands/execute-cql-file.ts b/src/commands/execute-cql-file.ts index 37c2042..843bc0e 100644 --- a/src/commands/execute-cql-file.ts +++ b/src/commands/execute-cql-file.ts @@ -14,6 +14,7 @@ import { import { Utils } from 'vscode-uri'; import { Commands } from '../commands/commands'; import { ExecuteCqlResponse, ExpressionResult, executeCql } from '../cql-service/cqlService.executeCql'; +import { VersionInfo } from '../protocol'; import { CqlSolution } from '../model/cqlSolution'; import { extractLibraryVersion } from '../helpers/fileHelper'; import { resolveParameters } from '../helpers/parametersHelper'; @@ -39,6 +40,7 @@ export interface TestCaseResult { parameters: ResultParameterEntry[]; results: ExpressionResult[]; errors: string[]; + versions?: VersionInfo; } export function register(context: ExtensionContext): void { @@ -185,6 +187,17 @@ export async function executeCQLFile( ); const cqlMessage = `CQL: ${cqlPaths.libraryDirectoryPath.fsPath}`; + + + const versionMessage: string[] = []; + const versions = response.versions; + if (versions) { + if (versions.translator) versionMessage.push(`Translator version: ${versions.translator}`); + if (versions.engine) versionMessage.push(`Engine version: ${versions.engine}`); + if (versions.clinicalReasoning) versionMessage.push(`Clinical Reasoning version: ${versions.clinicalReasoning}`); + if (versions.languageServer) versionMessage.push(`Language Server version: ${versions.languageServer}`); + } + 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.`; @@ -209,6 +222,7 @@ export async function executeCQLFile( } await insertLineAtEnd(textEditor, cqlMessage); + await insertLineAtEnd(textEditor, `${versionMessage.join('\n')}\n`); await insertLineAtEnd(textEditor, terminologyMessage); await insertLineAtEnd(textEditor, `${testMessage.join('\n')}\n`); @@ -225,6 +239,7 @@ export async function executeCQLFile( export function formatResponse(response: ExecuteCqlResponse): string { const lines: string[] = []; + for (let i = 0; i < response.results.length; i++) { if (i > 0) { lines.push(''); @@ -287,6 +302,7 @@ export function writeIndividualResultFiles( parameters, results, errors, + versions: response.versions, }; const patientId = testCaseName ?? 'no-context'; 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/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; From 4e93ee47155a9a1195de43e72a3283a06f7efd14 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 12 Jun 2026 13:05:27 -0600 Subject: [PATCH 28/38] clean-up test case result output --- src/commands/execute-cql-file.ts | 305 ++++++++++++++++++------------- 1 file changed, 181 insertions(+), 124 deletions(-) diff --git a/src/commands/execute-cql-file.ts b/src/commands/execute-cql-file.ts index 843bc0e..0745de0 100644 --- a/src/commands/execute-cql-file.ts +++ b/src/commands/execute-cql-file.ts @@ -6,37 +6,33 @@ import { Position, ProgressLocation, Range, - TextEditor, Uri, window, - workspace, + workspace } from 'vscode'; import { Utils } from 'vscode-uri'; import { Commands } from '../commands/commands'; -import { ExecuteCqlResponse, ExpressionResult, executeCql } from '../cql-service/cqlService.executeCql'; -import { VersionInfo } from '../protocol'; -import { CqlSolution } from '../model/cqlSolution'; -import { extractLibraryVersion } 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 } from '../model/testCase'; +import { executeCql, ExecuteCqlResponse, ExpressionResult } from '../cql-service/cqlService.executeCql'; import { CqlPaths, getCqlPaths, getExcludedTestCases, getFhirVersion, loadTestConfig, - TestConfig, - waitForTestCasesLoaded, + 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; - /** 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[]; @@ -51,6 +47,9 @@ export function register(context: ExtensionContext): void { ); } +/** + * Main command entry point orchestrating execution flow. + */ export async function executeCQLFile( cqlFileUri: Uri, testCases: Array | undefined = undefined, @@ -71,10 +70,10 @@ export async function executeCQLFile( 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); @@ -93,14 +92,12 @@ export async function executeCQLFile( const cqlSource = fs.readFileSync(cqlFileUri.fsPath, 'utf-8'); const determinedFhirVersion = getFhirVersion(cqlSource); - const fhirVersion: string = determinedFhirVersion ?? 'R4'; + 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 resultFormat = resultFormatOverride ?? testConfig.resultFormat; const doExecute = () => executeCql( @@ -116,8 +113,8 @@ export async function executeCQLFile( ); const startExecution = Date.now(); - let response: ExecuteCqlResponse | undefined; + if (showProgress) { response = await window.withProgress( { @@ -134,124 +131,194 @@ export async function executeCQLFile( response = await doExecute(); } - const endExecution = Date.now(); - const elapsedSeconds = (endExecution - startExecution) / 1000; + const elapsedSeconds = (Date.now() - startExecution) / 1000; + + if (!response) return; + // Delegate based on format layout strategy 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)`, - ); - } - } + await handleIndividualResults( + libraryName, + libraryVersion, + effectiveTestCases, + response, + cqlPaths, + startExecution, + testConfig.parameters, + showCompletion, + elapsedSeconds + ); } else { - const outputPath = Utils.resolvePath(cqlPaths.resultDirectoryPath, `${libraryName}.txt`); - fse.ensureDirSync(cqlPaths.resultDirectoryPath.fsPath); - if (!fs.existsSync(outputPath.fsPath)) { - fs.writeFileSync(outputPath.fsPath, ''); - } + await handleAggregatedTextReport( + libraryName, + effectiveTestCases, + excludedTestCases, + response, + cqlPaths, + 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); - const textEditor = await window.showTextDocument(textDocument); - await textEditor.edit( - editBuilder => { - editBuilder.delete( - new Range( - new Position(0, 0), - textDocument.lineAt(Math.max(0, textDocument.lineCount - 1)).range.end, - ), - ); - }, - { undoStopBefore: false, undoStopAfter: false }, + await window.showTextDocument(textDocument); + } else { + window.showInformationMessage( + `CQL execution complete — ${effectiveTestCases.length} test cases written (${elapsedSeconds.toFixed(1)}s)`, ); + } +} - const cqlMessage = `CQL: ${cqlPaths.libraryDirectoryPath.fsPath}`; +/** + * Format Handler: Consolidated Text Output + */ +async function handleAggregatedTextReport( + libraryName: string, + effectiveTestCases: TestCase[], + excludedTestCases: Map, + response: ExecuteCqlResponse, + cqlPaths: CqlPaths, + elapsedSeconds: number +) { + const outputPath = Utils.resolvePath(cqlPaths.resultDirectoryPath, `${libraryName}.txt`); + 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); - const versionMessage: string[] = []; - const versions = response.versions; - if (versions) { - if (versions.translator) versionMessage.push(`Translator version: ${versions.translator}`); - if (versions.engine) versionMessage.push(`Engine version: ${versions.engine}`); - if (versions.clinicalReasoning) versionMessage.push(`Clinical Reasoning version: ${versions.clinicalReasoning}`); - if (versions.languageServer) versionMessage.push(`Language Server version: ${versions.languageServer}`); - } + // Generate complete unified report buffer + const reportText = generateTextReport( + cqlPaths, + effectiveTestCases, + excludedTestCases, + response, + elapsedSeconds + ); - 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}`); - } - } + // Atomic replace execution + const entireDocRange = new Range( + new Position(0, 0), + textDocument.lineAt(Math.max(0, textDocument.lineCount - 1)).range.end, + ); - if (excludedTestCases.size > 0) { - testMessage.push('\nExcluded test cases:'); - for (const [testCase, reason] of excludedTestCases.entries()) { - testMessage.push(`${testCase} - ${reason}`); + 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]> = [ + ['translator', 'Translator version'], + ['engine', 'Engine version'], + ['clinicalReasoning', 'Clinical Reasoning version'], + ['languageServer', 'Language Server version'] + ]; + for (const [key, label] of versionKeys) { + if (response.versions[key]) { + lines.push(`${label}: ${response.versions[key]}`); } } + } - await insertLineAtEnd(textEditor, cqlMessage); - await insertLineAtEnd(textEditor, `${versionMessage.join('\n')}\n`); - await insertLineAtEnd(textEditor, terminologyMessage); - await insertLineAtEnd(textEditor, `${testMessage.join('\n')}\n`); + 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 (response) { - await insertLineAtEnd(textEditor, formatResponse(response)); + 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}`); } - await insertLineAtEnd( - textEditor, - `\nelapsed: ${elapsedSeconds.toString()} seconds\n`, - ); - await textDocument.save(); } + + 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[] = []; - for (let i = 0; i < response.results.length; i++) { - if (i > 0) { - lines.push(''); - } - for (const expr of response.results[i].expressions) { + 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(''); - lines.push('Evaluation logs:'); - lines.push(...response.logs); + lines.push('', 'Evaluation logs:', ...response.logs); } return lines.join('\n'); } @@ -313,14 +380,4 @@ export function writeIndividualResultFiles( ); fse.outputFileSync(outputPath.fsPath, JSON.stringify(result, null, 2)); } -} - -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 }, - ); -} +} \ No newline at end of file From bc7942e06eb505158d358a749cd130e66e14372a Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 12 Jun 2026 13:40:03 -0600 Subject: [PATCH 29/38] fix issue with execute filtered libraries with no test cases --- src/cql-explorer/cqlExplorer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index d47e5e6..03b21b9 100644 --- a/src/cql-explorer/cqlExplorer.ts +++ b/src/cql-explorer/cqlExplorer.ts @@ -189,7 +189,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; } From 413b419ef227feb3100dd5bc5581e8b93810d70e Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 12 Jun 2026 14:18:07 -0600 Subject: [PATCH 30/38] fix CQL Explorer library sorting --- .../cqlLibraryRootTreeItem.test.ts | 122 ++++++++++++++++++ .../suite/cql-explorer/getChildren.test.ts | 5 +- .../cqlProjectTreeDataProvider.ts | 39 ++++-- 3 files changed, 150 insertions(+), 16 deletions(-) diff --git a/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts b/src/__test__/suite/cql-explorer/cqlLibraryRootTreeItem.test.ts index fb388ed..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'; @@ -201,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/getChildren.test.ts b/src/__test__/suite/cql-explorer/getChildren.test.ts index c356f7c..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'; @@ -251,7 +252,7 @@ suite('CqlProjectTreeDataProvider.sortRootItemsInPlace', () => { expect(order).to.deep.equal(['CMS2FHIR', 'CMSFHIR529Hybrid']); }); - test('ascending sort puts CMS libraries before non-CMS libraries', () => { + 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')); @@ -261,7 +262,7 @@ suite('CqlProjectTreeDataProvider.sortRootItemsInPlace', () => { ); const order = provider.getRootItems().map(i => i.label as string); - expect(order).to.deep.equal(['CMS2', 'Apple', 'Banana']); + expect(order).to.deep.equal(['Apple', 'Banana', 'CMS2']); }); test('descending sort reverses CMS measure order and non-CMS alphabetical order', () => { diff --git a/src/cql-explorer/cqlProjectTreeDataProvider.ts b/src/cql-explorer/cqlProjectTreeDataProvider.ts index f735057..81bc54d 100644 --- a/src/cql-explorer/cqlProjectTreeDataProvider.ts +++ b/src/cql-explorer/cqlProjectTreeDataProvider.ts @@ -11,13 +11,13 @@ import { } from '../model/cqlProject'; import { DeviationKind } from '../model/igLayoutDetector'; -interface LibraryNameParts { +interface CmsNameParts { isCms: boolean; measureNumber: number; suffix: string; } -function parseLibraryName(name: string): LibraryNameParts { +function parseCmsName(name: string): CmsNameParts { const cmsMatch = name.match(/^CMS(?:FHIR)?(\d+)(.*)$/); if (cmsMatch) { return { @@ -29,14 +29,25 @@ function parseLibraryName(name: string): LibraryNameParts { return { isCms: false, measureNumber: 0, suffix: name }; } -function compareLibraryNames(a: string, b: string, descending: boolean): number { - const pa = parseLibraryName(a); - const pb = parseLibraryName(b); +function compareCmsNames(a: string, b: string, descending: boolean): number { + const pa = parseCmsName(a); + const pb = parseCmsName(b); - // CMS group always comes first (ascending and descending) - if (pa.isCms !== pb.isCms) return pa.isCms ? -1 : 1; + 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 (pa.isCms) { + if (ga === 1) { // Both CMS: sort by measure number numerically const numDiff = descending ? pb.measureNumber - pa.measureNumber @@ -48,7 +59,7 @@ function compareLibraryNames(a: string, b: string, descending: boolean): number : pa.suffix.localeCompare(pb.suffix); } - // Neither is CMS: standard localeCompare + // Both non-CMS (group 0 or 2): standard localeCompare return descending ? b.localeCompare(a) : a.localeCompare(b); } @@ -129,7 +140,7 @@ export class CqlTestCaseRootTreeItem extends vscode.TreeItem { public resetChildren(): void { this._children.length = 0; - const testCases = this.cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); + const testCases = this.cqlLibrary.TestCases.sort((a, b) => compareCmsNames(a.name, b.name, false)); testCases.forEach(tc => this.addTestCase(tc)); } @@ -215,7 +226,7 @@ export class CqlLibraryRootTreeItem extends vscode.TreeItem { this.cqlLibraryTreeItem = new CqlLibraryTreeItem(cqlLibrary); this._children.push(this.cqlLibraryTreeItem); - const testCases = cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); + const testCases = cqlLibrary.TestCases.sort((a, b) => compareCmsNames(a.name, b.name, false)); testCases.forEach(tc => this.addTestCase(tc)); if (cqlLibrary.resultUris.length > 0) { @@ -260,7 +271,7 @@ export class CqlLibraryRootTreeItem extends vscode.TreeItem { } this._cqlTestCaseRootTreeItem = undefined; - const testCases = this.cqlLibrary.TestCases.sort((a, b) => a.name.localeCompare(b.name)); + 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) { @@ -631,7 +642,7 @@ export class CqlProjectTreeDataProvider implements vscode.TreeDataProvider - compareLibraryNames(a.cqlLibrary.name, b.cqlLibrary.name, this.sortDescending), + compareCmsNames(a.cqlLibrary.name, b.cqlLibrary.name, this.sortDescending), ); } else { // Multi-project: full rebuild is acceptable (rare case) @@ -845,7 +856,7 @@ export function buildTree( !hideEmpty || lib.testCaseLoadState !== 'loaded' || lib.TestCases.length > 0, ) .filter(lib => filter === '' || lib.name.toLowerCase().includes(filter)) - .sort((a, b) => compareLibraryNames(a.name, b.name, sortDescending)) + .sort((a, b) => compareCmsNames(a.name, b.name, sortDescending)) .map( lib => new CqlLibraryRootTreeItem( From 43fc15199c404fb9be421b5ac387db67f06acbb1 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Fri, 12 Jun 2026 15:42:30 -0600 Subject: [PATCH 31/38] add webview config editor --- package.json | 11 + src/__test__/suite/extension/commands.test.ts | 1 + .../suite/views/configEditorWebview.test.ts | 218 ++++++++ src/commands/commands.ts | 5 + src/cql-explorer/cqlExplorer.ts | 23 + src/views/configEditorWebview.ts | 482 ++++++++++++++++++ 6 files changed, 740 insertions(+) create mode 100644 src/__test__/suite/views/configEditorWebview.test.ts create mode 100644 src/views/configEditorWebview.ts diff --git a/package.json b/package.json index adcf668..e0301ad 100644 --- a/package.json +++ b/package.json @@ -345,6 +345,12 @@ "icon": "$(warning)", "category": "CQL Explorer" }, + { + "command": "cql.explorer.edit-config", + "title": "Edit Configuration", + "icon": "$(gear)", + "category": "CQL Explorer" + }, { "command": "cql.debug.toggle-step-granularity", "title": "CQL: Toggle Step Granularity (CQL ↔ AST)", @@ -699,6 +705,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", diff --git a/src/__test__/suite/extension/commands.test.ts b/src/__test__/suite/extension/commands.test.ts index ef6f280..fa8b01a 100644 --- a/src/__test__/suite/extension/commands.test.ts +++ b/src/__test__/suite/extension/commands.test.ts @@ -32,6 +32,7 @@ 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', 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 30878c5..728070c 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -58,4 +58,9 @@ export namespace Commands { * Open settings.json */ export const OPEN_JSON_SETTINGS = 'workbench.action.openSettingsJson'; + + /* + * Edit Project Configuration + */ + export const EDIT_CONFIG = 'cql.explorer.edit-config'; } diff --git a/src/cql-explorer/cqlExplorer.ts b/src/cql-explorer/cqlExplorer.ts index 03b21b9..c6128df 100644 --- a/src/cql-explorer/cqlExplorer.ts +++ b/src/cql-explorer/cqlExplorer.ts @@ -18,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, @@ -749,6 +750,28 @@ export class CqlExplorer { 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/views/configEditorWebview.ts b/src/views/configEditorWebview.ts new file mode 100644 index 0000000..7e1e2bd --- /dev/null +++ b/src/views/configEditorWebview.ts @@ -0,0 +1,482 @@ +import * as fs from 'node:fs'; +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), 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}
+ +
+ +
+ + +
+ +