Skip to content

Commit 7d781aa

Browse files
Copilotsnehara99
andauthored
refactor: deduplicate test source resolution between test explorer and project outline (#4780)
* Initial plan * feat: navigate to test source file when clicking CTest in Project Outline (#4773) Co-authored-by: snehara99 <[email protected]> * fix: navigate cursor to correct test line when clicking CTest in Project Outline (#4773) Co-authored-by: snehara99 <[email protected]> * remove accidental .yarnrc file Co-authored-by: snehara99 <[email protected]> * refactor: extract shared resolveTestSourceLocation() to deduplicate test explorer and project outline source resolution Co-authored-by: snehara99 <[email protected]> * remove accidentally committed .npmrc.bak Co-authored-by: snehara99 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: snehara99 <[email protected]>
1 parent 206c6ee commit 7d781aa

4 files changed

Lines changed: 98 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Features:
1515
- Support `targetName` argument for launch-target command substitutions (`cmake.launchTargetPath`, etc.) via `${input:...}` variables, enabling build-before-run for non-active executable targets without changing the active launch target. [#4656](https://github.com/microsoft/vscode-cmake-tools/issues/4656)
1616

1717
Improvements:
18+
- Clicking on a CTest in the Project Outline now navigates to the test source file, matching the existing Test Explorer behavior. [#4773](https://github.com/microsoft/vscode-cmake-tools/issues/4773)
1819
- Clicking on a CTest unit test in the Test Explorer now navigates to the test source file by matching the test executable to its CMake target's source files. [#4449](https://github.com/microsoft/vscode-cmake-tools/issues/4449)
1920
- Make "CMake: Add ... Preset" commands available in the command palette when `cmake.useCMakePresets` is set to `auto`, even before a CMakePresets.json file exists. [#4401](https://github.com/microsoft/vscode-cmake-tools/issues/4401)
2021
- Improve CMake syntax highlighting with command-scoped keyword rules, expanded variable recognition, and better generator-expression support. Remove obsolete deprecated-keyword entries that caused false positives on user-defined names. [#4709](https://github.com/microsoft/vscode-cmake-tools/issues/4709) [#4508](https://github.com/microsoft/vscode-cmake-tools/issues/4508) [#4613](https://github.com/microsoft/vscode-cmake-tools/issues/4613)

src/ctest.ts

Lines changed: 74 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as xml2js from 'xml2js';
55
import * as zlib from 'zlib';
66

77
import { CMakeDriver } from '@cmt/drivers/drivers';
8+
import { CodeModelContent } from '@cmt/drivers/codeModel';
89
import * as logging from '@cmt/logging';
910
import { fs } from '@cmt/pr';
1011
import * as util from '@cmt/util';
@@ -782,9 +783,8 @@ export class CTestDriver implements vscode.Disposable {
782783
* Builds a map from normalized executable paths to source file information
783784
* by looking at the code model content from the CMake driver.
784785
*/
785-
private buildExecutableToSourcesMap(driver: CMakeDriver): Map<string, { sourceDir: string; sources: string[] }> {
786+
private buildExecutableToSourcesMap(codeModelContent: CodeModelContent | null): Map<string, { sourceDir: string; sources: string[] }> {
786787
const map = new Map<string, { sourceDir: string; sources: string[] }>();
787-
const codeModelContent = driver.codeModelContent;
788788
if (!codeModelContent) {
789789
return map;
790790
}
@@ -812,6 +812,61 @@ export class CTestDriver implements vscode.Disposable {
812812
return map;
813813
}
814814

815+
/**
816+
* Resolves the source file and line number for a single CTest test entry.
817+
* Uses a 3-step priority:
818+
* 1) DEF_SOURCE_LINE test property
819+
* 2) Code model executable-to-sources matching
820+
* 3) Backtrace graph (falls back to CMakeLists.txt)
821+
*/
822+
private resolveTestSourceLocation(
823+
test: CTestInfo['tests'][0],
824+
executableToSources: Map<string, { sourceDir: string; sources: string[] }> | undefined,
825+
backtraceGraph: CTestInfo['backtraceGraph'] | undefined
826+
): { file?: string; line?: number } {
827+
let file: string | undefined;
828+
let line: number | undefined;
829+
830+
// 1. Use DEF_SOURCE_LINE CMake test property
831+
const defSourceLineProperty = test.properties.filter(p => p.name === "DEF_SOURCE_LINE")[0];
832+
if (defSourceLineProperty && defSourceLineProperty.value && typeof defSourceLineProperty.value === 'string') {
833+
const match = defSourceLineProperty.value.match(/(.*):(\d+)/);
834+
if (match && match[1] && match[2]) {
835+
file = match[1];
836+
line = parseInt(match[2]);
837+
if (isNaN(line)) {
838+
line = undefined;
839+
file = undefined;
840+
}
841+
}
842+
}
843+
844+
// 2. Match test executable to CMake target sources
845+
if (!file && test.command && test.command.length > 0 && executableToSources) {
846+
const testExe = util.platformNormalizePath(test.command[0]);
847+
const targetInfo = executableToSources.get(testExe);
848+
if (targetInfo && targetInfo.sources.length > 0) {
849+
file = path.resolve(targetInfo.sourceDir, targetInfo.sources[0]);
850+
line = 1;
851+
}
852+
}
853+
854+
// 3. Backtrace graph (falls back to CMakeLists.txt)
855+
if (!file && backtraceGraph) {
856+
const nodes = backtraceGraph.nodes;
857+
if (test.backtrace !== undefined && nodes[test.backtrace] !== undefined) {
858+
let node = nodes[test.backtrace];
859+
while (node.parent !== undefined && nodes[node.parent].command !== undefined) {
860+
node = nodes[node.parent];
861+
}
862+
file = backtraceGraph.files[node.file];
863+
line = node.line;
864+
}
865+
}
866+
867+
return { file, line };
868+
}
869+
815870
private createTestItemAndSuiteTree(testName: string, testExplorerRoot: vscode.TestItem, initializedTestExplorer: vscode.TestController, uri?: vscode.Uri): TestAndParentSuite {
816871
let parentSuiteItem = testExplorerRoot;
817872
let testLabel = testName;
@@ -883,49 +938,10 @@ export class CTestDriver implements vscode.Disposable {
883938
} else if (testType === "CTestInfo" && this.tests !== undefined) {
884939
if (this.tests && this.tests.kind === 'ctestInfo') {
885940
// Build a map from executable paths to source files using the code model
886-
const executableToSources = this.buildExecutableToSourcesMap(driver);
941+
const executableToSources = this.buildExecutableToSourcesMap(driver.codeModelContent);
887942

888943
this.tests.tests.forEach(test => {
889-
let testDefFile: string | undefined;
890-
let testDefLine: number | undefined;
891-
892-
// Use DEF_SOURCE_LINE CMake test property to find file and line number
893-
// Property must be set in the test's CMakeLists.txt file or its included modules for this to work
894-
const defSourceLineProperty = test.properties.filter(property => property.name === "DEF_SOURCE_LINE")[0];
895-
if (defSourceLineProperty && defSourceLineProperty.value && typeof defSourceLineProperty.value === 'string') {
896-
// Use RegEx to match the format "file_path:line" in value[0]
897-
const match = defSourceLineProperty.value.match(/(.*):(\d+)/);
898-
if (match && match[1] && match[2]) {
899-
testDefFile = match[1];
900-
testDefLine = parseInt(match[2]);
901-
if (isNaN(testDefLine)) {
902-
testDefLine = undefined;
903-
testDefFile = undefined;
904-
}
905-
}
906-
}
907-
908-
// Try to find the test source file by matching the test executable to a CMake target
909-
if (!testDefFile && test.command && test.command.length > 0) {
910-
const testExe = util.platformNormalizePath(test.command[0]);
911-
const targetInfo = executableToSources.get(testExe);
912-
if (targetInfo && targetInfo.sources.length > 0) {
913-
testDefFile = path.resolve(targetInfo.sourceDir, targetInfo.sources[0]);
914-
testDefLine = 1;
915-
}
916-
}
917-
918-
const nodes = this.tests!.backtraceGraph.nodes;
919-
if (!testDefFile && test.backtrace !== undefined && nodes[test.backtrace] !== undefined) {
920-
// Use the backtrace graph to find the file and line number
921-
// This finds the CMake module's file and line number and not the test file and line number
922-
let node = nodes[test.backtrace];
923-
while (node.parent !== undefined && nodes[node.parent].command !== undefined) {
924-
node = nodes[node.parent];
925-
}
926-
testDefFile = this.tests!.backtraceGraph.files[node.file];
927-
testDefLine = node.line;
928-
}
944+
const { file: testDefFile, line: testDefLine } = this.resolveTestSourceLocation(test, executableToSources, this.tests!.backtraceGraph);
929945

930946
const testAndParentSuite = this.createTestItemAndSuiteTree(test.name, testExplorerRoot, initializedTestExplorer, testDefFile ? vscode.Uri.file(testDefFile) : undefined);
931947
const testItem = testAndParentSuite.test;
@@ -1048,14 +1064,23 @@ export class CTestDriver implements vscode.Disposable {
10481064

10491065
/**
10501066
* Returns test information suitable for the project outline view.
1051-
* Each entry maps a test name to its executable path.
1067+
* Each entry maps a test name to its executable path and optionally
1068+
* the resolved source file path for click-to-navigate.
10521069
*/
1053-
getTestsForOutline(): { name: string; executablePath: string }[] {
1070+
getTestsForOutline(codeModelContent?: CodeModelContent | null): { name: string; executablePath: string; sourceFilePath?: string; sourceFileLine?: number }[] {
10541071
if (this.tests) {
1055-
return this.tests.tests.map(test => ({
1056-
name: test.name,
1057-
executablePath: test.command[0]
1058-
}));
1072+
const executableToSources = codeModelContent ? this.buildExecutableToSourcesMap(codeModelContent) : undefined;
1073+
1074+
return this.tests.tests.map(test => {
1075+
const { file: sourceFilePath, line: sourceFileLine } = this.resolveTestSourceLocation(test, executableToSources, this.tests!.backtraceGraph);
1076+
1077+
return {
1078+
name: test.name,
1079+
executablePath: test.command[0],
1080+
sourceFilePath,
1081+
sourceFileLine
1082+
};
1083+
});
10591084
}
10601085
return [];
10611086
}

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ export class ExtensionManager implements vscode.Disposable {
862862
if (!cmakeProject) {
863863
return;
864864
}
865-
const tests = cmakeProject.cTestController.getTestsForOutline();
865+
const tests = cmakeProject.cTestController.getTestsForOutline(cmakeProject.codeModelContent);
866866
this.projectOutline.updateTests(cmakeProject, tests);
867867
}
868868

src/ui/projectOutline/projectOutline.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,9 @@ export class CTestTestNode extends BaseNode {
361361
readonly targetId: string,
362362
readonly testName: string,
363363
readonly folder: vscode.WorkspaceFolder,
364-
readonly sourceDir: string
364+
readonly sourceDir: string,
365+
readonly sourceFilePath?: string,
366+
readonly sourceFileLine?: number
365367
) {
366368
super(`${targetId}::test::${testName}`);
367369
}
@@ -384,6 +386,16 @@ export class CTestTestNode extends BaseNode {
384386
item.iconPath = new vscode.ThemeIcon('beaker');
385387
item.contextValue = 'nodeType=test';
386388
item.tooltip = localize('test.tooltip', 'Test: {0}', this.testName);
389+
if (this.sourceFilePath) {
390+
const uri = vscode.Uri.file(this.sourceFilePath);
391+
const line = this.sourceFileLine ? this.sourceFileLine - 1 : 0;
392+
const position = new vscode.Position(line, 0);
393+
item.command = {
394+
title: localize('open.file', 'Open file'),
395+
command: 'vscode.open',
396+
arguments: [uri, { selection: new vscode.Range(position, position) }]
397+
};
398+
}
387399
return item;
388400
}
389401
}
@@ -551,9 +563,9 @@ export class TargetNode extends BaseNode {
551563
}
552564
}
553565

554-
updateTests(testNames: string[]) {
555-
this._testNodes = testNames.map(name =>
556-
new CTestTestNode(this.id, name, this.folder, this.sourceDir)
566+
updateTests(tests: { name: string; sourceFilePath?: string; sourceFileLine?: number }[]) {
567+
this._testNodes = tests.map(test =>
568+
new CTestTestNode(this.id, test.name, this.folder, this.sourceDir, test.sourceFilePath, test.sourceFileLine)
557569
);
558570
}
559571
}
@@ -749,7 +761,7 @@ export class WorkspaceFolderNode extends BaseNode {
749761
return children.sort((a, b) => lexicographicalCompare(a.getOrderTuple(), b.getOrderTuple()));
750762
}
751763

752-
updateTests(cmakeProject: CMakeProject, tests: { name: string; executablePath: string }[]) {
764+
updateTests(cmakeProject: CMakeProject, tests: { name: string; executablePath: string; sourceFilePath?: string; sourceFileLine?: number }[]) {
753765
const sub_map = this._projects.get(cmakeProject.folderPath);
754766
if (!sub_map) {
755767
return;
@@ -772,7 +784,7 @@ export class WorkspaceFolderNode extends BaseNode {
772784
}
773785

774786
// Group tests by their executable path (matching to targets)
775-
const testsByTarget = new Map<TargetNode, string[]>();
787+
const testsByTarget = new Map<TargetNode, { name: string; sourceFilePath?: string; sourceFileLine?: number }[]>();
776788
for (const test of tests) {
777789
const normalizedExe = platformNormalizePath(test.executablePath);
778790
const target = targetsByPath.get(normalizedExe);
@@ -782,7 +794,7 @@ export class WorkspaceFolderNode extends BaseNode {
782794
arr = [];
783795
testsByTarget.set(target, arr);
784796
}
785-
arr.push(test.name);
797+
arr.push({ name: test.name, sourceFilePath: test.sourceFilePath, sourceFileLine: test.sourceFileLine });
786798
}
787799
}
788800

@@ -794,8 +806,8 @@ export class WorkspaceFolderNode extends BaseNode {
794806
}
795807

796808
// Update each target with its tests
797-
for (const [target, testNames] of testsByTarget) {
798-
target.updateTests(testNames);
809+
for (const [target, targetTests] of testsByTarget) {
810+
target.updateTests(targetTests);
799811
}
800812
}
801813
}
@@ -928,7 +940,7 @@ export class ProjectOutline implements vscode.TreeDataProvider<BaseNode> {
928940
this._changeEvent.fire(null);
929941
}
930942

931-
updateTests(cmakeProject: CMakeProject, tests: { name: string; executablePath: string }[]) {
943+
updateTests(cmakeProject: CMakeProject, tests: { name: string; executablePath: string; sourceFilePath?: string; sourceFileLine?: number }[]) {
932944
const folder = cmakeProject.workspaceContext.folder;
933945
const existing = this._folders.get(folder.uri.fsPath);
934946
if (!existing) {

0 commit comments

Comments
 (0)