Skip to content

Commit 28bb527

Browse files
Merge pull request #703 from lukecotter/fix-goto-symbol
feat: go to symbol robustness and fuzzy arg matching
2 parents fba96b0 + fd1b478 commit 28bb527

6 files changed

Lines changed: 675 additions & 97 deletions

File tree

lana/src/display/OpenFileInPackage.ts

Lines changed: 8 additions & 7 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 { sep } from 'path';
4+
import { basename, sep } from 'path';
55
import {
66
Position,
77
Selection,
@@ -22,10 +22,10 @@ export class OpenFileInPackage {
2222
return;
2323
}
2424

25-
const parts = symbolName.split('.');
26-
const fileName = parts[0]?.trim();
25+
const parts = symbolName.slice(0, symbolName.indexOf('('));
2726

28-
const paths = await context.findSymbol(fileName as string);
27+
// findSymbol displays an error message if not found, so no need to duplicate here but it would probably be better here.
28+
const paths = await context.findSymbol(parts);
2929
if (!paths.length) {
3030
return;
3131
}
@@ -59,16 +59,17 @@ export class OpenFileInPackage {
5959

6060
const parsedRoot = parseApex(document.getText());
6161

62-
const symbolLocation = getMethodLine(parsedRoot, parts);
62+
const symbolLocation = getMethodLine(parsedRoot, symbolName);
6363

6464
if (!symbolLocation.isExactMatch) {
6565
context.display.showErrorMessage(
66-
`Symbol '${symbolLocation.missingSymbol}' could not be found in file '${fileName}'`,
66+
`Symbol '${symbolLocation.missingSymbol}' could not be found in file '${basename(path)}'`,
6767
);
6868
}
6969
const zeroIndexedLineNumber = symbolLocation.line - 1;
70+
const character = symbolLocation.character ?? 0;
7071

71-
const pos = new Position(zeroIndexedLineNumber, 0);
72+
const pos = new Position(zeroIndexedLineNumber, character);
7273

7374
const options: TextDocumentShowOptions = {
7475
preserveFocus: false,

lana/src/salesforce/ApexParser/ApexSymbolLocator.ts

Lines changed: 126 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ import {
88
CommonTokenStream,
99
} from '@apexdevtools/apex-parser';
1010
import { CharStreams } from 'antlr4ts';
11-
import { ApexVisitor, type ApexMethodNode, type ApexNode } from './ApexVisitor';
11+
import {
12+
ApexVisitor,
13+
type ApexConstructorNode,
14+
type ApexMethodNode,
15+
type ApexNode,
16+
} from './ApexVisitor';
1217

1318
export type SymbolLocation = {
19+
character: number;
1420
line: number;
1521
isExactMatch: boolean;
1622
missingSymbol?: string;
@@ -25,61 +31,143 @@ export function parseApex(apexCode: string): ApexNode {
2531
return new ApexVisitor().visit(parser.compilationUnit());
2632
}
2733

28-
export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLocation {
29-
const result: SymbolLocation = { line: 1, isExactMatch: true };
34+
export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): SymbolLocation {
35+
const result: SymbolLocation = { character: 0, line: 1, isExactMatch: false };
3036

31-
if (symbols[0] === rootNode.name) {
32-
symbols = symbols.slice(1);
37+
if (fullyQualifiedSymbol.indexOf('(') === -1) {
38+
return result;
3339
}
3440

35-
if (!symbols.length) {
36-
return result;
41+
// NOTE: The symbol may contain namespaces as symbols from the debug log are fully qualified e.g myns.MyClass.InnerClass.method(args)
42+
// We are attempting rudamentary handling of the case where symbols contain namespace but the parsed class does not but no guarantees it work in all cases.
43+
44+
// There are two possible symbol types method and constructor, args are optional
45+
// MyClass.method(args) - method, MyClass.InnerClass.method(args) - method
46+
// MyClass(args) - constuctor, MyClass.InnerClass(args) - constuctor
47+
48+
// Find the Namespace of the supplied symbol, if there is one.
49+
// strip all whitespace to make comparisons easier
50+
let symbolToFind = normalizeText(fullyQualifiedSymbol);
51+
const outerClassNode = rootNode.children?.[0];
52+
let outerClassName = normalizeText(outerClassNode?.name ?? '');
53+
const endNsIndex = symbolToFind.indexOf(outerClassName);
54+
const namespace = endNsIndex > 0 ? symbolToFind.slice(0, endNsIndex - 1) : '';
55+
if (namespace) {
56+
outerClassName = namespace + '.' + outerClassName;
57+
// Remove the leading namespace as most likely the source code will not have it, for symbols in this file.
58+
symbolToFind = symbolToFind.replace(namespace + '.', '');
3759
}
3860

61+
// This is the index of the first '(' which indicates method args or constructor args.
62+
const methodArgsIndex = symbolToFind.indexOf('(');
63+
// We can't tell the difference between InnerClass constructor and outer class method call.
64+
// As such className could either be the class name or the method name, we need to check.
65+
const className = symbolToFind.slice(0, methodArgsIndex);
3966
let currentRoot: ApexNode | undefined = rootNode;
67+
// Keep iterating until we find the last symbol that is a class.
68+
// The next symbol might be a method or might be invalid.
69+
for (const symbol of className.split('.')) {
70+
const nextRoot = findClassNode(currentRoot, symbol, namespace);
71+
if (!nextRoot) {
72+
break;
73+
}
74+
75+
currentRoot = nextRoot;
76+
}
77+
78+
if (currentRoot) {
79+
result.line = currentRoot.line ?? 1;
80+
result.character = currentRoot.idCharacter ?? 0;
81+
}
4082

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-
}
83+
// This is the method name before the args list, this may actually be a class name though so we need to check.
84+
// e.g for MyClass.InnerClass(args) we get InnerClass(args) but is this a method of InnerClass constructor?
85+
const qualifiedMethodName = symbolToFind.slice(className.lastIndexOf('.') + 1);
86+
if (qualifiedMethodName && currentRoot) {
87+
let methodNode: ApexMethodNode | ApexConstructorNode | undefined = findMethodNode(
88+
currentRoot,
89+
qualifiedMethodName,
90+
outerClassName,
91+
);
92+
if (!methodNode) {
93+
methodNode = findConstructorNode(currentRoot, qualifiedMethodName, outerClassName);
94+
}
5995

96+
if (methodNode) {
6097
result.line = methodNode.line;
98+
result.character = methodNode.idCharacter;
99+
result.isExactMatch = true;
100+
return result;
61101
}
62102
}
63103

104+
result.line = currentRoot.line ?? 1;
105+
result.isExactMatch = false;
106+
// keep the original case for error messages.
107+
result.missingSymbol = fullyQualifiedSymbol.slice(className.lastIndexOf('.') + 1);
64108
return result;
65109
}
66110

67-
function isClassSymbol(symbol: string): boolean {
68-
return !symbol.includes('(');
111+
function findClassNode(root: ApexNode, symbol: string, namespace: string): ApexNode | undefined {
112+
const symbolWithoutNamespace = symbol.replaceAll(namespace + '.', '');
113+
return root.children?.find((child) => {
114+
if (child.nature === 'Class') {
115+
const normalizedChildName = normalizeText(child.name ?? '');
116+
return normalizedChildName === symbol || normalizedChildName === symbolWithoutNamespace;
117+
}
118+
119+
return false;
120+
});
69121
}
70122

71-
function findClassNode(root: ApexNode, symbol: string): ApexNode | undefined {
72-
return root.children?.find((child) => child.name === symbol && child.nature === 'Class');
123+
function findMethodNode(
124+
root: ApexNode,
125+
symbol: string,
126+
outerClassName: string,
127+
): ApexMethodNode | undefined {
128+
const [methodName, params = ''] = symbol.slice(0, -1).split('(');
129+
// Try again but with the class name removed from args list. args from the debug log are fully qualified but they are not necessarily in the file,
130+
// as we only need to qualify for external types to the file.
131+
// (MyClass.ObjectArg) vs (ObjectArg) where MyClass is current class.
132+
const paramsWithoutClassName = params.replaceAll(outerClassName + '.', '');
133+
134+
return root.children?.find((child) => {
135+
if (child.nature === 'Method' && normalizeText(child.name ?? '') === methodName) {
136+
const methodChild = child as ApexMethodNode;
137+
const methodParams = normalizeText(methodChild.params);
138+
return (
139+
params === undefined || methodParams === params || methodParams === paramsWithoutClassName
140+
);
141+
}
142+
return false;
143+
}) as ApexMethodNode;
73144
}
74145

75-
function findMethodNode(root: ApexNode, symbol: string): ApexMethodNode | undefined {
76-
const [methodName, params] = symbol.split('(');
77-
const paramStr = params?.replace(')', '').trim();
146+
function findConstructorNode(
147+
root: ApexNode,
148+
symbol: string,
149+
outerClassName: string,
150+
): ApexConstructorNode | undefined {
151+
const [constructorName, params = ''] = symbol.slice(0, -1).split('(');
152+
// Try again but with the class name removed from args list. args from the debug log are fully qualified but they are not necessarily in the file,
153+
// as we only need to qualify for external types to the file.
154+
// (MyClass.ObjectArg) vs (ObjectArg) where MyClass is current class.
155+
const paramsWithoutClassName = params.replaceAll(outerClassName + '.', '');
156+
157+
return root.children?.find((child) => {
158+
if (child.nature === 'Constructor' && normalizeText(child.name ?? '') === constructorName) {
159+
const constructorChild = child as ApexConstructorNode;
160+
const constructorParams = normalizeText(constructorChild.params);
161+
return (
162+
params === undefined ||
163+
constructorParams === params ||
164+
constructorParams === paramsWithoutClassName
165+
);
166+
}
167+
return false;
168+
}) as ApexConstructorNode;
169+
}
78170

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;
171+
function normalizeText(text: string): string {
172+
return text?.replaceAll(' ', '').toLowerCase();
85173
}

lana/src/salesforce/ApexParser/ApexVisitor.ts

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,68 @@
44
import type {
55
ApexParserVisitor,
66
ClassDeclarationContext,
7+
ConstructorDeclarationContext,
78
FormalParametersContext,
89
MethodDeclarationContext,
910
} from '@apexdevtools/apex-parser';
1011
import type { ErrorNode, ParseTree, RuleNode, TerminalNode } from 'antlr4ts/tree';
1112

12-
type ApexNature = 'Class' | 'Method';
13+
type ApexNature = 'Constructor' | 'Class' | 'Method';
1314

15+
/**
16+
* Represents a node in the Apex syntax tree.
17+
* Can be either a class or method declaration with optional child nodes.
18+
*/
1419
export interface ApexNode {
20+
/** The type of Apex construct (Class or Method) */
1521
nature?: ApexNature;
22+
/** The name of the class or method */
1623
name?: string;
24+
/** Child nodes (nested classes or methods) */
1725
children?: ApexNode[];
26+
/** Line number where the node is declared */
1827
line?: number;
28+
/** Character position of the identifier on the line */
29+
idCharacter?: number;
1930
}
2031

21-
export type ApexMethodNode = ApexNode & {
22-
nature: 'Method';
32+
/**
33+
* Represents a class declaration node in the Apex syntax tree.
34+
* All properties are required (non-optional) to ensure complete class metadata.
35+
*/
36+
export interface ApexClassNode extends ApexNode {
37+
/** Indicates this node represents a class declaration */
38+
nature: 'Class';
39+
/** Line number where the class is declared */
40+
line: number;
41+
/** Character position of the class identifier on the line */
42+
idCharacter: number;
43+
}
44+
45+
export interface ApexParamNode extends ApexNode {
46+
/** Indicates this node represents a method declaration */
47+
nature: 'Method' | 'Constructor';
48+
/** Comma-separated list of parameter types for the method */
2349
params: string;
50+
/** Line number where the method is declared */
2451
line: number;
25-
};
52+
/** Character position of the method identifier on the line */
53+
idCharacter: number;
54+
}
55+
56+
/**
57+
* Represents a method declaration node in the Apex syntax tree.
58+
* All properties are required (non-optional) to ensure complete method metadata.
59+
*/
60+
export interface ApexMethodNode extends ApexParamNode {
61+
/** Indicates this node represents a method declaration */
62+
nature: 'Method';
63+
}
64+
65+
export interface ApexConstructorNode extends ApexParamNode {
66+
/** Indicates this node represents a method declaration */
67+
nature: 'Constructor';
68+
}
2669

2770
type VisitableApex = ParseTree & {
2871
accept<Result>(visitor: ApexParserVisitor<Result>): Result;
@@ -49,22 +92,45 @@ export class ApexVisitor implements ApexParserVisitor<ApexNode> {
4992
return { children };
5093
}
5194

52-
visitClassDeclaration(ctx: ClassDeclarationContext): ApexNode {
95+
visitClassDeclaration(ctx: ClassDeclarationContext): ApexClassNode {
96+
const { start } = ctx;
97+
const ident = ctx.id();
98+
5399
return {
54100
nature: 'Class',
55-
name: ctx.id().Identifier()?.toString() ?? '',
101+
name: ident.text ?? '',
56102
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
57-
line: ctx.start.line,
103+
line: start.line,
104+
idCharacter: ident.start.charPositionInLine ?? 0,
105+
};
106+
}
107+
108+
visitConstructorDeclaration(ctx: ConstructorDeclarationContext): ApexConstructorNode {
109+
const { start } = ctx;
110+
const idContexts = ctx.qualifiedName().id();
111+
const constructorName = idContexts[idContexts.length - 1];
112+
113+
return {
114+
nature: 'Constructor',
115+
name: constructorName?.text ?? '',
116+
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
117+
params: this.getParameters(ctx.formalParameters()),
118+
line: start.line,
119+
idCharacter: start.charPositionInLine ?? 0,
58120
};
59121
}
60122

61123
visitMethodDeclaration(ctx: MethodDeclarationContext): ApexMethodNode {
124+
const { start } = ctx;
125+
const ident = ctx.id();
126+
62127
return {
63128
nature: 'Method',
64-
name: ctx.id().Identifier()?.toString() ?? '',
129+
name: ident.text ?? '',
65130
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
66131
params: this.getParameters(ctx.formalParameters()),
67-
line: ctx.start.line,
132+
line: start.line,
133+
idCharacter: ident.start.charPositionInLine ?? 0,
68134
};
69135
}
70136

@@ -78,7 +144,7 @@ export class ApexVisitor implements ApexParserVisitor<ApexNode> {
78144

79145
private getParameters(ctx: FormalParametersContext): string {
80146
const paramsList = ctx.formalParameterList()?.formalParameter();
81-
return paramsList?.map((param) => param.typeRef().typeName(0)?.text).join(', ') ?? '';
147+
return paramsList?.map((param) => param.typeRef().text).join(',') ?? '';
82148
}
83149

84150
private forNode(node: ApexNode, anonHandler: (n: ApexNode) => void) {

0 commit comments

Comments
 (0)