diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index b37f01aa..a66ac1f6 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { sep } from 'path'; +import { basename, sep } from 'path'; import { Position, Selection, @@ -22,10 +22,10 @@ export class OpenFileInPackage { return; } - const parts = symbolName.split('.'); - const fileName = parts[0]?.trim(); + const parts = symbolName.slice(0, symbolName.indexOf('(')); - const paths = await context.findSymbol(fileName as string); + // findSymbol displays an error message if not found, so no need to duplicate here but it would probably be better here. + const paths = await context.findSymbol(parts); if (!paths.length) { return; } @@ -59,16 +59,17 @@ export class OpenFileInPackage { const parsedRoot = parseApex(document.getText()); - const symbolLocation = getMethodLine(parsedRoot, parts); + const symbolLocation = getMethodLine(parsedRoot, symbolName); if (!symbolLocation.isExactMatch) { context.display.showErrorMessage( - `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${fileName}'`, + `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${basename(path)}'`, ); } const zeroIndexedLineNumber = symbolLocation.line - 1; + const character = symbolLocation.character ?? 0; - const pos = new Position(zeroIndexedLineNumber, 0); + const pos = new Position(zeroIndexedLineNumber, character); const options: TextDocumentShowOptions = { preserveFocus: false, diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index 6ce450b2..9b6c1aec 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -8,9 +8,15 @@ import { CommonTokenStream, } from '@apexdevtools/apex-parser'; import { CharStreams } from 'antlr4ts'; -import { ApexVisitor, type ApexMethodNode, type ApexNode } from './ApexVisitor'; +import { + ApexVisitor, + type ApexConstructorNode, + type ApexMethodNode, + type ApexNode, +} from './ApexVisitor'; export type SymbolLocation = { + character: number; line: number; isExactMatch: boolean; missingSymbol?: string; @@ -25,61 +31,143 @@ export function parseApex(apexCode: string): ApexNode { return new ApexVisitor().visit(parser.compilationUnit()); } -export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLocation { - const result: SymbolLocation = { line: 1, isExactMatch: true }; +export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): SymbolLocation { + const result: SymbolLocation = { character: 0, line: 1, isExactMatch: false }; - if (symbols[0] === rootNode.name) { - symbols = symbols.slice(1); + if (fullyQualifiedSymbol.indexOf('(') === -1) { + return result; } - if (!symbols.length) { - return result; + // NOTE: The symbol may contain namespaces as symbols from the debug log are fully qualified e.g myns.MyClass.InnerClass.method(args) + // 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. + + // There are two possible symbol types method and constructor, args are optional + // MyClass.method(args) - method, MyClass.InnerClass.method(args) - method + // MyClass(args) - constuctor, MyClass.InnerClass(args) - constuctor + + // Find the Namespace of the supplied symbol, if there is one. + // strip all whitespace to make comparisons easier + let symbolToFind = normalizeText(fullyQualifiedSymbol); + const outerClassNode = rootNode.children?.[0]; + let outerClassName = normalizeText(outerClassNode?.name ?? ''); + const endNsIndex = symbolToFind.indexOf(outerClassName); + const namespace = endNsIndex > 0 ? symbolToFind.slice(0, endNsIndex - 1) : ''; + if (namespace) { + outerClassName = namespace + '.' + outerClassName; + // Remove the leading namespace as most likely the source code will not have it, for symbols in this file. + symbolToFind = symbolToFind.replace(namespace + '.', ''); } + // This is the index of the first '(' which indicates method args or constructor args. + const methodArgsIndex = symbolToFind.indexOf('('); + // We can't tell the difference between InnerClass constructor and outer class method call. + // As such className could either be the class name or the method name, we need to check. + const className = symbolToFind.slice(0, methodArgsIndex); let currentRoot: ApexNode | undefined = rootNode; + // Keep iterating until we find the last symbol that is a class. + // The next symbol might be a method or might be invalid. + for (const symbol of className.split('.')) { + const nextRoot = findClassNode(currentRoot, symbol, namespace); + if (!nextRoot) { + break; + } + + currentRoot = nextRoot; + } + + if (currentRoot) { + result.line = currentRoot.line ?? 1; + result.character = currentRoot.idCharacter ?? 0; + } - for (const symbol of symbols) { - if (isClassSymbol(symbol)) { - currentRoot = findClassNode(currentRoot, symbol); - - if (!currentRoot) { - result.isExactMatch = false; - result.missingSymbol = symbol; - break; - } - } else { - const methodNode = findMethodNode(currentRoot, symbol); - - if (!methodNode) { - result.line = currentRoot.line ?? 1; - result.isExactMatch = false; - result.missingSymbol = symbol; - break; - } + // This is the method name before the args list, this may actually be a class name though so we need to check. + // e.g for MyClass.InnerClass(args) we get InnerClass(args) but is this a method of InnerClass constructor? + const qualifiedMethodName = symbolToFind.slice(className.lastIndexOf('.') + 1); + if (qualifiedMethodName && currentRoot) { + let methodNode: ApexMethodNode | ApexConstructorNode | undefined = findMethodNode( + currentRoot, + qualifiedMethodName, + outerClassName, + ); + if (!methodNode) { + methodNode = findConstructorNode(currentRoot, qualifiedMethodName, outerClassName); + } + if (methodNode) { result.line = methodNode.line; + result.character = methodNode.idCharacter; + result.isExactMatch = true; + return result; } } + result.line = currentRoot.line ?? 1; + result.isExactMatch = false; + // keep the original case for error messages. + result.missingSymbol = fullyQualifiedSymbol.slice(className.lastIndexOf('.') + 1); return result; } -function isClassSymbol(symbol: string): boolean { - return !symbol.includes('('); +function findClassNode(root: ApexNode, symbol: string, namespace: string): ApexNode | undefined { + const symbolWithoutNamespace = symbol.replaceAll(namespace + '.', ''); + return root.children?.find((child) => { + if (child.nature === 'Class') { + const normalizedChildName = normalizeText(child.name ?? ''); + return normalizedChildName === symbol || normalizedChildName === symbolWithoutNamespace; + } + + return false; + }); } -function findClassNode(root: ApexNode, symbol: string): ApexNode | undefined { - return root.children?.find((child) => child.name === symbol && child.nature === 'Class'); +function findMethodNode( + root: ApexNode, + symbol: string, + outerClassName: string, +): ApexMethodNode | undefined { + const [methodName, params = ''] = symbol.slice(0, -1).split('('); + // 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, + // as we only need to qualify for external types to the file. + // (MyClass.ObjectArg) vs (ObjectArg) where MyClass is current class. + const paramsWithoutClassName = params.replaceAll(outerClassName + '.', ''); + + return root.children?.find((child) => { + if (child.nature === 'Method' && normalizeText(child.name ?? '') === methodName) { + const methodChild = child as ApexMethodNode; + const methodParams = normalizeText(methodChild.params); + return ( + params === undefined || methodParams === params || methodParams === paramsWithoutClassName + ); + } + return false; + }) as ApexMethodNode; } -function findMethodNode(root: ApexNode, symbol: string): ApexMethodNode | undefined { - const [methodName, params] = symbol.split('('); - const paramStr = params?.replace(')', '').trim(); +function findConstructorNode( + root: ApexNode, + symbol: string, + outerClassName: string, +): ApexConstructorNode | undefined { + const [constructorName, params = ''] = symbol.slice(0, -1).split('('); + // 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, + // as we only need to qualify for external types to the file. + // (MyClass.ObjectArg) vs (ObjectArg) where MyClass is current class. + const paramsWithoutClassName = params.replaceAll(outerClassName + '.', ''); + + return root.children?.find((child) => { + if (child.nature === 'Constructor' && normalizeText(child.name ?? '') === constructorName) { + const constructorChild = child as ApexConstructorNode; + const constructorParams = normalizeText(constructorChild.params); + return ( + params === undefined || + constructorParams === params || + constructorParams === paramsWithoutClassName + ); + } + return false; + }) as ApexConstructorNode; +} - return root.children?.find( - (child) => - child.name === methodName && - child.nature === 'Method' && - (paramStr === undefined || (child as ApexMethodNode).params === paramStr), - ) as ApexMethodNode; +function normalizeText(text: string): string { + return text?.replaceAll(' ', '').toLowerCase(); } diff --git a/lana/src/salesforce/ApexParser/ApexVisitor.ts b/lana/src/salesforce/ApexParser/ApexVisitor.ts index 1ce73255..0146fc5e 100644 --- a/lana/src/salesforce/ApexParser/ApexVisitor.ts +++ b/lana/src/salesforce/ApexParser/ApexVisitor.ts @@ -4,25 +4,68 @@ import type { ApexParserVisitor, ClassDeclarationContext, + ConstructorDeclarationContext, FormalParametersContext, MethodDeclarationContext, } from '@apexdevtools/apex-parser'; import type { ErrorNode, ParseTree, RuleNode, TerminalNode } from 'antlr4ts/tree'; -type ApexNature = 'Class' | 'Method'; +type ApexNature = 'Constructor' | 'Class' | 'Method'; +/** + * Represents a node in the Apex syntax tree. + * Can be either a class or method declaration with optional child nodes. + */ export interface ApexNode { + /** The type of Apex construct (Class or Method) */ nature?: ApexNature; + /** The name of the class or method */ name?: string; + /** Child nodes (nested classes or methods) */ children?: ApexNode[]; + /** Line number where the node is declared */ line?: number; + /** Character position of the identifier on the line */ + idCharacter?: number; } -export type ApexMethodNode = ApexNode & { - nature: 'Method'; +/** + * Represents a class declaration node in the Apex syntax tree. + * All properties are required (non-optional) to ensure complete class metadata. + */ +export interface ApexClassNode extends ApexNode { + /** Indicates this node represents a class declaration */ + nature: 'Class'; + /** Line number where the class is declared */ + line: number; + /** Character position of the class identifier on the line */ + idCharacter: number; +} + +export interface ApexParamNode extends ApexNode { + /** Indicates this node represents a method declaration */ + nature: 'Method' | 'Constructor'; + /** Comma-separated list of parameter types for the method */ params: string; + /** Line number where the method is declared */ line: number; -}; + /** Character position of the method identifier on the line */ + idCharacter: number; +} + +/** + * Represents a method declaration node in the Apex syntax tree. + * All properties are required (non-optional) to ensure complete method metadata. + */ +export interface ApexMethodNode extends ApexParamNode { + /** Indicates this node represents a method declaration */ + nature: 'Method'; +} + +export interface ApexConstructorNode extends ApexParamNode { + /** Indicates this node represents a method declaration */ + nature: 'Constructor'; +} type VisitableApex = ParseTree & { accept(visitor: ApexParserVisitor): Result; @@ -49,22 +92,45 @@ export class ApexVisitor implements ApexParserVisitor { return { children }; } - visitClassDeclaration(ctx: ClassDeclarationContext): ApexNode { + visitClassDeclaration(ctx: ClassDeclarationContext): ApexClassNode { + const { start } = ctx; + const ident = ctx.id(); + return { nature: 'Class', - name: ctx.id().Identifier()?.toString() ?? '', + name: ident.text ?? '', children: ctx.children?.length ? this.visitChildren(ctx).children : [], - line: ctx.start.line, + line: start.line, + idCharacter: ident.start.charPositionInLine ?? 0, + }; + } + + visitConstructorDeclaration(ctx: ConstructorDeclarationContext): ApexConstructorNode { + const { start } = ctx; + const idContexts = ctx.qualifiedName().id(); + const constructorName = idContexts[idContexts.length - 1]; + + return { + nature: 'Constructor', + name: constructorName?.text ?? '', + children: ctx.children?.length ? this.visitChildren(ctx).children : [], + params: this.getParameters(ctx.formalParameters()), + line: start.line, + idCharacter: start.charPositionInLine ?? 0, }; } visitMethodDeclaration(ctx: MethodDeclarationContext): ApexMethodNode { + const { start } = ctx; + const ident = ctx.id(); + return { nature: 'Method', - name: ctx.id().Identifier()?.toString() ?? '', + name: ident.text ?? '', children: ctx.children?.length ? this.visitChildren(ctx).children : [], params: this.getParameters(ctx.formalParameters()), - line: ctx.start.line, + line: start.line, + idCharacter: ident.start.charPositionInLine ?? 0, }; } @@ -78,7 +144,7 @@ export class ApexVisitor implements ApexParserVisitor { private getParameters(ctx: FormalParametersContext): string { const paramsList = ctx.formalParameterList()?.formalParameter(); - return paramsList?.map((param) => param.typeRef().typeName(0)?.text).join(', ') ?? ''; + return paramsList?.map((param) => param.typeRef().text).join(',') ?? ''; } private forNode(node: ApexNode, anonHandler: (n: ApexNode) => void) { diff --git a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts index d530e262..ef12a519 100644 --- a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts +++ b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts @@ -9,38 +9,90 @@ jest.mock('@apexdevtools/apex-parser'); describe('ApexSymbolLocator', () => { const mockAST = { - nature: 'Class', - name: 'MyClass', - line: 1, children: [ - { - nature: 'Method', - name: 'foo', - params: '', - line: 2, - }, - { - nature: 'Method', - name: 'bar', - params: 'Integer', - line: 3, - }, - { - nature: 'Method', - name: 'bar', - params: 'Integer, Integer', - line: 4, - }, { nature: 'Class', - name: 'Inner', - line: 5, + name: 'myclass', + line: 1, children: [ + { + nature: 'Method', + name: 'foo', + params: '', + line: 2, + }, + { + nature: 'Method', + name: 'bar', + params: 'integer', + line: 3, + }, { nature: 'Method', name: 'bar', - params: 'Integer', + params: 'integer,integer', + line: 4, + }, + { + nature: 'Method', + name: 'bar', + params: 'MyClass.InnerClass, InnerClass, integer,integer', + line: 5, + }, + { + nature: 'Class', + name: 'inner', line: 6, + children: [ + { + nature: 'Constructor', + name: 'Inner', + params: '', + line: 7, + }, + { + nature: 'Constructor', + name: 'Inner', + params: 'String', + line: 8, + }, + { + nature: 'Method', + name: 'bar', + params: 'integer', + line: 9, + }, + ], + }, + { + nature: 'Constructor', + name: 'MyClass', + params: '', + line: 10, + }, + { + nature: 'Constructor', + name: 'MyClass', + params: 'string', + line: 11, + }, + { + nature: 'Constructor', + name: 'MyClass', + params: 'string,integer', + line: 12, + }, + { + nature: 'Constructor', + name: 'MyClass', + params: 'Map, Map, String, Integer', + line: 13, + }, + { + nature: 'Class', + name: 'inner2', + line: 14, + children: [], }, ], }, @@ -70,48 +122,322 @@ describe('ApexSymbolLocator', () => { }); it('should find method line for top-level method', () => { - const result = getMethodLine(root, ['MyClass', 'foo()']); + const result = getMethodLine(root, 'MyClass.foo()'); expect(result.line).toBe(2); expect(result.isExactMatch).toBe(true); }); it('should find method line for method with params', () => { - const result = getMethodLine(root, ['MyClass', 'bar(Integer)']); + const result = getMethodLine(root, 'MyClass.bar(Integer)'); expect(result.line).toBe(3); expect(result.isExactMatch).toBe(true); }); it('should find method line for overloaded method', () => { - const result = getMethodLine(root, ['MyClass', 'bar(Integer, Integer)']); + const result = getMethodLine(root, 'MyClass.bar(Integer, Integer)'); expect(result.line).toBe(4); expect(result.isExactMatch).toBe(true); }); it('should find method line for inner class method', () => { - const result = getMethodLine(root, ['MyClass', 'Inner', 'bar(Integer)']); - expect(result.line).toBe(6); + const result = getMethodLine(root, 'MyClass.Inner.bar(Integer)'); + expect(result.line).toBe(9); expect(result.isExactMatch).toBe(true); }); it('should handle symbol not found', () => { - const result = getMethodLine(root, ['MyClass', 'notFound()']); + const result = getMethodLine(root, 'MyClass.notFound()'); expect(result.line).toBe(1); expect(result.isExactMatch).toBe(false); expect(result.missingSymbol).toBe('notFound()'); }); it('should handle symbol not found on inner class', () => { - const result = getMethodLine(root, ['MyClass', 'Inner', 'notFound()']); - expect(result.line).toBe(5); + const result = getMethodLine(root, 'MyClass.Inner.notFound()'); + expect(result.line).toBe(6); expect(result.isExactMatch).toBe(false); expect(result.missingSymbol).toBe('notFound()'); }); it('should handle missing class', () => { - const result = getMethodLine(root, ['NotAClass', 'foo()']); + const result = getMethodLine(root, 'NotAClass.foo()'); + expect(result.line).toBe(1); + expect(result.isExactMatch).toBe(false); + expect(result.missingSymbol).toBe('foo()'); + }); + }); + + describe('getMethodLine - constructor cases', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should find constructor with no parameters', () => { + const result = getMethodLine(root, 'MyClass()'); + expect(result.line).toBe(10); + expect(result.isExactMatch).toBe(true); + }); + + it('should find constructor with single parameter', () => { + const result = getMethodLine(root, 'MyClass(String)'); + expect(result.line).toBe(11); + expect(result.isExactMatch).toBe(true); + }); + + it('should find overloaded constructor with multiple parameters', () => { + const result = getMethodLine(root, 'MyClass(String, Integer)'); + expect(result.line).toBe(12); + expect(result.isExactMatch).toBe(true); + }); + + it('should find overloaded constructor with multiple parameters + custom types', () => { + const result = getMethodLine( + root, + 'MyClass(Map, Map, String, Integer)', + ); + expect(result.line).toBe(13); + expect(result.isExactMatch).toBe(true); + }); + + it('should find overloaded constructor with multiple parameters + custom types but missing class prefix', () => { + const result = getMethodLine( + root, + 'MyClass(Map, Map, String, Integer)', + ); + expect(result.line).toBe(1); + expect(result.isExactMatch).toBe(false); + }); + + it('should find inner constructors', () => { + let result = getMethodLine(root, 'MyClass.inner()'); + expect(result.line).toBe(7); + expect(result.isExactMatch).toBe(true); + + result = getMethodLine(root, 'MyClass.inner(string)'); + expect(result.line).toBe(8); + expect(result.isExactMatch).toBe(true); + }); + + it('should handle constructor not found with wrong params', () => { + const result = getMethodLine(root, 'MyClass(Boolean)'); + expect(result.line).toBe(1); + expect(result.isExactMatch).toBe(false); + expect(result.missingSymbol).toBe('MyClass(Boolean)'); + }); + + it('should handle case-insensitive constructor lookup', () => { + const result = getMethodLine(root, 'myclass()'); + expect(result.line).toBe(10); + expect(result.isExactMatch).toBe(true); + }); + + it('should handle case-insensitive parameter type lookup', () => { + const result = getMethodLine(root, 'MyClass(string)'); + expect(result.line).toBe(11); + expect(result.isExactMatch).toBe(true); + }); + }); + + describe('getMethodLine - namespace cases', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should find method with namespace prefix', () => { + const result = getMethodLine(root, 'myns.MyClass.foo()'); + expect(result.line).toBe(2); + expect(result.isExactMatch).toBe(true); + }); + + it('should find constructor with namespace prefix', () => { + const result = getMethodLine(root, 'myns.MyClass()'); + expect(result.line).toBe(10); + expect(result.isExactMatch).toBe(true); + }); + + it('should find inner class method with namespace', () => { + const result = getMethodLine(root, 'myns.MyClass.Inner.bar(Integer)'); + expect(result.line).toBe(9); + expect(result.isExactMatch).toBe(true); + }); + + it('should find constructor with namespace and parameters', () => { + const result = getMethodLine(root, 'myns.MyClass(String, Integer)'); + expect(result.line).toBe(12); + expect(result.isExactMatch).toBe(true); + }); + + it('should find overloaded method with namespace', () => { + const result = getMethodLine(root, 'ns.MyClass.bar(Integer, Integer)'); + expect(result.line).toBe(4); + expect(result.isExactMatch).toBe(true); + }); + + it('should handle missing method with namespace', () => { + const result = getMethodLine(root, 'myns.MyClass.missing()'); expect(result.line).toBe(1); expect(result.isExactMatch).toBe(false); - expect(result.missingSymbol).toBe('NotAClass'); + expect(result.missingSymbol).toBe('lass.missing()'); + }); + + it('should handle namespace with missing inner class method', () => { + const result = getMethodLine(root, 'ns.MyClass.Inner.notFound()'); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + expect(result.missingSymbol).toBe('er.notFound()'); + }); + + it('should ignore namespace and find correct class', () => { + const result = getMethodLine(root, 'com.example.MyClass.bar(Integer)'); + expect(result.line).toBe(3); + expect(result.isExactMatch).toBe(true); + }); + + it('should handle case-insensitive namespace', () => { + const result = getMethodLine(root, 'MYNS.myclass.foo()'); + expect(result.line).toBe(2); + expect(result.isExactMatch).toBe(true); + }); + }); + + describe('getMethodLine - inner class methods', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should find inner class method bar with single integer param', () => { + const result = getMethodLine(root, 'MyClass.Inner.bar(Integer)'); + expect(result.line).toBe(9); + expect(result.isExactMatch).toBe(true); + }); + + it('should return inner class line when method with qualified type parameters not found', () => { + const result = getMethodLine( + root, + 'MyClass.Inner.bar(MyClass.InnerClass, InnerClass, Integer, Integer)', + ); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + }); + + it('should return inner class line when method not found in inner class', () => { + const result = getMethodLine(root, 'MyClass.Inner.missingMethod()'); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + expect(result.missingSymbol).toBe('missingMethod()'); + }); + + it('should return inner class line when constructor not found in inner class', () => { + const result = getMethodLine(root, 'MyClass.Inner(Boolean)'); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + expect(result.missingSymbol).toBe('Inner(Boolean)'); + }); + + it('should find inner class constructor with no parameters', () => { + const result = getMethodLine(root, 'MyClass.Inner()'); + expect(result.line).toBe(7); + expect(result.isExactMatch).toBe(true); + }); + + it('should find inner class constructor with string parameter', () => { + const result = getMethodLine(root, 'MyClass.Inner(String)'); + expect(result.line).toBe(8); + expect(result.isExactMatch).toBe(true); + }); + + it('should return method line when inner class not found', () => { + const result = getMethodLine(root, 'MyClass.NonExistent.foo()'); + expect(result.line).toBe(2); + expect(result.isExactMatch).toBe(true); + }); + }); + + describe('getMethodLine - fallback to class line', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should return outer class line when method not found', () => { + const result = getMethodLine(root, 'MyClass.unknownMethod()'); + expect(result.line).toBe(1); + expect(result.isExactMatch).toBe(false); + }); + + it('should return outer class line when constructor not found', () => { + const result = getMethodLine(root, 'MyClass(Double)'); + expect(result.line).toBe(1); + expect(result.isExactMatch).toBe(false); + }); + + it('should return inner class line when method not found in inner class', () => { + const result = getMethodLine(root, 'MyClass.Inner.unknownMethod()'); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + }); + + it('should return inner class line when constructor params do not match', () => { + const result = getMethodLine(root, 'MyClass.Inner(Double)'); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + }); + + it('should return outer class line with namespace when method not found', () => { + const result = getMethodLine(root, 'ns1.ns2.MyClass.unknownMethod()'); + expect(result.line).toBe(1); + expect(result.isExactMatch).toBe(false); + }); + + it('should return inner class line with namespace when inner method not found', () => { + const result = getMethodLine(root, 'ns.MyClass.Inner.unknownMethod()'); + expect(result.line).toBe(6); + expect(result.isExactMatch).toBe(false); + }); + + it('should return inner class line when no default constructor found', () => { + const result = getMethodLine(root, 'MyClass.Inner2()'); + expect(result.line).toBe(14); + expect(result.isExactMatch).toBe(false); + }); + }); + + describe('getMethodLine - inner class with multiple constructors', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should find first inner class constructor (no params)', () => { + const result = getMethodLine(root, 'MyClass.Inner()'); + expect(result.line).toBe(7); + expect(result.isExactMatch).toBe(true); + }); + + it('should find second inner class constructor (string param)', () => { + const result = getMethodLine(root, 'MyClass.Inner(String)'); + expect(result.line).toBe(8); + expect(result.isExactMatch).toBe(true); + }); + + it('should handle case-insensitive inner class constructor lookup', () => { + const result = getMethodLine(root, 'myclass.inner()'); + expect(result.line).toBe(7); + expect(result.isExactMatch).toBe(true); + }); + + it('should handle case-insensitive inner class constructor parameter lookup', () => { + const result = getMethodLine(root, 'MyClass.INNER(string)'); + expect(result.line).toBe(8); + expect(result.isExactMatch).toBe(true); }); }); }); diff --git a/lana/src/salesforce/__tests__/ApexVisitor.test.ts b/lana/src/salesforce/__tests__/ApexVisitor.test.ts index a1b94b88..d3167397 100644 --- a/lana/src/salesforce/__tests__/ApexVisitor.test.ts +++ b/lana/src/salesforce/__tests__/ApexVisitor.test.ts @@ -19,7 +19,8 @@ describe('ApexVisitor', () => { it('should return class node with name and children', () => { const ctx = { id: () => ({ - Identifier: () => ({ toString: () => 'MyClass' }), + text: 'MyClass', + start: { charPositionInLine: 0 }, }), children: [{}], get childCount() { @@ -45,7 +46,8 @@ describe('ApexVisitor', () => { it('should handle missing Identifier', () => { const ctx = { id: () => ({ - Identifier: () => undefined, + text: '', + start: { charPositionInLine: 0 }, }), children: [], start: { line: 10 }, @@ -61,7 +63,8 @@ describe('ApexVisitor', () => { it('should handle missing children', () => { const ctx = { id: () => ({ - Identifier: () => ({ toString: () => 'NoChildren' }), + text: 'NoChildren', + start: { charPositionInLine: 0 }, }), children: undefined, start: { line: 15 }, @@ -78,14 +81,15 @@ describe('ApexVisitor', () => { it('should return method node with name, params, and line', () => { const ctx = { id: () => ({ - Identifier: () => ({ toString: () => 'myMethod' }), + text: 'myMethod', + start: { charPositionInLine: 2 }, }), children: [{}], formalParameters: () => ({ formalParameterList: () => ({ formalParameter: () => [ - { typeRef: () => ({ typeName: () => ({ text: 'Integer' }) }) }, - { typeRef: () => ({ typeName: () => ({ text: 'String' }) }) }, + { typeRef: () => ({ text: 'Integer' }) }, + { typeRef: () => ({ text: 'String' }) }, ], }), }), @@ -97,14 +101,15 @@ describe('ApexVisitor', () => { expect(node.nature).toBe('Method'); expect(node.name).toBe('myMethod'); - expect(node.params).toBe('Integer, String'); + expect(node.params).toBe('Integer,String'); expect(node.line).toBe(42); }); it('should handle missing Identifier and params', () => { const ctx = { id: () => ({ - Identifier: () => undefined, + text: '', + start: { charPositionInLine: 0 }, }), children: [], formalParameters: () => ({ @@ -121,6 +126,79 @@ describe('ApexVisitor', () => { }); }); + describe('visitConstructorDeclaration', () => { + it('should return constructor node with name, params, and line', () => { + const ctx = { + qualifiedName: () => ({ + id: () => [{ text: 'OuterClass' }, { text: 'MyConstructor' }], + }), + children: [{}], + formalParameters: () => ({ + formalParameterList: () => ({ + formalParameter: () => [ + { typeRef: () => ({ text: 'String' }) }, + { typeRef: () => ({ text: 'Integer' }) }, + ], + }), + }), + start: { line: 20, charPositionInLine: 5 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitConstructorDeclaration(ctx as any); + + expect(node.nature).toBe('Constructor'); + expect(node.name).toBe('MyConstructor'); + expect(node.params).toBe('String,Integer'); + expect(node.line).toBe(20); + expect(node.idCharacter).toBe(5); + }); + + it('should handle constructor with no params', () => { + const ctx = { + qualifiedName: () => ({ + id: () => [{ text: 'MyClass' }], + }), + children: [], + formalParameters: () => ({ + formalParameterList: () => undefined, + }), + start: { line: 10, charPositionInLine: 2 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitConstructorDeclaration(ctx as any); + + expect(node.nature).toBe('Constructor'); + expect(node.name).toBe('MyClass'); + expect(node.params).toBe(''); + expect(node.line).toBe(10); + }); + + it('should handle nested class constructor', () => { + const ctx = { + qualifiedName: () => ({ + id: () => [{ text: 'OuterClass' }, { text: 'InnerClass' }, { text: 'InnerClass' }], + }), + children: [{}], + formalParameters: () => ({ + formalParameterList: () => ({ + formalParameter: () => [{ typeRef: () => ({ text: 'Boolean' }) }], + }), + }), + start: { line: 35, charPositionInLine: 10 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitConstructorDeclaration(ctx as any); + + expect(node.nature).toBe('Constructor'); + expect(node.name).toBe('InnerClass'); + expect(node.params).toBe('Boolean'); + expect(node.line).toBe(35); + }); + }); + describe('visitTerminal', () => { it('should return empty object', () => { expect(visitor.visitTerminal({} as any)).toEqual({}); diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index e1c3b3fb..85b81802 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -5,7 +5,17 @@ import { type Workspace } from '@apexdevtools/apex-ls'; import { VSWorkspace } from '../../workspace/VSWorkspace.js'; +/** + * Finds Apex symbol definitions (classes) within Salesforce workspaces. + * Searches across multiple workspaces and supports nested symbol resolution. + */ export class SymbolFinder { + /** + * Searches for a symbol across multiple workspaces. + * @param workspaces - Array of VS Code workspaces to search in + * @param symbol - The fully qualified or partial symbol name to find + * @returns Array of file paths to .cls files containing the symbol + */ async findSymbol(workspaces: VSWorkspace[], symbol: string): Promise { // Dynamic import for code splitting. Improves performance by reducing the amount of JS that is loaded and parsed at the start. // eslint-disable-next-line @typescript-eslint/naming-convention @@ -22,8 +32,16 @@ export class SymbolFinder { return paths; } + /** + * Searches for a symbol within a single workspace, recursively resolving nested symbols. + * If a symbol is not found, attempts to find its parent namespace by removing the last segment. + * @param ws - The Apex workspace to search in + * @param symbol - The symbol name to find (can be fully qualified like 'namespace.ClassName') + * @returns Path to the .cls file containing the symbol, or null if not found + */ private findInWorkspace(ws: Workspace, symbol: string): string | null { const paths = ws.findType(symbol); + if (paths.length === 0) { const parts = symbol.split('.'); if (parts.length > 1) { @@ -32,6 +50,7 @@ export class SymbolFinder { } return null; } + return paths.find((path) => path.endsWith('.cls')) || null; } }