Skip to content

Commit fd1b478

Browse files
committed
feat: Enhance symbol resolution by adding constructor support and improving test coverage
1 parent 92cb9d6 commit fd1b478

6 files changed

Lines changed: 567 additions & 111 deletions

File tree

lana/src/display/OpenFileInPackage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class OpenFileInPackage {
2424

2525
const parts = symbolName.slice(0, symbolName.indexOf('('));
2626

27+
// findSymbol displays an error message if not found, so no need to duplicate here but it would probably be better here.
2728
const paths = await context.findSymbol(parts);
2829
if (!paths.length) {
2930
return;

lana/src/salesforce/ApexParser/ApexSymbolLocator.ts

Lines changed: 80 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ 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 = {
1419
character: number;
@@ -27,7 +32,7 @@ export function parseApex(apexCode: string): ApexNode {
2732
}
2833

2934
export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): SymbolLocation {
30-
const result: SymbolLocation = { character: 0, line: 1, isExactMatch: true };
35+
const result: SymbolLocation = { character: 0, line: 1, isExactMatch: false };
3136

3237
if (fullyQualifiedSymbol.indexOf('(') === -1) {
3338
return result;
@@ -41,24 +46,23 @@ export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string):
4146
// MyClass(args) - constuctor, MyClass.InnerClass(args) - constuctor
4247

4348
// Find the Namespace of the supplied symbol, if there is one.
49+
// strip all whitespace to make comparisons easier
50+
let symbolToFind = normalizeText(fullyQualifiedSymbol);
4451
const outerClassNode = rootNode.children?.[0];
45-
let outerClassName = outerClassNode?.name ?? '';
46-
const endNsIndex = fullyQualifiedSymbol.indexOf(outerClassName);
47-
const namespace = endNsIndex > 0 ? fullyQualifiedSymbol.slice(0, endNsIndex - 1) : '';
52+
let outerClassName = normalizeText(outerClassNode?.name ?? '');
53+
const endNsIndex = symbolToFind.indexOf(outerClassName);
54+
const namespace = endNsIndex > 0 ? symbolToFind.slice(0, endNsIndex - 1) : '';
4855
if (namespace) {
4956
outerClassName = namespace + '.' + outerClassName;
5057
// Remove the leading namespace as most likely the source code will not have it, for symbols in this file.
51-
fullyQualifiedSymbol = fullyQualifiedSymbol.replace(namespace + '.', '');
58+
symbolToFind = symbolToFind.replace(namespace + '.', '');
5259
}
5360

54-
// strip all whitespace to make comparisons easier
55-
fullyQualifiedSymbol = fullyQualifiedSymbol.replaceAll(' ', '').toLowerCase();
56-
5761
// This is the index of the first '(' which indicates method args or constructor args.
58-
const methodArgsIndex = fullyQualifiedSymbol.indexOf('(');
62+
const methodArgsIndex = symbolToFind.indexOf('(');
5963
// We can't tell the difference between InnerClass constructor and outer class method call.
6064
// As such className could either be the class name or the method name, we need to check.
61-
const className = fullyQualifiedSymbol.slice(0, methodArgsIndex);
65+
const className = symbolToFind.slice(0, methodArgsIndex);
6266
let currentRoot: ApexNode | undefined = rootNode;
6367
// Keep iterating until we find the last symbol that is a class.
6468
// The next symbol might be a method or might be invalid.
@@ -76,71 +80,94 @@ export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string):
7680
result.character = currentRoot.idCharacter ?? 0;
7781
}
7882

79-
// TODO: enchance to find constructors as well as methods
80-
8183
// This is the method name before the args list, this may actually be a class name though so we need to check.
8284
// e.g for MyClass.InnerClass(args) we get InnerClass(args) but is this a method of InnerClass constructor?
83-
const qualifiedMethodName = fullyQualifiedSymbol.slice(className.lastIndexOf('.') + 1);
85+
const qualifiedMethodName = symbolToFind.slice(className.lastIndexOf('.') + 1);
8486
if (qualifiedMethodName && currentRoot) {
85-
const methodNode = findMethodNode(currentRoot, qualifiedMethodName, outerClassName);
86-
87+
let methodNode: ApexMethodNode | ApexConstructorNode | undefined = findMethodNode(
88+
currentRoot,
89+
qualifiedMethodName,
90+
outerClassName,
91+
);
8792
if (!methodNode) {
88-
result.line = currentRoot.line ?? 1;
89-
result.isExactMatch = false;
90-
result.missingSymbol = qualifiedMethodName;
91-
} else {
93+
methodNode = findConstructorNode(currentRoot, qualifiedMethodName, outerClassName);
94+
}
95+
96+
if (methodNode) {
9297
result.line = methodNode.line;
9398
result.character = methodNode.idCharacter;
99+
result.isExactMatch = true;
100+
return result;
94101
}
95102
}
96103

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);
97108
return result;
98109
}
99110

