From ac88f68ed38e9e12e1ff25a3278bb230111d7961 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:35:08 +0000 Subject: [PATCH 1/5] doc: Enhance documentation for SymbolFinder class and its methods --- lana/src/salesforce/codesymbol/SymbolFinder.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index e1c3b3fb..70d7855b 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,6 +32,13 @@ 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) { From b32b8f4423f4bfd14778f7ca8fa6954aac3e34b1 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:00:54 +0000 Subject: [PATCH 2/5] fix: Update error message to use basename for file name in OpenFileInPackage --- lana/src/display/OpenFileInPackage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index b37f01aa..9206cffd 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, @@ -63,7 +63,7 @@ export class OpenFileInPackage { 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; From 58beb746f24ff4e1a4db57383603e49d7b20a460 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:50:34 +0000 Subject: [PATCH 3/5] feat: go to symbol now goes to character instead of line e.g for `public void method()` the cursor goes before method. It makes it easier to spot the selected symbol. --- lana/src/display/OpenFileInPackage.ts | 10 ++-- .../ApexParser/ApexSymbolLocator.ts | 5 +- lana/src/salesforce/ApexParser/ApexVisitor.ts | 50 +++++++++++++++++-- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index 9206cffd..41ab2c58 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -22,10 +22,9 @@ 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); + const paths = await context.findSymbol(parts); if (!paths.length) { return; } @@ -59,7 +58,7 @@ export class OpenFileInPackage { const parsedRoot = parseApex(document.getText()); - const symbolLocation = getMethodLine(parsedRoot, parts); + const symbolLocation = getMethodLine(parsedRoot, symbolName); if (!symbolLocation.isExactMatch) { context.display.showErrorMessage( @@ -67,8 +66,9 @@ export class OpenFileInPackage { ); } 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..e68419fb 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -11,6 +11,7 @@ import { CharStreams } from 'antlr4ts'; import { ApexVisitor, type ApexMethodNode, type ApexNode } from './ApexVisitor'; export type SymbolLocation = { + character: number; line: number; isExactMatch: boolean; missingSymbol?: string; @@ -26,7 +27,7 @@ export function parseApex(apexCode: string): ApexNode { } export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLocation { - const result: SymbolLocation = { line: 1, isExactMatch: true }; + const result: SymbolLocation = { character: 0, line: 1, isExactMatch: true }; if (symbols[0] === rootNode.name) { symbols = symbols.slice(1); @@ -52,12 +53,14 @@ export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLoca if (!methodNode) { result.line = currentRoot.line ?? 1; + result.character = currentRoot.idCharacter ?? 0; result.isExactMatch = false; result.missingSymbol = symbol; break; } result.line = methodNode.line; + result.character = methodNode.idCharacter; } } diff --git a/lana/src/salesforce/ApexParser/ApexVisitor.ts b/lana/src/salesforce/ApexParser/ApexVisitor.ts index 1ce73255..5c58dd18 100644 --- a/lana/src/salesforce/ApexParser/ApexVisitor.ts +++ b/lana/src/salesforce/ApexParser/ApexVisitor.ts @@ -11,18 +11,50 @@ import type { ErrorNode, ParseTree, RuleNode, TerminalNode } from 'antlr4ts/tree type ApexNature = '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; +} + +/** + * 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 type ApexMethodNode = ApexNode & { +/** + * 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 ApexNode { + /** Indicates this node represents a method declaration */ nature: 'Method'; + /** 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; +} type VisitableApex = ParseTree & { accept(visitor: ApexParserVisitor): Result; @@ -49,22 +81,30 @@ 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() ?? '', children: ctx.children?.length ? this.visitChildren(ctx).children : [], - line: ctx.start.line, + line: start.line, + idCharacter: ident.start.charPositionInLine ?? 0, }; } visitMethodDeclaration(ctx: MethodDeclarationContext): ApexMethodNode { + const { start } = ctx; + const ident = ctx.id(); + return { nature: 'Method', name: ctx.id().Identifier()?.toString() ?? '', children: ctx.children?.length ? this.visitChildren(ctx).children : [], params: this.getParameters(ctx.formalParameters()), - line: ctx.start.line, + line: start.line, + idCharacter: ident.start.charPositionInLine, }; } From 92cb9d6f4f231883b8f9ab9180719a0326d848ee Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:07:55 +0000 Subject: [PATCH 4/5] fix: Refactor getMethodLine to handle fully qualified symbols and improve method resolution Done via a combination of - match arg list directly with string from log, if no match then try again with the outer class name stripped from the args since the source code probably does not reference it (but could) - strip spaces from arg list so they can also be compared --- .../ApexParser/ApexSymbolLocator.ts | 120 +++++++++++++----- lana/src/salesforce/ApexParser/ApexVisitor.ts | 12 +- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index e68419fb..ad95f29f 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -26,39 +26,69 @@ export function parseApex(apexCode: string): ApexNode { return new ApexVisitor().visit(parser.compilationUnit()); } -export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLocation { +export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): SymbolLocation { const result: SymbolLocation = { character: 0, line: 1, isExactMatch: true }; - 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. + const outerClassNode = rootNode.children?.[0]; + let outerClassName = outerClassNode?.name ?? ''; + const endNsIndex = fullyQualifiedSymbol.indexOf(outerClassName); + const namespace = endNsIndex > 0 ? fullyQualifiedSymbol.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. + fullyQualifiedSymbol = fullyQualifiedSymbol.replace(namespace + '.', ''); } + // strip all whitespace to make comparisons easier + fullyQualifiedSymbol = fullyQualifiedSymbol.replaceAll(' ', '').toLowerCase(); + + // This is the index of the first '(' which indicates method args or constructor args. + const methodArgsIndex = fullyQualifiedSymbol.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 = fullyQualifiedSymbol.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; + } - for (const symbol of symbols) { - if (isClassSymbol(symbol)) { - currentRoot = findClassNode(currentRoot, symbol); + currentRoot = nextRoot; + } - if (!currentRoot) { - result.isExactMatch = false; - result.missingSymbol = symbol; - break; - } - } else { - const methodNode = findMethodNode(currentRoot, symbol); + if (currentRoot) { + result.line = currentRoot.line ?? 1; + result.character = currentRoot.idCharacter ?? 0; + } - if (!methodNode) { - result.line = currentRoot.line ?? 1; - result.character = currentRoot.idCharacter ?? 0; - result.isExactMatch = false; - result.missingSymbol = symbol; - break; - } + // TODO: enchance to find constructors as well as methods + // 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 = fullyQualifiedSymbol.slice(className.lastIndexOf('.') + 1); + if (qualifiedMethodName && currentRoot) { + const methodNode = findMethodNode(currentRoot, qualifiedMethodName, outerClassName); + + if (!methodNode) { + result.line = currentRoot.line ?? 1; + result.isExactMatch = false; + result.missingSymbol = qualifiedMethodName; + } else { result.line = methodNode.line; result.character = methodNode.idCharacter; } @@ -67,22 +97,50 @@ export function getMethodLine(rootNode: ApexNode, symbols: string[]): SymbolLoca return result; } -function isClassSymbol(symbol: string): boolean { - return !symbol.includes('('); -} +function findClassNode(root: ApexNode, symbol: string, namespace: string): ApexNode | undefined { + const classNode = root.children?.find( + (child) => child.name === symbol && child.nature === 'Class', + ); + if (classNode) { + return classNode; + } + + if (namespace) { + return root.children?.find( + (child) => child.name === symbol.replaceAll(namespace + '.', '') && child.nature === 'Class', + ); + } -function findClassNode(root: ApexNode, symbol: string): ApexNode | undefined { - return root.children?.find((child) => child.name === symbol && child.nature === 'Class'); + return undefined; } -function findMethodNode(root: ApexNode, symbol: string): ApexMethodNode | undefined { - const [methodName, params] = symbol.split('('); - const paramStr = params?.replace(')', '').trim(); +function findMethodNode( + root: ApexNode, + symbol: string, + outerClassName: string, +): ApexMethodNode | undefined { + const [methodName, args = ''] = symbol.slice(0, -1).split('('); + let params = args; + + const methodNode = root.children?.find( + (child) => + child.name === methodName && + child.nature === 'Method' && + (params === undefined || (child as ApexMethodNode).params.toLowerCase() === params), + ) as ApexMethodNode; + + if (methodNode) { + return methodNode; + } + // 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. + params = params.replaceAll(outerClassName + '.', ''); return root.children?.find( (child) => child.name === methodName && child.nature === 'Method' && - (paramStr === undefined || (child as ApexMethodNode).params === paramStr), + (params === undefined || (child as ApexMethodNode).params.toLowerCase() === params), ) as ApexMethodNode; } diff --git a/lana/src/salesforce/ApexParser/ApexVisitor.ts b/lana/src/salesforce/ApexParser/ApexVisitor.ts index 5c58dd18..f5fa4071 100644 --- a/lana/src/salesforce/ApexParser/ApexVisitor.ts +++ b/lana/src/salesforce/ApexParser/ApexVisitor.ts @@ -18,7 +18,7 @@ type ApexNature = 'Class' | 'Method'; export interface ApexNode { /** The type of Apex construct (Class or Method) */ nature?: ApexNature; - /** The name of the class or method */ + /** The name of the class or method, in lower case */ name?: string; /** Child nodes (nested classes or methods) */ children?: ApexNode[]; @@ -87,7 +87,7 @@ export class ApexVisitor implements ApexParserVisitor { return { nature: 'Class', - name: ctx.id().Identifier()?.toString() ?? '', + name: ident.text.toLowerCase(), children: ctx.children?.length ? this.visitChildren(ctx).children : [], line: start.line, idCharacter: ident.start.charPositionInLine ?? 0, @@ -100,7 +100,7 @@ export class ApexVisitor implements ApexParserVisitor { return { nature: 'Method', - name: ctx.id().Identifier()?.toString() ?? '', + name: ident.text.toLowerCase(), children: ctx.children?.length ? this.visitChildren(ctx).children : [], params: this.getParameters(ctx.formalParameters()), line: start.line, @@ -118,7 +118,11 @@ 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.replaceAll(' ', '').toLowerCase()) + .join(',') ?? '' + ); } private forNode(node: ApexNode, anonHandler: (n: ApexNode) => void) { From fd1b478c4ef6b6c82eb4f56158939a07fac1a853 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:58:36 +0000 Subject: [PATCH 5/5] feat: Enhance symbol resolution by adding constructor support and improving test coverage --- lana/src/display/OpenFileInPackage.ts | 1 + .../ApexParser/ApexSymbolLocator.ts | 133 +++--- lana/src/salesforce/ApexParser/ApexVisitor.ts | 54 ++- .../__tests__/ApexSymbolLocator.test.ts | 394 ++++++++++++++++-- .../salesforce/__tests__/ApexVisitor.test.ts | 94 ++++- .../src/salesforce/codesymbol/SymbolFinder.ts | 2 + 6 files changed, 567 insertions(+), 111 deletions(-) diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index 41ab2c58..a66ac1f6 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -24,6 +24,7 @@ export class OpenFileInPackage { const parts = symbolName.slice(0, symbolName.indexOf('(')); + // 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; diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index ad95f29f..9b6c1aec 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -8,7 +8,12 @@ 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; @@ -27,7 +32,7 @@ export function parseApex(apexCode: string): ApexNode { } export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): SymbolLocation { - const result: SymbolLocation = { character: 0, line: 1, isExactMatch: true }; + const result: SymbolLocation = { character: 0, line: 1, isExactMatch: false }; if (fullyQualifiedSymbol.indexOf('(') === -1) { return result; @@ -41,24 +46,23 @@ export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): // 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 = outerClassNode?.name ?? ''; - const endNsIndex = fullyQualifiedSymbol.indexOf(outerClassName); - const namespace = endNsIndex > 0 ? fullyQualifiedSymbol.slice(0, endNsIndex - 1) : ''; + 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. - fullyQualifiedSymbol = fullyQualifiedSymbol.replace(namespace + '.', ''); + symbolToFind = symbolToFind.replace(namespace + '.', ''); } - // strip all whitespace to make comparisons easier - fullyQualifiedSymbol = fullyQualifiedSymbol.replaceAll(' ', '').toLowerCase(); - // This is the index of the first '(' which indicates method args or constructor args. - const methodArgsIndex = fullyQualifiedSymbol.indexOf('('); + 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 = fullyQualifiedSymbol.slice(0, methodArgsIndex); + 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. @@ -76,42 +80,44 @@ export function getMethodLine(rootNode: ApexNode, fullyQualifiedSymbol: string): result.character = currentRoot.idCharacter ?? 0; } - // TODO: enchance to find constructors as well as methods - // 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 = fullyQualifiedSymbol.slice(className.lastIndexOf('.') + 1); + const qualifiedMethodName = symbolToFind.slice(className.lastIndexOf('.') + 1); if (qualifiedMethodName && currentRoot) { - const methodNode = findMethodNode(currentRoot, qualifiedMethodName, outerClassName); - + let methodNode: ApexMethodNode | ApexConstructorNode | undefined = findMethodNode( + currentRoot, + qualifiedMethodName, + outerClassName, + ); if (!methodNode) { - result.line = currentRoot.line ?? 1; - result.isExactMatch = false; - result.missingSymbol = qualifiedMethodName; - } else { + 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 findClassNode(root: ApexNode, symbol: string, namespace: string): ApexNode | undefined { - const classNode = root.children?.find( - (child) => child.name === symbol && child.nature === 'Class', - ); - if (classNode) { - return classNode; - } - - if (namespace) { - return root.children?.find( - (child) => child.name === symbol.replaceAll(namespace + '.', '') && child.nature === 'Class', - ); - } + 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 undefined; + return false; + }); } function findMethodNode( @@ -119,28 +125,49 @@ function findMethodNode( symbol: string, outerClassName: string, ): ApexMethodNode | undefined { - const [methodName, args = ''] = symbol.slice(0, -1).split('('); - let params = args; - - const methodNode = root.children?.find( - (child) => - child.name === methodName && - child.nature === 'Method' && - (params === undefined || (child as ApexMethodNode).params.toLowerCase() === params), - ) as ApexMethodNode; - - if (methodNode) { - return methodNode; - } + 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 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. - params = params.replaceAll(outerClassName + '.', ''); - return root.children?.find( - (child) => - child.name === methodName && - child.nature === 'Method' && - (params === undefined || (child as ApexMethodNode).params.toLowerCase() === params), - ) as ApexMethodNode; + 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; +} + +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 f5fa4071..0146fc5e 100644 --- a/lana/src/salesforce/ApexParser/ApexVisitor.ts +++ b/lana/src/salesforce/ApexParser/ApexVisitor.ts @@ -4,12 +4,13 @@ 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. @@ -18,7 +19,7 @@ type ApexNature = 'Class' | 'Method'; export interface ApexNode { /** The type of Apex construct (Class or Method) */ nature?: ApexNature; - /** The name of the class or method, in lower case */ + /** The name of the class or method */ name?: string; /** Child nodes (nested classes or methods) */ children?: ApexNode[]; @@ -41,13 +42,9 @@ export interface ApexClassNode extends ApexNode { 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 ApexNode { +export interface ApexParamNode extends ApexNode { /** Indicates this node represents a method declaration */ - nature: 'Method'; + nature: 'Method' | 'Constructor'; /** Comma-separated list of parameter types for the method */ params: string; /** Line number where the method is declared */ @@ -56,6 +53,20 @@ export interface ApexMethodNode extends ApexNode { 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; }; @@ -87,24 +98,39 @@ export class ApexVisitor implements ApexParserVisitor { return { nature: 'Class', - name: ident.text.toLowerCase(), + name: ident.text ?? '', children: ctx.children?.length ? this.visitChildren(ctx).children : [], 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: ident.text.toLowerCase(), + name: ident.text ?? '', children: ctx.children?.length ? this.visitChildren(ctx).children : [], params: this.getParameters(ctx.formalParameters()), line: start.line, - idCharacter: ident.start.charPositionInLine, + idCharacter: ident.start.charPositionInLine ?? 0, }; } @@ -118,11 +144,7 @@ export class ApexVisitor implements ApexParserVisitor { private getParameters(ctx: FormalParametersContext): string { const paramsList = ctx.formalParameterList()?.formalParameter(); - return ( - paramsList - ?.map((param) => param.typeRef().text.replaceAll(' ', '').toLowerCase()) - .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 70d7855b..85b81802 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -41,6 +41,7 @@ export class SymbolFinder { */ 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) { @@ -49,6 +50,7 @@ export class SymbolFinder { } return null; } + return paths.find((path) => path.endsWith('.cls')) || null; } }