Skip to content

Commit 13e5c40

Browse files
Merge pull request #655 from jjocoulter/bug/go-to-goes-to-wrong-class
feat: go to code - go to symbol rather than line number
2 parents 59427a2 + 298b86f commit 13e5c40

File tree

12 files changed

+525
-84
lines changed

12 files changed

+525
-84
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- 🎯 **Call Tree Go To**: Go-to links in call tree now navigate to method definition instead of where method was called from ([#632] [#200])
1213
-**Log parsing**: improved performance ([#552])
1314

1415
## [1.18.1] 2025-07-09
@@ -401,6 +402,8 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
401402

402403
<!-- Unreleased -->
403404

405+
[#632]: https://github.com/certinia/debug-log-analyzer/issues/632
406+
[#200]: https://github.com/certinia/debug-log-analyzer/issues/200
404407
[#552]: https://github.com/certinia/debug-log-analyzer/issues/552
405408

406409
<!-- v1.18.0 -->

jest.config.js

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,39 @@
1+
const defaultConfig = {
2+
testEnvironment: 'node',
3+
moduleNameMapper: {
4+
'^(\\.{1,2}/.*)\\.js$': '$1',
5+
},
6+
transform: {
7+
'^.+\\.(ts|js)?$': [
8+
'@swc/jest',
9+
{
10+
jsc: {
11+
target: 'esnext',
12+
parser: { decorators: true, syntax: 'typescript' },
13+
},
14+
},
15+
],
16+
},
17+
transformIgnorePatterns: [
18+
// allow lit/@lit transformation
19+
'<rootDir>/node_modules/(?!@?lit)',
20+
],
21+
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/out/'],
22+
extensionsToTreatAsEsm: ['.ts', '.tsx'],
23+
};
24+
125
/** @type {import('@jest/types').Config.InitialOptions} */
226
export default {
327
projects: [
428
{
29+
...defaultConfig,
530
displayName: 'log-viewer',
631
rootDir: '<rootDir>/log-viewer',
7-
testEnvironment: 'node',
8-
9-
moduleNameMapper: {
10-
'^(\\.{1,2}/.*)\\.js$': '$1',
11-
},
12-
transform: {
13-
'^.+\\.(ts|js)?$': [
14-
'@swc/jest',
15-
{
16-
jsc: {
17-
target: 'esnext',
18-
parser: { decorators: true, syntax: 'typescript' },
19-
},
20-
},
21-
],
22-
},
23-
transformIgnorePatterns: [
24-
// allow lit/@lit transformation
25-
'<rootDir>/node_modules/(?!@?lit)',
26-
],
27-
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/out/'],
28-
extensionsToTreatAsEsm: ['.ts', '.tsx'],
32+
},
33+
{
34+
...defaultConfig,
35+
displayName: 'lana',
36+
rootDir: '<rootDir>/lana',
2937
},
3038
],
3139
};

lana/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@
176176
},
177177
"dependencies": {
178178
"@apexdevtools/apex-ls": "^5.10.0",
179+
"@apexdevtools/apex-parser": "^4.4.0",
179180
"@apexdevtools/sfdx-auth-helper": "^2.1.0",
180181
"@salesforce/apex-node": "^1.6.2"
181182
},

lana/src/commands/LogView.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,9 @@ export class LogView {
6666
}
6767

6868
case 'openType': {
69-
const { typeName } = <{ typeName: string; text: string }>payload;
70-
if (typeName) {
71-
const [className, lineNumber] = typeName.split('-');
72-
let line;
73-
if (lineNumber) {
74-
line = parseInt(lineNumber);
75-
}
76-
OpenFileInPackage.openFileForSymbol(context, className || '', line);
69+
const symbol = <string>payload;
70+
if (symbol) {
71+
OpenFileInPackage.openFileForSymbol(context, symbol);
7772
}
7873
break;
7974
}

lana/src/display/Display.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* Copyright (c) 2020 Certinia Inc. All rights reserved.
33
*/
4-
import { Uri, commands, window, type MessageOptions } from 'vscode';
4+
import { Uri, commands, window, type MessageOptions, type TextDocumentShowOptions } from 'vscode';
55

66
import { appName } from '../AppSettings.js';
77

@@ -23,7 +23,7 @@ export class Display {
2323
window.showErrorMessage(s, options);
2424
}
2525

26-
showFile(path: string): void {
27-
commands.executeCommand('vscode.open', Uri.file(path.trim()));
26+
showFile(path: string, options: TextDocumentShowOptions = {}): void {
27+
commands.executeCommand('vscode.open', Uri.file(path.trim()), options);
2828
}
2929
}

lana/src/display/OpenFileInPackage.ts

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,29 @@ import {
77
Selection,
88
Uri,
99
ViewColumn,
10-
commands,
10+
workspace,
1111
type TextDocumentShowOptions,
1212
} from 'vscode';
1313

1414
import { Context } from '../Context.js';
1515
import { Item, Options, QuickPick } from './QuickPick.js';
1616

17+
import { getMethodLine, parseApex } from '../salesforce/ApexParser/ApexSymbolLocator.js';
18+
1719
export class OpenFileInPackage {
18-
static async openFileForSymbol(
19-
context: Context,
20-
name: string,
21-
lineNumber?: number,
22-
): Promise<void> {
23-
const paths = await context.findSymbol(name);
20+
static async openFileForSymbol(context: Context, symbolName: string): Promise<void> {
21+
if (!symbolName?.trim()) {
22+
return;
23+
}
24+
25+
const parts = symbolName.split('.');
26+
const fileName = parts[0]?.trim();
27+
28+
const paths = await context.findSymbol(fileName as string);
2429
if (!paths.length) {
2530
return;
2631
}
32+
2733
const matchingWs = context.workspaces.filter((ws) => {
2834
const found = paths.findIndex((p) => p.startsWith(ws.path()));
2935
if (found > -1) {
@@ -38,24 +44,39 @@ export class OpenFileInPackage {
3844
new Options('Select a workspace:'),
3945
)
4046
: [new Item(matchingWs[0]?.name() || '', matchingWs[0]?.path() || '', '')];
47+
if (!wsPath) {
48+
return;
49+
}
4150

42-
if (wsPath && lineNumber) {
43-
const zeroBasedLine = lineNumber - 1;
44-
const linePosition = new Position(zeroBasedLine, 0);
45-
46-
const options: TextDocumentShowOptions = {
47-
preserveFocus: false,
48-
preview: false,
49-
viewColumn: ViewColumn.Active,
50-
selection: new Selection(linePosition, linePosition),
51-
};
52-
53-
const wsPathTrimmed = wsPath.description.trim();
54-
const path =
55-
paths.find((e) => {
56-
return e.startsWith(wsPathTrimmed + sep);
57-
}) || '';
58-
commands.executeCommand('vscode.open', Uri.file(path), options);
51+
const wsPathTrimmed = wsPath.description.trim();
52+
const path =
53+
paths.find((e) => {
54+
return e.startsWith(wsPathTrimmed + sep);
55+
}) || '';
56+
57+
const uri = Uri.file(path);
58+
const document = await workspace.openTextDocument(uri);
59+
60+
const parsedRoot = parseApex(document.getText());
61+
62+
const symbolLocation = getMethodLine(parsedRoot, parts);
63+
64+
if (!symbolLocation.isExactMatch) {
65+
context.display.showErrorMessage(
66+
`Symbol '${symbolLocation.missingSymbol}' could not be found in file '${fileName}'`,
67+
);
5968
}
69+
const zeroIndexedLineNumber = symbolLocation.line - 1;
70+
71+
const pos = new Position(zeroIndexedLineNumber, 0);
72+
73+
const options: TextDocumentShowOptions = {
74+
preserveFocus: false,
75+
preview: false,
76+
viewColumn: ViewColumn.Active,
77+
selection: new Selection(pos, pos),
78+
};
79+
80+
context.display.showFile(path, options);
6081
}
6182
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2025 Certinia Inc. All rights reserved.
3+
*/
4+
import {
5+
ApexLexer,
6+
ApexParser,
7+
CaseInsensitiveInputStream,
8+
CommonTokenStream,
9+
} from '@apexdevtools/apex-parser';
10+
import { CharStreams } from 'antlr4ts';
11+
import { ApexVisitor, type ApexMethodNode, type ApexNode } from './ApexVisitor';
12+
13+
export type SymbolLocation = {
14+
line: number;
15+
isExactMatch: boolean;
16+
missingSymbol?: string;
17+
};
18+
19+
export function parseApex(apexCode: string): ApexNode {
20+
const parser = new ApexParser(
21+
new CommonTokenStream(
22+
new ApexLexer(new CaseInsensitiveInputStream(CharStreams.fromString(apexCode))),
23+
),
24+
);
25+
return new ApexVisitor().visit(parser.compilationUnit());
26+
}
27+
28+
export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLocation {
29+
const result: SymbolLocation = { line: 1, isExactMatch: true };
30+
31+
if (symbols[0] === rootNode.name) {
32+
symbols = symbols.slice(1);
33+
}
34+
35+
if (!symbols.length) {
36+
return result;
37+
}
38+
39+
let currentRoot: ApexNode | undefined = rootNode;
40+
41+
for (const symbol of symbols) {
42+
if (isClassSymbol(symbol)) {
43+
currentRoot = findClassNode(currentRoot, symbol);
44+
45+
if (!currentRoot) {
46+
result.isExactMatch = false;
47+
result.missingSymbol = symbol;
48+
break;
49+
}
50+
} else {
51+
const methodNode = findMethodNode(currentRoot, symbol);
52+
53+
if (!methodNode) {
54+
result.line = currentRoot.line ?? 1;
55+
result.isExactMatch = false;
56+
result.missingSymbol = symbol;
57+
break;
58+
}
59+
60+
result.line = methodNode.line;
61+
}
62+
}
63+
64+
return result;
65+
}
66+
67+
function isClassSymbol(symbol: string): boolean {
68+
return !symbol.includes('(');
69+
}
70+
71+
function findClassNode(root: ApexNode, symbol: string): ApexNode | undefined {
72+
return root.children?.find((child) => child.name === symbol && child.nature === 'Class');
73+
}
74+
75+
function findMethodNode(root: ApexNode, symbol: string): ApexMethodNode | undefined {
76+
const [methodName, params] = symbol.split('(');
77+
const paramStr = params?.replace(')', '').trim();
78+
79+
return root.children?.find(
80+
(child) =>
81+
child.name === methodName &&
82+
child.nature === 'Method' &&
83+
(paramStr === undefined || (child as ApexMethodNode).params === paramStr),
84+
) as ApexMethodNode;
85+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright (c) 2025 Certinia Inc. All rights reserved.
3+
*/
4+
import type {
5+
ApexParserVisitor,
6+
ClassDeclarationContext,
7+
FormalParametersContext,
8+
MethodDeclarationContext,
9+
} from '@apexdevtools/apex-parser';
10+
import type { ErrorNode, ParseTree, RuleNode, TerminalNode } from 'antlr4ts/tree';
11+
12+
type ApexNature = 'Class' | 'Method';
13+
14+
export interface ApexNode {
15+
nature?: ApexNature;
16+
name?: string;
17+
children?: ApexNode[];
18+
line?: number;
19+
}
20+
21+
export type ApexMethodNode = ApexNode & {
22+
nature: 'Method';
23+
params: string;
24+
line: number;
25+
};
26+
27+
type VisitableApex = ParseTree & {
28+
accept<Result>(visitor: ApexParserVisitor<Result>): Result;
29+
};
30+
31+
export class ApexVisitor implements ApexParserVisitor<ApexNode> {
32+
visit(ctx: ParseTree): ApexNode {
33+
return ctx ? (ctx as VisitableApex).accept(this) : {};
34+
}
35+
36+
visitChildren(ctx: RuleNode): ApexNode {
37+
const children: ApexNode[] = [];
38+
39+
for (let index = 0; index < ctx.childCount; index++) {
40+
const child = ctx.getChild(index);
41+
const node = this.visit(child);
42+
if (!node) {
43+
continue;
44+
}
45+
46+
this.forNode(node, (anon) => children.push(anon));
47+
}
48+
49+
return { children };
50+
}
51+
52+
visitClassDeclaration(ctx: ClassDeclarationContext): ApexNode {
53+
return {
54+
nature: 'Class',
55+
name: ctx.id().Identifier()?.toString() ?? '',
56+
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
57+
line: ctx.start.line,
58+
};
59+
}
60+
61+
visitMethodDeclaration(ctx: MethodDeclarationContext): ApexMethodNode {
62+
return {
63+
nature: 'Method',
64+
name: ctx.id().Identifier()?.toString() ?? '',
65+
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
66+
params: this.getParameters(ctx.formalParameters()),
67+
line: ctx.start.line,
68+
};
69+
}
70+
71+
visitTerminal(_ctx: TerminalNode): ApexNode {
72+
return {};
73+
}
74+
75+
visitErrorNode(_ctx: ErrorNode): ApexNode {
76+
return {};
77+
}
78+
79+
private getParameters(ctx: FormalParametersContext): string {
80+
const paramsList = ctx.formalParameterList()?.formalParameter();
81+
return paramsList?.map((param) => param.typeRef().typeName(0)?.text).join(', ') ?? '';
82+
}
83+
84+
private forNode(node: ApexNode, anonHandler: (n: ApexNode) => void) {
85+
if (this.isAnonNode(node)) {
86+
anonHandler(node);
87+
} else if (node.children?.length) {
88+
node.children.forEach((child) => anonHandler(child));
89+
}
90+
}
91+
92+
private isAnonNode(node: ApexNode) {
93+
return !!node.nature;
94+
}
95+
}

0 commit comments

Comments
 (0)