100111
function findClassNode(root: ApexNode, symbol: string, namespace: string): ApexNode | undefined {
101-
const classNode = root.children?.find(
102-
(child) => child.name === symbol && child.nature === 'Class',
103-
);
104-
if (classNode) {
105-
return classNode;
106-
}
107-
108-
if (namespace) {
109-
return root.children?.find(
110-
(child) => child.name === symbol.replaceAll(namespace + '.', '') && child.nature === 'Class',
111-
);
112-
}
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+
}
113118

114-
return undefined;
119+
return false;
120+
});
115121
}
116122

117123
function findMethodNode(
118124
root: ApexNode,
119125
symbol: string,
120126
outerClassName: string,
121127
): ApexMethodNode | undefined {
122-
const [methodName, args = ''] = symbol.slice(0, -1).split('(');
123-
let params = args;
124-
125-
const methodNode = root.children?.find(
126-
(child) =>
127-
child.name === methodName &&
128-
child.nature === 'Method' &&
129-
(params === undefined || (child as ApexMethodNode).params.toLowerCase() === params),
130-
) as ApexMethodNode;
131-
132-
if (methodNode) {
133-
return methodNode;
134-
}
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;
144+
}
135145

146+
function findConstructorNode(
147+
root: ApexNode,
148+
symbol: string,
149+
outerClassName: string,
150+
): ApexConstructorNode | undefined {
151+
const [constructorName, params = ''] = symbol.slice(0, -1).split('(');
136152
// 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,
137153
// as we only need to qualify for external types to the file.
138154
// (MyClass.ObjectArg) vs (ObjectArg) where MyClass is current class.
139-
params = params.replaceAll(outerClassName + '.', '');
140-
return root.children?.find(
141-
(child) =>
142-
child.name === methodName &&
143-
child.nature === 'Method' &&
144-
(params === undefined || (child as ApexMethodNode).params.toLowerCase() === params),
145-
) as ApexMethodNode;
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+
}
170+
171+
function normalizeText(text: string): string {
172+
return text?.replaceAll(' ', '').toLowerCase();
146173
}

lana/src/salesforce/ApexParser/ApexVisitor.ts

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
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

1415
/**
1516
* Represents a node in the Apex syntax tree.
@@ -18,7 +19,7 @@ type ApexNature = 'Class' | 'Method';
1819
export interface ApexNode {
1920
/** The type of Apex construct (Class or Method) */
2021
nature?: ApexNature;
21-
/** The name of the class or method, in lower case */
22+
/** The name of the class or method */
2223
name?: string;
2324
/** Child nodes (nested classes or methods) */
2425
children?: ApexNode[];
@@ -41,13 +42,9 @@ export interface ApexClassNode extends ApexNode {
4142
idCharacter: number;
4243
}
4344

44-
/**
45-
* Represents a method declaration node in the Apex syntax tree.
46-
* All properties are required (non-optional) to ensure complete method metadata.
47-
*/
48-
export interface ApexMethodNode extends ApexNode {
45+
export interface ApexParamNode extends ApexNode {
4946
/** Indicates this node represents a method declaration */
50-
nature: 'Method';
47+
nature: 'Method' | 'Constructor';
5148
/** Comma-separated list of parameter types for the method */
5249
params: string;
5350
/** Line number where the method is declared */
@@ -56,6 +53,20 @@ export interface ApexMethodNode extends ApexNode {
5653
idCharacter: number;
5754
}
5855

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+
}
69+
5970
type VisitableApex = ParseTree & {
6071
accept<Result>(visitor: ApexParserVisitor<Result>): Result;
6172
};
@@ -87,24 +98,39 @@ export class ApexVisitor implements ApexParserVisitor<ApexNode> {
8798

8899
return {
89100
nature: 'Class',
90-
name: ident.text.toLowerCase(),
101+
name: ident.text ?? '',
91102
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
92103
line: start.line,
93104
idCharacter: ident.start.charPositionInLine ?? 0,
94105
};
95106
}
96107

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,
120+
};
121+
}
122+
97123
visitMethodDeclaration(ctx: MethodDeclarationContext): ApexMethodNode {
98124
const { start } = ctx;
99125
const ident = ctx.id();
100126

101127
return {
102128
nature: 'Method',
103-
name: ident.text.toLowerCase(),
129+
name: ident.text ?? '',
104130
children: ctx.children?.length ? this.visitChildren(ctx).children : [],
105131
params: this.getParameters(ctx.formalParameters()),
106132
line: start.line,
107-
idCharacter: ident.start.charPositionInLine,
133+
idCharacter: ident.start.charPositionInLine ?? 0,
108134
};
109135
}
110136

@@ -118,11 +144,7 @@ export class ApexVisitor implements ApexParserVisitor<ApexNode> {
118144

119145
private getParameters(ctx: FormalParametersContext): string {
120146
const paramsList = ctx.formalParameterList()?.formalParameter();
121-
return (
122-
paramsList
123-
?.map((param) => param.typeRef().text.replaceAll(' ', '').toLowerCase())
124-
.join(',') ?? ''
125-
);
147+
return paramsList?.map((param) => param.typeRef().text).join(',') ?? '';
126148
}
127149

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

0 commit comments

Comments
 (0)