From 85d1a0d45d3bca10fedd990e9abe9292aeedea8a Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Wed, 17 Sep 2025 09:59:57 +0100 Subject: [PATCH 01/18] fix: account for inner class types in method params for go-to functionality --- lana/src/display/OpenFileInPackage.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index b37f01aa..baf133ee 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -22,7 +22,7 @@ export class OpenFileInPackage { return; } - const parts = symbolName.split('.'); + const parts = this.getSymbolParts(symbolName); const fileName = parts[0]?.trim(); const paths = await context.findSymbol(fileName as string); @@ -79,4 +79,20 @@ export class OpenFileInPackage { context.display.showFile(path, options); } + + private static getSymbolParts(symbol: string): string[] { + const openingParentheses = symbol.indexOf('('); + + if (openingParentheses === -1) { + return symbol.split('.'); + } + + const path = symbol.slice(0, openingParentheses); + const params = symbol.slice(openingParentheses); + + const parts = path.split('.'); + parts[parts.length - 1] += params; + + return parts; + } } From 64d2c1937a8b33f0d1c8e7e113ac3e14b3429e88 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Wed, 17 Sep 2025 15:26:06 +0100 Subject: [PATCH 02/18] fix: ensure methods can be matched using qualified or unqualified names --- lana/src/salesforce/ApexParser/ApexSymbolLocator.ts | 13 ++++++++++++- lana/src/salesforce/ApexParser/ApexVisitor.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index 6ce450b2..9ad8f653 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -76,10 +76,21 @@ function findMethodNode(root: ApexNode, symbol: string): ApexMethodNode | undefi const [methodName, params] = symbol.split('('); const paramStr = params?.replace(')', '').trim(); + const rootName = root.name!; + return root.children?.find( (child) => child.name === methodName && child.nature === 'Method' && - (paramStr === undefined || (child as ApexMethodNode).params === paramStr), + (paramStr === undefined || + matchesUnqualified(rootName, (child as ApexMethodNode).params, paramStr)), ) as ApexMethodNode; } + +function matchesUnqualified(qualifierString: string, str1: string, str2: string): boolean { + const regex = new RegExp(`\\b(?:${qualifierString}|System)\\.`, 'gi'); + const unqualifiedStr1 = str1.replace(regex, ''); + const unqualifiedStr2 = str2.replace(regex, ''); + + return unqualifiedStr1 === unqualifiedStr2; +} diff --git a/lana/src/salesforce/ApexParser/ApexVisitor.ts b/lana/src/salesforce/ApexParser/ApexVisitor.ts index 1ce73255..4fb009a7 100644 --- a/lana/src/salesforce/ApexParser/ApexVisitor.ts +++ b/lana/src/salesforce/ApexParser/ApexVisitor.ts @@ -78,7 +78,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) { From 7ae523d626c635eaeb74c28bc35ed21e121e0612 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Wed, 17 Sep 2025 15:26:59 +0100 Subject: [PATCH 03/18] test: add tests for fuzzy parameter matching --- .../__tests__/ApexSymbolLocator.test.ts | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts index d530e262..385c980b 100644 --- a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts +++ b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts @@ -31,19 +31,30 @@ describe('ApexSymbolLocator', () => { params: 'Integer, Integer', line: 4, }, + { + nature: 'Method', + name: 'baz', + params: 'MyClass.Inner, MyClass.InnerTwo', + line: 5, + }, { nature: 'Class', name: 'Inner', - line: 5, + line: 6, children: [ { nature: 'Method', name: 'bar', params: 'Integer', - line: 6, + line: 7, }, ], }, + { + nature: 'Class', + name: 'InnerTwo', + line: 8, + }, ], }; @@ -89,7 +100,7 @@ describe('ApexSymbolLocator', () => { it('should find method line for inner class method', () => { const result = getMethodLine(root, ['MyClass', 'Inner', 'bar(Integer)']); - expect(result.line).toBe(6); + expect(result.line).toBe(7); expect(result.isExactMatch).toBe(true); }); @@ -102,7 +113,7 @@ describe('ApexSymbolLocator', () => { it('should handle symbol not found on inner class', () => { const result = getMethodLine(root, ['MyClass', 'Inner', 'notFound()']); - expect(result.line).toBe(5); + expect(result.line).toBe(6); expect(result.isExactMatch).toBe(false); expect(result.missingSymbol).toBe('notFound()'); }); @@ -114,4 +125,30 @@ describe('ApexSymbolLocator', () => { expect(result.missingSymbol).toBe('NotAClass'); }); }); + + describe('fuzzy parameter matching', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should find method when fully qualified inner class passed', () => { + const result = getMethodLine(root, ['MyClass', 'baz(MyClass.Inner, MyClass.InnerTwo)']); + expect(result.line).toBe(5); + expect(result.isExactMatch).toBe(true); + }); + + it('should find method when short form passed', () => { + const result = getMethodLine(root, ['MyClass', 'baz(Inner, InnerTwo)']); + expect(result.line).toBe(5); + expect(result.isExactMatch).toBe(true); + }); + + it('should find method when mixed fully qualified and short form passed', () => { + const result = getMethodLine(root, ['MyClass', 'baz(MyClass.Inner, InnerTwo)']); + expect(result.line).toBe(5); + expect(result.isExactMatch).toBe(true); + }); + }); }); From 40fcc7f5b223ac9ac8d9be5f1240ebd6a2389ea2 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Wed, 17 Sep 2025 15:28:39 +0100 Subject: [PATCH 04/18] test: fix apex visitor test --- lana/src/salesforce/__tests__/ApexVisitor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lana/src/salesforce/__tests__/ApexVisitor.test.ts b/lana/src/salesforce/__tests__/ApexVisitor.test.ts index a1b94b88..554661c5 100644 --- a/lana/src/salesforce/__tests__/ApexVisitor.test.ts +++ b/lana/src/salesforce/__tests__/ApexVisitor.test.ts @@ -84,8 +84,8 @@ describe('ApexVisitor', () => { formalParameters: () => ({ formalParameterList: () => ({ formalParameter: () => [ - { typeRef: () => ({ typeName: () => ({ text: 'Integer' }) }) }, - { typeRef: () => ({ typeName: () => ({ text: 'String' }) }) }, + { typeRef: () => ({ text: 'Integer' }) }, + { typeRef: () => ({ text: 'String' }) }, ], }), }), From 4129b81930912caa9ed53fbf9ae2538ace833564 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Fri, 10 Oct 2025 15:47:07 +0100 Subject: [PATCH 05/18] fix: stop apex-ls throwing errors from sfdx project issues --- lana/src/salesforce/codesymbol/SymbolFinder.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index e1c3b3fb..6b95493c 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -5,6 +5,8 @@ import { type Workspace } from '@apexdevtools/apex-ls'; import { VSWorkspace } from '../../workspace/VSWorkspace.js'; +type GetMethod = (wsPath: string, ignoreIssues: boolean) => Workspace; // This is typed in apex-ls to only have 1 parameter + export class SymbolFinder { 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. @@ -12,7 +14,17 @@ export class SymbolFinder { const { Workspaces } = await import('@apexdevtools/apex-ls'); const paths = []; for (const ws of workspaces) { - const apexWs = Workspaces.get(ws.path()); + /** + * By default, `get` throws on any issues in the workspace. This could be things like Apex classes missing meta files, duplicate classes, etc. + * We don't care about these issues so pass ignoreIssues parameter to ensure we always get a return. + */ + const ignoreIssues = true; + const apexWs = (Workspaces.get as GetMethod)(ws.path(), ignoreIssues); + + if (!apexWs) { + return []; + } + const filePath = this.findInWorkspace(apexWs, symbol); if (filePath) { paths.push(filePath); From fd9eedcbc6d81e0764048ba69472a40ba0808a00 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Thu, 20 Nov 2025 16:50:17 +0000 Subject: [PATCH 06/18] feat: implement workspace manager and class finder to replace apex-ls --- lana/package.json | 1 - lana/src/Context.ts | 24 +++--- lana/src/display/OpenFileInPackage.ts | 61 +++----------- lana/src/display/QuickPickWorkspace.ts | 10 ++- .../ApexParser/ApexSymbolLocator.ts | 83 ++++++++++--------- .../salesforce/codesymbol/ApexSymbolParser.ts | 61 ++++++++++++++ .../codesymbol/SfdxProjectReader.ts | 35 ++++++++ .../src/salesforce/codesymbol/SymbolFinder.ts | 49 +++-------- lana/src/workspace/VSWorkspace.ts | 51 +++++++++++- lana/src/workspace/VSWorkspaceManager.ts | 42 ++++++++++ pnpm-lock.yaml | 39 --------- 11 files changed, 270 insertions(+), 186 deletions(-) create mode 100644 lana/src/salesforce/codesymbol/ApexSymbolParser.ts create mode 100644 lana/src/salesforce/codesymbol/SfdxProjectReader.ts create mode 100644 lana/src/workspace/VSWorkspaceManager.ts diff --git a/lana/package.json b/lana/package.json index ba93099f..676fca1a 100644 --- a/lana/package.json +++ b/lana/package.json @@ -175,7 +175,6 @@ "vscode:prepublish": "rm -rf out && pnpm -w run build" }, "dependencies": { - "@apexdevtools/apex-ls": "^5.10.0", "@apexdevtools/apex-parser": "^4.4.0", "@apexdevtools/sfdx-auth-helper": "^2.1.0", "@salesforce/apex-node": "^1.6.2" diff --git a/lana/src/Context.ts b/lana/src/Context.ts index 55511467..ea992423 100644 --- a/lana/src/Context.ts +++ b/lana/src/Context.ts @@ -1,42 +1,38 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { workspace, type ExtensionContext } from 'vscode'; +import { Uri, type ExtensionContext } from 'vscode'; import { ShowAnalysisCodeLens } from './codelenses/ShowAnalysisCodeLens.js'; import { RetrieveLogFile } from './commands/RetrieveLogFile.js'; import { ShowLogAnalysis } from './commands/ShowLogAnalysis.js'; import { Display } from './display/Display.js'; import { WhatsNewNotification } from './display/WhatsNewNotification.js'; -import { SymbolFinder } from './salesforce/codesymbol/SymbolFinder.js'; -import { VSWorkspace } from './workspace/VSWorkspace.js'; +import type { ApexSymbol } from './salesforce/codesymbol/ApexSymbolParser.js'; +import { VSWorkspaceManager } from './workspace/VSWorkspaceManager.js'; export class Context { - symbolFinder = new SymbolFinder(); context: ExtensionContext; display: Display; - workspaces: VSWorkspace[] = []; + workspaceManager = new VSWorkspaceManager(); constructor(context: ExtensionContext, display: Display) { this.context = context; this.display = display; - if (workspace.workspaceFolders) { - this.workspaces = workspace.workspaceFolders.map((folder) => { - return new VSWorkspace(folder); - }); - } - RetrieveLogFile.apply(this); ShowLogAnalysis.apply(this); ShowAnalysisCodeLens.apply(this); WhatsNewNotification.apply(this); } - async findSymbol(symbol: string): Promise { - const path = await this.symbolFinder.findSymbol(this.workspaces, symbol); + async findSymbol(apexSymbol: ApexSymbol): Promise { + const path = await this.workspaceManager.findSymbol(apexSymbol); + if (!path.length) { - this.display.showErrorMessage(`Type '${symbol}' was not found in workspace`); + this.display.showErrorMessage( + `Type '${apexSymbol.namespace ? apexSymbol.namespace + '.' : ''}${apexSymbol.outerClass}' was not found in workspace`, + ); } return path; } diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index baf133ee..4da8e84b 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -1,20 +1,19 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { sep } from 'path'; import { + commands, Position, Selection, - Uri, ViewColumn, workspace, type TextDocumentShowOptions, } from 'vscode'; import { Context } from '../Context.js'; -import { Item, Options, QuickPick } from './QuickPick.js'; import { getMethodLine, parseApex } from '../salesforce/ApexParser/ApexSymbolLocator.js'; +import { parseSymbol } from '../salesforce/codesymbol/ApexSymbolParser.js'; export class OpenFileInPackage { static async openFileForSymbol(context: Context, symbolName: string): Promise { @@ -22,49 +21,27 @@ export class OpenFileInPackage { return; } - const parts = this.getSymbolParts(symbolName); - const fileName = parts[0]?.trim(); + const apexSymbol = parseSymbol(symbolName, context.workspaceManager.getAllProjects()); - const paths = await context.findSymbol(fileName as string); - if (!paths.length) { - return; - } - - const matchingWs = context.workspaces.filter((ws) => { - const found = paths.findIndex((p) => p.startsWith(ws.path())); - if (found > -1) { - return ws; - } - }); + const paths = await context.findSymbol(apexSymbol); - const [wsPath] = - matchingWs.length > 1 - ? await QuickPick.pick( - matchingWs.map((p) => new Item(p.name(), p.path(), '')), - new Options('Select a workspace:'), - ) - : [new Item(matchingWs[0]?.name() || '', matchingWs[0]?.path() || '', '')]; - if (!wsPath) { + if (!paths.length) { return; } - const wsPathTrimmed = wsPath.description.trim(); - const path = - paths.find((e) => { - return e.startsWith(wsPathTrimmed + sep); - }) || ''; - - const uri = Uri.file(path); + // TODO: implement quickpick + const uri = paths[0]!; const document = await workspace.openTextDocument(uri); const parsedRoot = parseApex(document.getText()); - const symbolLocation = getMethodLine(parsedRoot, parts); + const symbolLocation = getMethodLine(parsedRoot, apexSymbol); 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 '${apexSymbol.outerClass}'`, ); + return; } const zeroIndexedLineNumber = symbolLocation.line - 1; @@ -77,22 +54,6 @@ export class OpenFileInPackage { selection: new Selection(pos, pos), }; - context.display.showFile(path, options); - } - - private static getSymbolParts(symbol: string): string[] { - const openingParentheses = symbol.indexOf('('); - - if (openingParentheses === -1) { - return symbol.split('.'); - } - - const path = symbol.slice(0, openingParentheses); - const params = symbol.slice(openingParentheses); - - const parts = path.split('.'); - parts[parts.length - 1] += params; - - return parts; + commands.executeCommand('vscode.open', paths[0], options); } } diff --git a/lana/src/display/QuickPickWorkspace.ts b/lana/src/display/QuickPickWorkspace.ts index 44fca9b7..598c0d7c 100644 --- a/lana/src/display/QuickPickWorkspace.ts +++ b/lana/src/display/QuickPickWorkspace.ts @@ -9,9 +9,11 @@ import { Item, Options, QuickPick } from './QuickPick.js'; export class QuickPickWorkspace { static async pickOrReturn(context: Context): Promise { - if (context.workspaces.length > 1) { + const workspaceFolders = context.workspaceManager.workspaceFolders; + + if (workspaceFolders.length > 1) { const [workspace] = await QuickPick.pick( - context.workspaces.map((ws) => new Item(ws.name(), ws.path(), '')), + workspaceFolders.map((ws) => new Item(ws.name(), ws.path(), '')), new Options('Select a workspace:'), ); @@ -20,8 +22,8 @@ export class QuickPickWorkspace { } else { throw new Error('No workspace selected'); } - } else if (context.workspaces.length === 1) { - return context.workspaces[0]?.path() || ''; + } else if (workspaceFolders.length === 1) { + return workspaceFolders[0]?.path() || ''; } else { if (window.activeTextEditor) { return parse(window.activeTextEditor.document.fileName).dir; diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index 9ad8f653..f6d3060a 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -8,6 +8,7 @@ import { CommonTokenStream, } from '@apexdevtools/apex-parser'; import { CharStreams } from 'antlr4ts'; +import type { ApexSymbol } from '../codesymbol/ApexSymbolParser'; import { ApexVisitor, type ApexMethodNode, type ApexNode } from './ApexVisitor'; export type SymbolLocation = { @@ -25,70 +26,74 @@ 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, apexSymbol: ApexSymbol): SymbolLocation { const result: SymbolLocation = { line: 1, isExactMatch: true }; - if (symbols[0] === rootNode.name) { - symbols = symbols.slice(1); - } + let currentRoot: ApexNode | undefined = rootNode; - if (!symbols.length) { + currentRoot = findClassNode(currentRoot, apexSymbol.outerClass); + + if (!currentRoot) { + result.isExactMatch = false; + result.missingSymbol = apexSymbol.outerClass; return result; } - let currentRoot: ApexNode | undefined = rootNode; + if (apexSymbol.innerClass) { + currentRoot = findClassNode(currentRoot, apexSymbol.innerClass); - 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; - } - - result.line = methodNode.line; + if (!currentRoot) { + result.isExactMatch = false; + result.missingSymbol = apexSymbol.innerClass; + return result; } } - return result; -} + const methodNode = findMethodNode(currentRoot, apexSymbol); -function isClassSymbol(symbol: string): boolean { - return !symbol.includes('('); + if (!methodNode) { + result.line = currentRoot.line ?? 1; + result.isExactMatch = false; + result.missingSymbol = apexSymbol.method + '(' + apexSymbol.parameters + ')'; + return result; + } + + result.line = methodNode.line; + + return result; } 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): ApexMethodNode | undefined { - const [methodName, params] = symbol.split('('); - const paramStr = params?.replace(')', '').trim(); - +function findMethodNode(root: ApexNode, apexSymbol: ApexSymbol): ApexMethodNode | undefined { const rootName = root.name!; return root.children?.find( (child) => - child.name === methodName && + child.name === apexSymbol.method && child.nature === 'Method' && - (paramStr === undefined || - matchesUnqualified(rootName, (child as ApexMethodNode).params, paramStr)), + (apexSymbol.parameters === '' || + matchesUnqualified( + rootName, + (child as ApexMethodNode).params, + apexSymbol.parameters, + apexSymbol.namespace, + )), ) as ApexMethodNode; } -function matchesUnqualified(qualifierString: string, str1: string, str2: string): boolean { - const regex = new RegExp(`\\b(?:${qualifierString}|System)\\.`, 'gi'); +function matchesUnqualified( + qualifierString: string, + str1: string, + str2: string, + namespace: string | null, +): boolean { + const regex = new RegExp( + `\\b(?:${qualifierString}${namespace ? '|' + namespace : ''}|System)\\.`, + 'gi', + ); const unqualifiedStr1 = str1.replace(regex, ''); const unqualifiedStr2 = str2.replace(regex, ''); diff --git a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts new file mode 100644 index 00000000..0aa18532 --- /dev/null +++ b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts @@ -0,0 +1,61 @@ +import type { SfdxProject } from './SfdxProjectReader'; + +export type ApexSymbol = { + namespace: string | null; + outerClass: string; + innerClass: string | null; + method: string; + parameters: string; +}; + +type ApexSymbolParts = [string, string, string?, string?]; + +export function parseSymbol(symbol: string, projects: SfdxProject[]): ApexSymbol { + const symbolParts = getSymbolParts(symbol); + + if (!symbolParts?.length) { + throw new Error(`Invalid symbol: ${symbol}`); + } + + const hasNamespace = symbolHasNamespace(projects, symbolParts); + + const [methodName, params] = symbolParts[symbolParts.length - 1]!.split('(') as [string, string]; + const paramStr = params?.replace(')', '').trim(); + + return { + namespace: hasNamespace ? symbolParts[0] : null, + outerClass: hasNamespace ? symbolParts[1] : symbolParts[0], + innerClass: + hasNamespace && symbolParts.length === 4 + ? symbolParts[2]! + : !hasNamespace && symbolParts.length === 3 + ? symbolParts[1] + : null, + method: methodName, + parameters: paramStr, + }; +} + +function getSymbolParts(symbol: string): ApexSymbolParts { + const openingParentheses = symbol.indexOf('('); + + if (openingParentheses === -1) { + return symbol.split('.') as ApexSymbolParts; + } + + const path = symbol.slice(0, openingParentheses); + const params = symbol.slice(openingParentheses); + + const parts = path.split('.'); + parts[parts.length - 1] += params; + + return parts as ApexSymbolParts; +} + +function symbolHasNamespace(projects: SfdxProject[], symbolParts: ApexSymbolParts) { + return symbolParts.length === 4 || !!findNamespacedProject(projects, symbolParts[0]!).length; +} + +function findNamespacedProject(projects: SfdxProject[], namespace: string) { + return projects.filter((project) => project.namespace === namespace); +} diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts new file mode 100644 index 00000000..e5738dd1 --- /dev/null +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { RelativePattern, workspace, type WorkspaceFolder } from 'vscode'; + +export interface SfdxProject { + readonly name: string | null; + readonly namespace: string; + readonly packageDirectories: readonly PackageDirectory[]; +} + +export interface PackageDirectory { + readonly path: string; + readonly default: boolean; +} + +export async function getProjects(workspaceFolder: WorkspaceFolder): Promise { + const projects: SfdxProject[] = []; + + const relativePattern = new RelativePattern(workspaceFolder, '**/sfdx-project.json'); + // TODO: Check if any node modules use sfdx-project.json files + const sfdxProjectUris = await workspace.findFiles(relativePattern, '**/node_modules/**'); + + for (const uri of sfdxProjectUris) { + try { + const document = await workspace.openTextDocument(uri); + const content = document.getText(); + projects.push(JSON.parse(content) as SfdxProject); + } catch { + // Skip invalid JSON files + } + } + + return projects; +} diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index 6b95493c..d96eaa76 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -1,49 +1,22 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { type Workspace } from '@apexdevtools/apex-ls'; -import { VSWorkspace } from '../../workspace/VSWorkspace.js'; - -type GetMethod = (wsPath: string, ignoreIssues: boolean) => Workspace; // This is typed in apex-ls to only have 1 parameter +import type { Uri } from 'vscode'; +import type { VSWorkspace } from '../../workspace/VSWorkspace.js'; +import type { VSWorkspaceManager } from '../../workspace/VSWorkspaceManager.js'; +import { type ApexSymbol } from './ApexSymbolParser.js'; export class SymbolFinder { - 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 - const { Workspaces } = await import('@apexdevtools/apex-ls'); - const paths = []; - for (const ws of workspaces) { - /** - * By default, `get` throws on any issues in the workspace. This could be things like Apex classes missing meta files, duplicate classes, etc. - * We don't care about these issues so pass ignoreIssues parameter to ensure we always get a return. - */ - const ignoreIssues = true; - const apexWs = (Workspaces.get as GetMethod)(ws.path(), ignoreIssues); - - if (!apexWs) { - return []; - } - - const filePath = this.findInWorkspace(apexWs, symbol); - if (filePath) { - paths.push(filePath); - } - } + async findSymbol(workspaceManager: VSWorkspaceManager, apexSymbol: ApexSymbol): Promise { + const matchingFolders = apexSymbol.namespace + ? workspaceManager.getWorkspaceForNamespacedProjects(apexSymbol.namespace) + : workspaceManager.workspaceFolders; - return paths; + return await this.getClassFilepaths(matchingFolders, apexSymbol); } - 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) { - parts.pop(); - return this.findInWorkspace(ws, parts.join('.')); - } - return null; - } - return paths.find((path) => path.endsWith('.cls')) || null; + async getClassFilepaths(folders: VSWorkspace[], apexSymbol: ApexSymbol): Promise { + return (await Promise.all(folders.map((folder) => folder.findClass(apexSymbol)))).flat(); } } diff --git a/lana/src/workspace/VSWorkspace.ts b/lana/src/workspace/VSWorkspace.ts index 38f79af7..76a38dd5 100644 --- a/lana/src/workspace/VSWorkspace.ts +++ b/lana/src/workspace/VSWorkspace.ts @@ -1,10 +1,13 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { type WorkspaceFolder } from 'vscode'; +import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; +import type { ApexSymbol } from '../salesforce/codesymbol/ApexSymbolParser'; +import { getProjects, type SfdxProject } from '../salesforce/codesymbol/SfdxProjectReader'; export class VSWorkspace { workspaceFolder: WorkspaceFolder; + sfdxProjectsByNamespace: Record = {}; constructor(workspaceFolder: WorkspaceFolder) { this.workspaceFolder = workspaceFolder; @@ -16,4 +19,50 @@ export class VSWorkspace { name(): string { return this.workspaceFolder.name; } + + async parseSfdxProjects() { + const sfdxProjects = await getProjects(this.workspaceFolder); + + this.sfdxProjectsByNamespace = sfdxProjects.reduce( + (projectsByNamespace, project) => { + const namespace = project.namespace ?? ''; + + if (!projectsByNamespace[namespace]) { + projectsByNamespace[namespace] = []; + } + + projectsByNamespace[namespace].push(project); + return projectsByNamespace; + }, + {} as Record, + ); + } + + getProjectsForNamespace(namespace: string): SfdxProject[] { + return this.sfdxProjectsByNamespace[namespace] ?? []; + } + + getAllProjects(): SfdxProject[] { + return Object.values(this.sfdxProjectsByNamespace).flat(); + } + + async findClass(apexSymbol: ApexSymbol): Promise { + const projects = apexSymbol.namespace + ? this.getProjectsForNamespace(apexSymbol.namespace) + : this.getAllProjects(); + + const classFileName = `${apexSymbol.outerClass}.cls`; + const uris: Uri[] = []; + + for (const project of projects) { + for (const packageDir of project.packageDirectories) { + const searchPath = Uri.joinPath(this.workspaceFolder.uri, packageDir.path); + const pattern = new RelativePattern(searchPath, `**/${classFileName}`); + const foundFiles = await workspace.findFiles(pattern); + uris.push(...foundFiles); + } + } + + return uris; + } } diff --git a/lana/src/workspace/VSWorkspaceManager.ts b/lana/src/workspace/VSWorkspaceManager.ts new file mode 100644 index 00000000..a0e83c53 --- /dev/null +++ b/lana/src/workspace/VSWorkspaceManager.ts @@ -0,0 +1,42 @@ +import { Uri, workspace } from 'vscode'; +import type { ApexSymbol } from '../salesforce/codesymbol/ApexSymbolParser'; +import type { SfdxProject } from '../salesforce/codesymbol/SfdxProjectReader'; +import { SymbolFinder } from '../salesforce/codesymbol/SymbolFinder'; +import { VSWorkspace } from './VSWorkspace'; + +export class VSWorkspaceManager { + symbolFinder = new SymbolFinder(); + workspaceFolders: VSWorkspace[] = []; + + constructor() { + if (workspace.workspaceFolders) { + this.workspaceFolders = workspace.workspaceFolders.map((folder) => { + return new VSWorkspace(folder); + }); + } + } + + async findSymbol(apexSymbol: ApexSymbol): Promise { + await this.refreshWorkspaceProjectInfo(); + + return await this.symbolFinder.findSymbol(this, apexSymbol); + } + + getAllProjects(): SfdxProject[] { + return this.workspaceFolders.flatMap((folder) => folder.getAllProjects()); + } + + getWorkspaceForNamespacedProjects(namespace: string): VSWorkspace[] { + return this.workspaceFolders.filter( + (folder) => folder.getProjectsForNamespace(namespace).length, + ); + } + + getProjectsForNamespace(namespace: string): SfdxProject[] { + return this.workspaceFolders.flatMap((folder) => folder.getProjectsForNamespace(namespace)); + } + + private async refreshWorkspaceProjectInfo() { + await Promise.all(this.workspaceFolders.map((folder) => folder.parseSfdxProjects())); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f1d219f..b0108ff1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,9 +112,6 @@ importers: lana: dependencies: - '@apexdevtools/apex-ls': - specifier: ^5.10.0 - version: 5.10.0 '@apexdevtools/apex-parser': specifier: ^4.4.0 version: 4.4.1 @@ -297,14 +294,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@apexdevtools/apex-ls@5.10.0': - resolution: {integrity: sha512-xc9yRYKBRKgTvG1xeINDDwi3iY9/f92BpCGKlpsQpS17CSh9ioAetkiryvzLHiOn3ru8YGvqdBD8FNAheC+Rdg==} - engines: {node: '>=14.0.0'} - - '@apexdevtools/apex-parser@4.3.1': - resolution: {integrity: sha512-RlaWpAFudE0GvUcim/xLBNw5ipNgT0Yd1eM7jeZJS0R9qn5DxeUGY3636zfgKgWkK04075y1fgNbPsIi/EjKiw==} - engines: {node: '>=8.0.0'} - '@apexdevtools/apex-parser@4.4.1': resolution: {integrity: sha512-tLHQ8DkI7/aoL9nOax+Xb3OEXk8IK1mTIpcCBaBJ3kk0Mhy4ik9jfQVAoSxjbWo8aLrjz2E4jnjmSU1iZlEt+Q==} engines: {node: '>=8.0.0'} @@ -312,10 +301,6 @@ packages: '@apexdevtools/sfdx-auth-helper@2.1.0': resolution: {integrity: sha512-D/oNZwxP4erngD007XgunMaVJdmUfptGhB02lboUvA8yIn2g1+CUPAeSaYhuZqD8reb3ezRdaSYuZo7AIOraGQ==} - '@apexdevtools/vf-parser@1.1.0': - resolution: {integrity: sha512-dP45Y3b4F0b8HosvGEMo6ugRx8yKhaz7h6eJh1cD/YvFxajk6x9Hfrtw63U2EKAstug6waBsT6QC7h+4USjBnA==} - engines: {node: '>=8.0.0'} - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -2854,11 +2839,6 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - '@xmldom/xmldom@0.7.9': - resolution: {integrity: sha512-yceMpm/xd4W2a85iqZyO09gTnHvXF6pyiWjD2jcOJs7hRoZtNNOO1eJlhHj1ixA+xip2hOyGn+LgcvLCMo5zXA==} - engines: {node: '>=10.0.0'} - deprecated: this version is no longer supported, please update to at least 0.8.* - '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -7848,18 +7828,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 - '@apexdevtools/apex-ls@5.10.0': - dependencies: - '@apexdevtools/apex-parser': 4.3.1 - '@apexdevtools/vf-parser': 1.1.0 - '@xmldom/xmldom': 0.7.9 - antlr4ts: 0.5.0-alpha.4 - - '@apexdevtools/apex-parser@4.3.1': - dependencies: - antlr4ts: 0.5.0-alpha.4 - node-dir: 0.1.17 - '@apexdevtools/apex-parser@4.4.1': dependencies: antlr4ts: 0.5.0-alpha.4 @@ -7873,11 +7841,6 @@ snapshots: - encoding - supports-color - '@apexdevtools/vf-parser@1.1.0': - dependencies: - antlr4ts: 0.5.0-alpha.4 - node-dir: 0.1.17 - '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -11375,8 +11338,6 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@xmldom/xmldom@0.7.9': {} - '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} From b547359181ea5c489d5ce4cfbc4fa40af86d46e6 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 11:18:29 +0000 Subject: [PATCH 07/18] fix: ensure projects are loaded before attempting to parse symbols --- lana/src/Context.ts | 4 +--- lana/src/display/OpenFileInPackage.ts | 4 ++-- .../ApexParser/ApexSymbolLocator.ts | 19 ++++++------------- .../salesforce/codesymbol/ApexSymbolParser.ts | 2 ++ .../codesymbol/SfdxProjectReader.ts | 1 - .../src/salesforce/codesymbol/SymbolFinder.ts | 2 ++ lana/src/workspace/VSWorkspaceManager.ts | 4 +--- 7 files changed, 14 insertions(+), 22 deletions(-) diff --git a/lana/src/Context.ts b/lana/src/Context.ts index ea992423..61c477b8 100644 --- a/lana/src/Context.ts +++ b/lana/src/Context.ts @@ -30,9 +30,7 @@ export class Context { const path = await this.workspaceManager.findSymbol(apexSymbol); if (!path.length) { - this.display.showErrorMessage( - `Type '${apexSymbol.namespace ? apexSymbol.namespace + '.' : ''}${apexSymbol.outerClass}' was not found in workspace`, - ); + this.display.showErrorMessage(`Type '${apexSymbol.fullSymbol}' was not found in workspace`); } return path; } diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index 4da8e84b..5353062e 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -21,6 +21,7 @@ export class OpenFileInPackage { return; } + await context.workspaceManager.refreshWorkspaceProjectInfo(); const apexSymbol = parseSymbol(symbolName, context.workspaceManager.getAllProjects()); const paths = await context.findSymbol(apexSymbol); @@ -39,9 +40,8 @@ export class OpenFileInPackage { if (!symbolLocation.isExactMatch) { context.display.showErrorMessage( - `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${apexSymbol.outerClass}'`, + `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${apexSymbol.fullSymbol}'`, ); - return; } const zeroIndexedLineNumber = symbolLocation.line - 1; diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index f6d3060a..a5762da9 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -68,7 +68,9 @@ function findClassNode(root: ApexNode, symbol: string): ApexNode | undefined { } function findMethodNode(root: ApexNode, apexSymbol: ApexSymbol): ApexMethodNode | undefined { - const rootName = root.name!; + const qualifierString = apexSymbol.namespace + ? `${apexSymbol.namespace}|${apexSymbol.outerClass}` + : apexSymbol.outerClass; return root.children?.find( (child) => @@ -76,24 +78,15 @@ function findMethodNode(root: ApexNode, apexSymbol: ApexSymbol): ApexMethodNode child.nature === 'Method' && (apexSymbol.parameters === '' || matchesUnqualified( - rootName, + qualifierString, (child as ApexMethodNode).params, apexSymbol.parameters, - apexSymbol.namespace, )), ) as ApexMethodNode; } -function matchesUnqualified( - qualifierString: string, - str1: string, - str2: string, - namespace: string | null, -): boolean { - const regex = new RegExp( - `\\b(?:${qualifierString}${namespace ? '|' + namespace : ''}|System)\\.`, - 'gi', - ); +function matchesUnqualified(qualifierString: string, str1: string, str2: string): boolean { + const regex = new RegExp(`\\b(?:${qualifierString}|System)\\.`, 'gi'); const unqualifiedStr1 = str1.replace(regex, ''); const unqualifiedStr2 = str2.replace(regex, ''); diff --git a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts index 0aa18532..7740313f 100644 --- a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts +++ b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts @@ -1,6 +1,7 @@ import type { SfdxProject } from './SfdxProjectReader'; export type ApexSymbol = { + fullSymbol: string; namespace: string | null; outerClass: string; innerClass: string | null; @@ -23,6 +24,7 @@ export function parseSymbol(symbol: string, projects: SfdxProject[]): ApexSymbol const paramStr = params?.replace(')', '').trim(); return { + fullSymbol: symbol, namespace: hasNamespace ? symbolParts[0] : null, outerClass: hasNamespace ? symbolParts[1] : symbolParts[0], innerClass: diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts index e5738dd1..25646cf8 100644 --- a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -18,7 +18,6 @@ export async function getProjects(workspaceFolder: WorkspaceFolder): Promise { - await this.refreshWorkspaceProjectInfo(); - return await this.symbolFinder.findSymbol(this, apexSymbol); } @@ -36,7 +34,7 @@ export class VSWorkspaceManager { return this.workspaceFolders.flatMap((folder) => folder.getProjectsForNamespace(namespace)); } - private async refreshWorkspaceProjectInfo() { + async refreshWorkspaceProjectInfo() { await Promise.all(this.workspaceFolders.map((folder) => folder.parseSfdxProjects())); } } From 66bdf175a223121efb78f4a983cf9e2fa01fa79c Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 11:33:29 +0000 Subject: [PATCH 08/18] test: update existing tests --- .../ApexParser/ApexSymbolLocator.ts | 4 +- .../__tests__/ApexSymbolLocator.test.ts | 75 ++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts index a5762da9..4beae908 100644 --- a/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts +++ b/lana/src/salesforce/ApexParser/ApexSymbolLocator.ts @@ -31,7 +31,9 @@ export function getMethodLine(rootNode: ApexNode, apexSymbol: ApexSymbol): Symbo let currentRoot: ApexNode | undefined = rootNode; - currentRoot = findClassNode(currentRoot, apexSymbol.outerClass); + if (currentRoot.name !== apexSymbol.outerClass) { + currentRoot = findClassNode(currentRoot, apexSymbol.outerClass); + } if (!currentRoot) { result.isExactMatch = false; diff --git a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts index 385c980b..b57fe8f3 100644 --- a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts +++ b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts @@ -3,6 +3,24 @@ */ import { getMethodLine, parseApex } from '../ApexParser/ApexSymbolLocator'; import { ApexVisitor, type ApexNode } from '../ApexParser/ApexVisitor'; +import type { ApexSymbol } from '../codesymbol/ApexSymbolParser'; + +function createSymbol(opts: { + namespace?: string | null; + outerClass: string; + innerClass?: string | null; + method: string; + parameters?: string; +}): ApexSymbol { + return { + fullSymbol: 'testSymbol', + namespace: opts.namespace ?? null, + outerClass: opts.outerClass, + innerClass: opts.innerClass ?? null, + method: opts.method, + parameters: opts.parameters ?? '', + }; +} jest.mock('../ApexParser/ApexVisitor'); jest.mock('@apexdevtools/apex-parser'); @@ -81,45 +99,65 @@ describe('ApexSymbolLocator', () => { }); it('should find method line for top-level method', () => { - const result = getMethodLine(root, ['MyClass', 'foo()']); + const result = getMethodLine(root, createSymbol({ outerClass: 'MyClass', method: '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, + createSymbol({ outerClass: 'MyClass', method: 'bar', parameters: '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, + createSymbol({ outerClass: 'MyClass', method: 'bar', parameters: '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)']); + const result = getMethodLine( + root, + createSymbol({ + outerClass: 'MyClass', + innerClass: 'Inner', + method: 'bar', + parameters: 'Integer', + }), + ); expect(result.line).toBe(7); expect(result.isExactMatch).toBe(true); }); it('should handle symbol not found', () => { - const result = getMethodLine(root, ['MyClass', 'notFound()']); + const result = getMethodLine( + root, + createSymbol({ outerClass: 'MyClass', method: '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()']); + const result = getMethodLine( + root, + createSymbol({ outerClass: 'MyClass', innerClass: 'Inner', method: '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, createSymbol({ outerClass: 'NotAClass', method: 'foo' })); expect(result.line).toBe(1); expect(result.isExactMatch).toBe(false); expect(result.missingSymbol).toBe('NotAClass'); @@ -134,19 +172,36 @@ describe('ApexSymbolLocator', () => { }); it('should find method when fully qualified inner class passed', () => { - const result = getMethodLine(root, ['MyClass', 'baz(MyClass.Inner, MyClass.InnerTwo)']); + const result = getMethodLine( + root, + createSymbol({ + outerClass: 'MyClass', + method: 'baz', + parameters: 'MyClass.Inner, MyClass.InnerTwo', + }), + ); expect(result.line).toBe(5); expect(result.isExactMatch).toBe(true); }); it('should find method when short form passed', () => { - const result = getMethodLine(root, ['MyClass', 'baz(Inner, InnerTwo)']); + const result = getMethodLine( + root, + createSymbol({ outerClass: 'MyClass', method: 'baz', parameters: 'Inner, InnerTwo' }), + ); expect(result.line).toBe(5); expect(result.isExactMatch).toBe(true); }); it('should find method when mixed fully qualified and short form passed', () => { - const result = getMethodLine(root, ['MyClass', 'baz(MyClass.Inner, InnerTwo)']); + const result = getMethodLine( + root, + createSymbol({ + outerClass: 'MyClass', + method: 'baz', + parameters: 'MyClass.Inner, InnerTwo', + }), + ); expect(result.line).toBe(5); expect(result.isExactMatch).toBe(true); }); From d79d0be6289f8e96b5aca38fd589b55e121edb79 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 12:14:39 +0000 Subject: [PATCH 09/18] test: add vscode api mocks --- lana/src/__mocks__/vscode.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 lana/src/__mocks__/vscode.ts diff --git a/lana/src/__mocks__/vscode.ts b/lana/src/__mocks__/vscode.ts new file mode 100644 index 00000000..ca682fb8 --- /dev/null +++ b/lana/src/__mocks__/vscode.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +/* eslint-disable @typescript-eslint/naming-convention */ + +export const RelativePattern = jest.fn(); + +export const Uri = { + file: jest.fn((path: string) => ({ fsPath: path })), + joinPath: jest.fn((base: { fsPath: string }, ...paths: string[]) => ({ + fsPath: [base.fsPath, ...paths].join('/'), + })), +}; + +export const workspace = { + findFiles: jest.fn(), + openTextDocument: jest.fn(), + workspaceFolders: [], +}; + +export const window = { + showInformationMessage: jest.fn(), + showErrorMessage: jest.fn(), + showWarningMessage: jest.fn(), + createOutputChannel: jest.fn(() => ({ + appendLine: jest.fn(), + show: jest.fn(), + })), +}; From a57cd3bc362dd64724ed103f9b32aba5145ea5d6 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 12:15:36 +0000 Subject: [PATCH 10/18] format: refactor type construction to be more readable --- .../salesforce/codesymbol/ApexSymbolParser.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts index 7740313f..c3bfc7f5 100644 --- a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts +++ b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts @@ -14,7 +14,7 @@ type ApexSymbolParts = [string, string, string?, string?]; export function parseSymbol(symbol: string, projects: SfdxProject[]): ApexSymbol { const symbolParts = getSymbolParts(symbol); - if (!symbolParts?.length) { + if (!symbolParts?.length || symbolParts.length < 2) { throw new Error(`Invalid symbol: ${symbol}`); } @@ -23,16 +23,15 @@ export function parseSymbol(symbol: string, projects: SfdxProject[]): ApexSymbol const [methodName, params] = symbolParts[symbolParts.length - 1]!.split('(') as [string, string]; const paramStr = params?.replace(')', '').trim(); + const namespace = hasNamespace ? symbolParts[0] : null; + const outerClass = hasNamespace ? symbolParts[1] : symbolParts[0]; + const innerClass = getInnerClass(symbolParts, hasNamespace); + return { fullSymbol: symbol, - namespace: hasNamespace ? symbolParts[0] : null, - outerClass: hasNamespace ? symbolParts[1] : symbolParts[0], - innerClass: - hasNamespace && symbolParts.length === 4 - ? symbolParts[2]! - : !hasNamespace && symbolParts.length === 3 - ? symbolParts[1] - : null, + namespace, + outerClass, + innerClass, method: methodName, parameters: paramStr, }; @@ -61,3 +60,15 @@ function symbolHasNamespace(projects: SfdxProject[], symbolParts: ApexSymbolPart function findNamespacedProject(projects: SfdxProject[], namespace: string) { return projects.filter((project) => project.namespace === namespace); } + +function getInnerClass(symbolParts: ApexSymbolParts, hasNamespace: boolean): string | null { + if (hasNamespace && symbolParts.length === 4) { + return symbolParts[2]!; + } + + if (!hasNamespace && symbolParts.length === 3) { + return symbolParts[1]!; + } + + return null; +} From 43afebcc1c8141994844e6b4b4160aec63948802 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 12:15:53 +0000 Subject: [PATCH 11/18] test: add tests for new functionality --- .../__tests__/ApexSymbolParser.test.ts | 161 ++++++++++++++++++ .../__tests__/SfdxProjectReader.test.ts | 107 ++++++++++++ .../codesymbol/SfdxProjectReader.ts | 5 +- .../workspace/__tests__/VSWorkspace.test.ts | 153 +++++++++++++++++ .../__tests__/VSWorkspaceManager.test.ts | 136 +++++++++++++++ 5 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 lana/src/salesforce/__tests__/ApexSymbolParser.test.ts create mode 100644 lana/src/salesforce/__tests__/SfdxProjectReader.test.ts create mode 100644 lana/src/workspace/__tests__/VSWorkspace.test.ts create mode 100644 lana/src/workspace/__tests__/VSWorkspaceManager.test.ts diff --git a/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts b/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts new file mode 100644 index 00000000..63c001ee --- /dev/null +++ b/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { parseSymbol, type ApexSymbol } from '../codesymbol/ApexSymbolParser'; +import type { SfdxProject } from '../codesymbol/SfdxProjectReader'; + +function createProject(namespace: string): SfdxProject { + return { + name: 'test-project', + namespace, + packageDirectories: [{ path: 'force-app', default: true }], + }; +} + +describe('parseSymbol', () => { + describe('without namespace', () => { + const projects: SfdxProject[] = []; + + it('should parse simple class and method', () => { + const result = parseSymbol('MyClass.myMethod()', projects); + + expect(result).toEqual({ + fullSymbol: 'MyClass.myMethod()', + namespace: null, + outerClass: 'MyClass', + innerClass: null, + method: 'myMethod', + parameters: '', + }); + }); + + it('should parse method with parameters', () => { + const result = parseSymbol('MyClass.myMethod(String, Integer)', projects); + + expect(result).toEqual({ + fullSymbol: 'MyClass.myMethod(String, Integer)', + namespace: null, + outerClass: 'MyClass', + innerClass: null, + method: 'myMethod', + parameters: 'String, Integer', + }); + }); + + it('should parse inner class method', () => { + const result = parseSymbol('MyClass.Inner.myMethod()', projects); + + expect(result).toEqual({ + fullSymbol: 'MyClass.Inner.myMethod()', + namespace: null, + outerClass: 'MyClass', + innerClass: 'Inner', + method: 'myMethod', + parameters: '', + }); + }); + + it('should parse inner class method with parameters', () => { + const result = parseSymbol('MyClass.Inner.myMethod(String)', projects); + + expect(result).toEqual({ + fullSymbol: 'MyClass.Inner.myMethod(String)', + namespace: null, + outerClass: 'MyClass', + innerClass: 'Inner', + method: 'myMethod', + parameters: 'String', + }); + }); + }); + + describe('with namespace', () => { + const projects = [createProject('ns')]; + + it('should parse namespaced class and method', () => { + const result = parseSymbol('ns.MyClass.myMethod()', projects); + + expect(result).toEqual({ + fullSymbol: 'ns.MyClass.myMethod()', + namespace: 'ns', + outerClass: 'MyClass', + innerClass: null, + method: 'myMethod', + parameters: '', + }); + }); + + it('should parse namespaced method with parameters', () => { + const result = parseSymbol('ns.MyClass.myMethod(String, Integer)', projects); + + expect(result).toEqual({ + fullSymbol: 'ns.MyClass.myMethod(String, Integer)', + namespace: 'ns', + outerClass: 'MyClass', + innerClass: null, + method: 'myMethod', + parameters: 'String, Integer', + }); + }); + + it('should parse namespaced inner class method', () => { + const result = parseSymbol('ns.MyClass.Inner.myMethod()', projects); + + expect(result).toEqual({ + fullSymbol: 'ns.MyClass.Inner.myMethod()', + namespace: 'ns', + outerClass: 'MyClass', + innerClass: 'Inner', + method: 'myMethod', + parameters: '', + }); + }); + + it('should parse namespaced inner class method with parameters', () => { + const result = parseSymbol('ns.MyClass.Inner.myMethod(String)', projects); + + expect(result).toEqual({ + fullSymbol: 'ns.MyClass.Inner.myMethod(String)', + namespace: 'ns', + outerClass: 'MyClass', + innerClass: 'Inner', + method: 'myMethod', + parameters: 'String', + }); + }); + }); + + describe('namespace detection', () => { + it('should detect namespace from projects when symbol has 3 parts', () => { + const projects = [createProject('myns')]; + const result = parseSymbol('myns.MyClass.myMethod()', projects); + + expect(result.namespace).toBe('myns'); + expect(result.outerClass).toBe('MyClass'); + }); + + it('should not detect namespace when first part does not match any project', () => { + const projects = [createProject('otherns')]; + const result = parseSymbol('MyClass.Inner.myMethod()', projects); + + expect(result.namespace).toBeNull(); + expect(result.outerClass).toBe('MyClass'); + expect(result.innerClass).toBe('Inner'); + }); + + it('should always detect namespace when symbol has 4 parts', () => { + const projects: SfdxProject[] = []; + const result = parseSymbol('ns.MyClass.Inner.myMethod()', projects); + + expect(result.namespace).toBe('ns'); + expect(result.outerClass).toBe('MyClass'); + expect(result.innerClass).toBe('Inner'); + }); + }); + + describe('error handling', () => { + it('should throw error for empty symbol', () => { + expect(() => parseSymbol('', [])).toThrow('Invalid symbol: '); + }); + }); +}); diff --git a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts new file mode 100644 index 00000000..b8d6f210 --- /dev/null +++ b/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { RelativePattern, workspace, type WorkspaceFolder } from 'vscode'; +import { getProjects } from '../codesymbol/SfdxProjectReader'; + +jest.mock('vscode'); + +describe('getProjects', () => { + const mockWorkspaceFolder = { + uri: { fsPath: '/workspace' }, + name: 'test-workspace', + index: 0, + } as WorkspaceFolder; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty array when no sfdx-project.json files found', async () => { + (workspace.findFiles as jest.Mock).mockResolvedValue([]); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toEqual([]); + expect(RelativePattern).toHaveBeenCalledWith(mockWorkspaceFolder, '**/sfdx-project.json'); + }); + + it('should parse valid sfdx-project.json files', async () => { + const mockUri = { fsPath: '/workspace/sfdx-project.json' }; + const mockProjectContent = { + name: 'my-project', + namespace: 'myns', + packageDirectories: [{ path: 'force-app', default: true }], + }; + + (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); + (workspace.openTextDocument as jest.Mock).mockResolvedValue({ + getText: () => JSON.stringify(mockProjectContent), + }); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toEqual([mockProjectContent]); + }); + + it('should parse multiple sfdx-project.json files', async () => { + const mockUris = [ + { fsPath: '/workspace/project1/sfdx-project.json' }, + { fsPath: '/workspace/project2/sfdx-project.json' }, + ]; + const mockProjects = [ + { name: 'project1', namespace: 'ns1', packageDirectories: [] }, + { name: 'project2', namespace: 'ns2', packageDirectories: [] }, + ]; + + (workspace.findFiles as jest.Mock).mockResolvedValue(mockUris); + (workspace.openTextDocument as jest.Mock) + .mockResolvedValueOnce({ getText: () => JSON.stringify(mockProjects[0]) }) + .mockResolvedValueOnce({ getText: () => JSON.stringify(mockProjects[1]) }); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toEqual(mockProjects); + }); + + it('should skip invalid JSON files and log warning', async () => { + const mockUri = { fsPath: '/workspace/invalid/sfdx-project.json' }; + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); + (workspace.openTextDocument as jest.Mock).mockResolvedValue({ + getText: () => 'invalid json', + }); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse sfdx-project.json'), + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should continue processing other files when one fails', async () => { + const mockUris = [ + { fsPath: '/workspace/invalid/sfdx-project.json' }, + { fsPath: '/workspace/valid/sfdx-project.json' }, + ]; + const validProject = { name: 'valid', namespace: '', packageDirectories: [] }; + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + (workspace.findFiles as jest.Mock).mockResolvedValue(mockUris); + (workspace.openTextDocument as jest.Mock) + .mockResolvedValueOnce({ getText: () => 'invalid json' }) + .mockResolvedValueOnce({ getText: () => JSON.stringify(validProject) }); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toEqual([validProject]); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts index 25646cf8..c3fbfcdb 100644 --- a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -25,8 +25,9 @@ export async function getProjects(workspaceFolder: WorkspaceFolder): Promise { + const mockWorkspaceFolder = { + uri: { fsPath: '/workspace' }, + name: 'test-workspace', + index: 0, + } as WorkspaceFolder; + + let vsWorkspace: VSWorkspace; + + beforeEach(() => { + jest.clearAllMocks(); + vsWorkspace = new VSWorkspace(mockWorkspaceFolder); + }); + + describe('path', () => { + it('should return workspace folder path', () => { + expect(vsWorkspace.path()).toBe('/workspace'); + }); + }); + + describe('name', () => { + it('should return workspace folder name', () => { + expect(vsWorkspace.name()).toBe('test-workspace'); + }); + }); + + describe('parseSfdxProjects', () => { + it('should group projects by namespace', async () => { + const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); + const mockProjects: SfdxProject[] = [ + { name: 'project1', namespace: 'ns1', packageDirectories: [] }, + { name: 'project2', namespace: 'ns1', packageDirectories: [] }, + { name: 'project3', namespace: 'ns2', packageDirectories: [] }, + { name: 'project4', namespace: '', packageDirectories: [] }, + ]; + + (getProjects as jest.Mock).mockResolvedValue(mockProjects); + + await vsWorkspace.parseSfdxProjects(); + + expect(vsWorkspace.getProjectsForNamespace('ns1')).toHaveLength(2); + expect(vsWorkspace.getProjectsForNamespace('ns2')).toHaveLength(1); + expect(vsWorkspace.getProjectsForNamespace('')).toHaveLength(1); + }); + }); + + describe('getProjectsForNamespace', () => { + it('should return empty array for unknown namespace', () => { + expect(vsWorkspace.getProjectsForNamespace('unknown')).toEqual([]); + }); + + it('should return projects matching the namespace', async () => { + const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); + const ns1Projects: SfdxProject[] = [ + { name: 'project1', namespace: 'ns1', packageDirectories: [] }, + { name: 'project2', namespace: 'ns1', packageDirectories: [] }, + ]; + const mockProjects: SfdxProject[] = [ + ...ns1Projects, + { name: 'project3', namespace: 'ns2', packageDirectories: [] }, + ]; + + (getProjects as jest.Mock).mockResolvedValue(mockProjects); + await vsWorkspace.parseSfdxProjects(); + + expect(vsWorkspace.getProjectsForNamespace('ns1')).toEqual(ns1Projects); + }); + }); + + describe('getAllProjects', () => { + it('should return all projects across namespaces', async () => { + const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); + const mockProjects: SfdxProject[] = [ + { name: 'project1', namespace: 'ns1', packageDirectories: [] }, + { name: 'project2', namespace: 'ns2', packageDirectories: [] }, + ]; + + (getProjects as jest.Mock).mockResolvedValue(mockProjects); + + await vsWorkspace.parseSfdxProjects(); + + expect(vsWorkspace.getAllProjects()).toEqual(mockProjects); + }); + }); + + describe('findClass', () => { + beforeEach(async () => { + const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); + const mockProjects: SfdxProject[] = [ + { + name: 'project1', + namespace: 'ns1', + packageDirectories: [{ path: 'force-app', default: true }], + }, + { + name: 'project2', + namespace: '', + packageDirectories: [{ path: 'src', default: true }], + }, + ]; + + (getProjects as jest.Mock).mockResolvedValue(mockProjects); + await vsWorkspace.parseSfdxProjects(); + }); + + it('should search in namespaced projects when namespace provided', async () => { + const mockUri = { fsPath: '/workspace/force-app/classes/MyClass.cls' }; + (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); + (Uri.joinPath as jest.Mock).mockReturnValue({ fsPath: '/workspace/force-app' }); + + const result = await vsWorkspace.findClass({ + fullSymbol: 'ns1.MyClass.method()', + namespace: 'ns1', + outerClass: 'MyClass', + innerClass: null, + method: 'method', + parameters: '', + }); + + expect(result).toEqual([mockUri]); + expect(RelativePattern).toHaveBeenCalledWith( + { fsPath: '/workspace/force-app' }, + '**/MyClass.cls', + ); + }); + + it('should search in all projects when no namespace provided', async () => { + (workspace.findFiles as jest.Mock).mockResolvedValue([]); + (Uri.joinPath as jest.Mock).mockReturnValue({ fsPath: '/workspace/src' }); + + await vsWorkspace.findClass({ + fullSymbol: 'MyClass.method()', + namespace: null, + outerClass: 'MyClass', + innerClass: null, + method: 'method', + parameters: '', + }); + + expect(workspace.findFiles).toHaveBeenCalled(); + }); + }); +}); diff --git a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts new file mode 100644 index 00000000..083dc47d --- /dev/null +++ b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { workspace } from 'vscode'; +import type { SfdxProject } from '../../salesforce/codesymbol/SfdxProjectReader'; +import { VSWorkspace } from '../VSWorkspace'; +import { VSWorkspaceManager } from '../VSWorkspaceManager'; + +jest.mock('vscode'); +jest.mock('../VSWorkspace'); + +describe('VSWorkspaceManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + (workspace as { workspaceFolders?: unknown[] }).workspaceFolders = undefined; + }); + + describe('constructor', () => { + it('should create VSWorkspace for each workspace folder', () => { + const mockFolders = [ + { uri: { fsPath: '/ws1' }, name: 'ws1', index: 0 }, + { uri: { fsPath: '/ws2' }, name: 'ws2', index: 1 }, + ]; + (workspace as { workspaceFolders?: unknown[] }).workspaceFolders = mockFolders; + + const manager = new VSWorkspaceManager(); + + expect(manager.workspaceFolders).toHaveLength(2); + expect(VSWorkspace).toHaveBeenCalledTimes(2); + }); + + it('should handle no workspace folders', () => { + const manager = new VSWorkspaceManager(); + + expect(manager.workspaceFolders).toHaveLength(0); + }); + }); + + describe('getAllProjects', () => { + it('should aggregate projects from all workspaces', () => { + const mockProjects1: SfdxProject[] = [ + { name: 'p1', namespace: 'ns1', packageDirectories: [] }, + ]; + const mockProjects2: SfdxProject[] = [ + { name: 'p2', namespace: 'ns2', packageDirectories: [] }, + ]; + + const mockWorkspace1 = { getAllProjects: jest.fn().mockReturnValue(mockProjects1) }; + const mockWorkspace2 = { getAllProjects: jest.fn().mockReturnValue(mockProjects2) }; + + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; + + const result = manager.getAllProjects(); + + expect(result).toEqual([...mockProjects1, ...mockProjects2]); + }); + }); + + describe('getWorkspaceForNamespacedProjects', () => { + it('should return workspaces that have projects with matching namespace', () => { + const mockWorkspace1 = { + getProjectsForNamespace: jest.fn().mockReturnValue([{ name: 'p1' }]), + }; + const mockWorkspace2 = { + getProjectsForNamespace: jest.fn().mockReturnValue([]), + }; + + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; + + const result = manager.getWorkspaceForNamespacedProjects('ns1'); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(mockWorkspace1); + }); + }); + + describe('getProjectsForNamespace', () => { + it('should aggregate namespaced projects from all workspaces', () => { + const mockProjects1 = [{ name: 'p1', namespace: 'ns1' }]; + const mockProjects2 = [{ name: 'p2', namespace: 'ns1' }]; + + const mockWorkspace1 = { + getProjectsForNamespace: jest.fn().mockReturnValue(mockProjects1), + }; + const mockWorkspace2 = { + getProjectsForNamespace: jest.fn().mockReturnValue(mockProjects2), + }; + + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; + + const result = manager.getProjectsForNamespace('ns1'); + + expect(result).toEqual([...mockProjects1, ...mockProjects2]); + }); + }); + + describe('refreshWorkspaceProjectInfo', () => { + it('should call parseSfdxProjects on all workspaces', async () => { + const mockWorkspace1 = { parseSfdxProjects: jest.fn().mockResolvedValue(undefined) }; + const mockWorkspace2 = { parseSfdxProjects: jest.fn().mockResolvedValue(undefined) }; + + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; + + await manager.refreshWorkspaceProjectInfo(); + + expect(mockWorkspace1.parseSfdxProjects).toHaveBeenCalled(); + expect(mockWorkspace2.parseSfdxProjects).toHaveBeenCalled(); + }); + }); + + describe('findSymbol', () => { + it('should delegate to symbolFinder', async () => { + const mockUri = { fsPath: '/test/MyClass.cls' }; + const mockSymbol = { + fullSymbol: 'MyClass.method()', + namespace: null, + outerClass: 'MyClass', + innerClass: null, + method: 'method', + parameters: '', + }; + + const manager = new VSWorkspaceManager(); + manager.symbolFinder.findSymbol = jest.fn().mockResolvedValue([mockUri]); + + const result = await manager.findSymbol(mockSymbol); + + expect(result).toEqual([mockUri]); + expect(manager.symbolFinder.findSymbol).toHaveBeenCalledWith(manager, mockSymbol); + }); + }); +}); From 43233cdff0ea251564d094c39fa2a2c3ca7b689d Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 15:18:36 +0000 Subject: [PATCH 12/18] feat: implement quick pick when multiple files matched for a symbol --- lana/src/Context.ts | 5 +-- lana/src/display/OpenFileInPackage.ts | 8 ++--- .../src/salesforce/codesymbol/SymbolFinder.ts | 35 ++++++++++++++++--- lana/src/workspace/VSWorkspaceManager.ts | 2 +- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/lana/src/Context.ts b/lana/src/Context.ts index 61c477b8..f7c03ded 100644 --- a/lana/src/Context.ts +++ b/lana/src/Context.ts @@ -26,12 +26,13 @@ export class Context { WhatsNewNotification.apply(this); } - async findSymbol(apexSymbol: ApexSymbol): Promise { + async findSymbol(apexSymbol: ApexSymbol): Promise { const path = await this.workspaceManager.findSymbol(apexSymbol); - if (!path.length) { + if (!path) { this.display.showErrorMessage(`Type '${apexSymbol.fullSymbol}' was not found in workspace`); } + return path; } } diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index 5353062e..1dd296b3 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -24,14 +24,12 @@ export class OpenFileInPackage { await context.workspaceManager.refreshWorkspaceProjectInfo(); const apexSymbol = parseSymbol(symbolName, context.workspaceManager.getAllProjects()); - const paths = await context.findSymbol(apexSymbol); + const uri = await context.findSymbol(apexSymbol); - if (!paths.length) { + if (!uri) { return; } - // TODO: implement quickpick - const uri = paths[0]!; const document = await workspace.openTextDocument(uri); const parsedRoot = parseApex(document.getText()); @@ -54,6 +52,6 @@ export class OpenFileInPackage { selection: new Selection(pos, pos), }; - commands.executeCommand('vscode.open', paths[0], options); + commands.executeCommand('vscode.open', uri, options); } } diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index 8c3b87d7..dc74143c 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -3,22 +3,49 @@ */ import type { Uri } from 'vscode'; +import { workspace } from 'vscode'; +import { Item, Options, QuickPick } from '../../display/QuickPick.js'; import type { VSWorkspace } from '../../workspace/VSWorkspace.js'; import type { VSWorkspaceManager } from '../../workspace/VSWorkspaceManager.js'; import { type ApexSymbol } from './ApexSymbolParser.js'; +class ClassItem extends Item { + uri: Uri; + + constructor(uri: Uri, className: string) { + super(className, workspace.asRelativePath(uri), ''); + this.uri = uri; + } +} + export class SymbolFinder { - async findSymbol(workspaceManager: VSWorkspaceManager, apexSymbol: ApexSymbol): Promise { + async findSymbol( + workspaceManager: VSWorkspaceManager, + apexSymbol: ApexSymbol, + ): Promise { const matchingFolders = apexSymbol.namespace ? workspaceManager.getWorkspaceForNamespacedProjects(apexSymbol.namespace) : workspaceManager.workspaceFolders; - // Quick-pick here to choose from valid projects??? + const paths = await this.getClassFilepaths(matchingFolders, apexSymbol); + + if (!paths.length) { + return null; + } + + if (paths.length === 1) { + return paths[0]!; + } + + const selected = await QuickPick.pick( + paths.map((uri) => new ClassItem(uri, apexSymbol.outerClass)), + new Options('Select a class:'), + ); - return await this.getClassFilepaths(matchingFolders, apexSymbol); + return selected.length ? selected[0]!.uri : null; } - async getClassFilepaths(folders: VSWorkspace[], apexSymbol: ApexSymbol): Promise { + private async getClassFilepaths(folders: VSWorkspace[], apexSymbol: ApexSymbol): Promise { return (await Promise.all(folders.map((folder) => folder.findClass(apexSymbol)))).flat(); } } diff --git a/lana/src/workspace/VSWorkspaceManager.ts b/lana/src/workspace/VSWorkspaceManager.ts index 7a109af4..4a136955 100644 --- a/lana/src/workspace/VSWorkspaceManager.ts +++ b/lana/src/workspace/VSWorkspaceManager.ts @@ -16,7 +16,7 @@ export class VSWorkspaceManager { } } - async findSymbol(apexSymbol: ApexSymbol): Promise { + async findSymbol(apexSymbol: ApexSymbol): Promise { return await this.symbolFinder.findSymbol(this, apexSymbol); } From b76e969e8a1889fead49ee209cd00695bd131c4e Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 15:18:47 +0000 Subject: [PATCH 13/18] test: add symbol finder tests --- lana/src/__mocks__/vscode.ts | 3 + .../salesforce/__tests__/SymbolFinder.test.ts | 141 ++++++++++++++++++ .../__tests__/VSWorkspaceManager.test.ts | 4 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 lana/src/salesforce/__tests__/SymbolFinder.test.ts diff --git a/lana/src/__mocks__/vscode.ts b/lana/src/__mocks__/vscode.ts index ca682fb8..552eff3a 100644 --- a/lana/src/__mocks__/vscode.ts +++ b/lana/src/__mocks__/vscode.ts @@ -16,6 +16,9 @@ export const workspace = { findFiles: jest.fn(), openTextDocument: jest.fn(), workspaceFolders: [], + asRelativePath: jest.fn((uri: { fsPath: string } | string) => + typeof uri === 'string' ? uri : uri.fsPath, + ), }; export const window = { diff --git a/lana/src/salesforce/__tests__/SymbolFinder.test.ts b/lana/src/salesforce/__tests__/SymbolFinder.test.ts new file mode 100644 index 00000000..32ae3e90 --- /dev/null +++ b/lana/src/salesforce/__tests__/SymbolFinder.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import type { Uri } from 'vscode'; +import { QuickPick } from '../../display/QuickPick'; +import type { VSWorkspace } from '../../workspace/VSWorkspace'; +import type { VSWorkspaceManager } from '../../workspace/VSWorkspaceManager'; +import type { ApexSymbol } from '../codesymbol/ApexSymbolParser'; +import { SymbolFinder } from '../codesymbol/SymbolFinder'; + +jest.mock('vscode'); +jest.mock('../../display/QuickPick'); + +function createSymbol(opts: { namespace?: string | null; outerClass: string }): ApexSymbol { + return { + fullSymbol: 'testSymbol', + namespace: opts.namespace ?? null, + outerClass: opts.outerClass, + innerClass: null, + method: 'method', + parameters: '', + }; +} + +function createMockUri(path: string): Uri { + return { fsPath: path } as Uri; +} + +function createMockWorkspace(findClassResult: Uri[]): VSWorkspace { + return { + findClass: jest.fn().mockResolvedValue(findClassResult), + } as unknown as VSWorkspace; +} + +function createMockManager( + workspaceFolders: VSWorkspace[], + namespacedWorkspaces: VSWorkspace[] = [], +): VSWorkspaceManager { + return { + workspaceFolders, + getWorkspaceForNamespacedProjects: jest.fn().mockReturnValue(namespacedWorkspaces), + } as unknown as VSWorkspaceManager; +} + +describe('SymbolFinder', () => { + let symbolFinder: SymbolFinder; + + beforeEach(() => { + jest.clearAllMocks(); + symbolFinder = new SymbolFinder(); + }); + + describe('findSymbol', () => { + it('should return null when no classes found', async () => { + const mockWorkspace = createMockWorkspace([]); + const manager = createMockManager([mockWorkspace]); + const symbol = createSymbol({ outerClass: 'MyClass' }); + + const result = await symbolFinder.findSymbol(manager, symbol); + + expect(result).toBeNull(); + }); + + it('should return single result without showing QuickPick', async () => { + const mockUri = createMockUri('/workspace/MyClass.cls'); + const mockWorkspace = createMockWorkspace([mockUri]); + const manager = createMockManager([mockWorkspace]); + const symbol = createSymbol({ outerClass: 'MyClass' }); + + const result = await symbolFinder.findSymbol(manager, symbol); + + expect(result).toBe(mockUri); + expect(QuickPick.pick).not.toHaveBeenCalled(); + }); + + it('should show QuickPick when multiple results found', async () => { + const mockUri1 = createMockUri('/workspace1/MyClass.cls'); + const mockUri2 = createMockUri('/workspace2/MyClass.cls'); + const mockWorkspace = createMockWorkspace([mockUri1, mockUri2]); + const manager = createMockManager([mockWorkspace]); + const symbol = createSymbol({ outerClass: 'MyClass' }); + + (QuickPick.pick as jest.Mock).mockResolvedValue([{ uri: mockUri1 }]); + + const result = await symbolFinder.findSymbol(manager, symbol); + + expect(result).toBe(mockUri1); + expect(QuickPick.pick).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ uri: mockUri1 }), + expect.objectContaining({ uri: mockUri2 }), + ]), + expect.any(Object), + ); + }); + + it('should return null when user cancels QuickPick', async () => { + const mockUri1 = createMockUri('/workspace1/MyClass.cls'); + const mockUri2 = createMockUri('/workspace2/MyClass.cls'); + const mockWorkspace = createMockWorkspace([mockUri1, mockUri2]); + const manager = createMockManager([mockWorkspace]); + const symbol = createSymbol({ outerClass: 'MyClass' }); + + (QuickPick.pick as jest.Mock).mockResolvedValue([]); + + const result = await symbolFinder.findSymbol(manager, symbol); + + expect(result).toBeNull(); + }); + + it('should use namespaced workspaces when symbol has namespace', async () => { + const mockUri = createMockUri('/namespaced/MyClass.cls'); + const regularWorkspace = createMockWorkspace([]); + const namespacedWorkspace = createMockWorkspace([mockUri]); + const manager = createMockManager([regularWorkspace], [namespacedWorkspace]); + const symbol = createSymbol({ namespace: 'ns', outerClass: 'MyClass' }); + + const result = await symbolFinder.findSymbol(manager, symbol); + + expect(result).toBe(mockUri); + expect(manager.getWorkspaceForNamespacedProjects).toHaveBeenCalledWith('ns'); + expect(namespacedWorkspace.findClass).toHaveBeenCalledWith(symbol); + expect(regularWorkspace.findClass).not.toHaveBeenCalled(); + }); + + it('should use all workspaces when symbol has no namespace', async () => { + const mockUri = createMockUri('/workspace1/MyClass.cls'); + const mockWorkspace1 = createMockWorkspace([mockUri]); + const mockWorkspace2 = createMockWorkspace([]); + const manager = createMockManager([mockWorkspace1, mockWorkspace2]); + const symbol = createSymbol({ outerClass: 'MyClass' }); + + const result = await symbolFinder.findSymbol(manager, symbol); + + expect(result).toBe(mockUri); + expect(manager.getWorkspaceForNamespacedProjects).not.toHaveBeenCalled(); + expect(mockWorkspace1.findClass).toHaveBeenCalledWith(symbol); + expect(mockWorkspace2.findClass).toHaveBeenCalledWith(symbol); + }); + }); +}); diff --git a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts index 083dc47d..43c7a6b7 100644 --- a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts @@ -125,11 +125,11 @@ describe('VSWorkspaceManager', () => { }; const manager = new VSWorkspaceManager(); - manager.symbolFinder.findSymbol = jest.fn().mockResolvedValue([mockUri]); + manager.symbolFinder.findSymbol = jest.fn().mockResolvedValue(mockUri); const result = await manager.findSymbol(mockSymbol); - expect(result).toEqual([mockUri]); + expect(result).toEqual(mockUri); expect(manager.symbolFinder.findSymbol).toHaveBeenCalledWith(manager, mockSymbol); }); }); From 555d5cf7827955524f12ebe5dd7578f694308ea4 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Mon, 24 Nov 2025 16:35:31 +0000 Subject: [PATCH 14/18] fix: store full path to sfdx project instead of relative --- .../salesforce/__tests__/SfdxProjectReader.test.ts | 13 +++++++++++-- .../src/salesforce/codesymbol/SfdxProjectReader.ts | 14 ++++++++++++-- lana/src/workspace/VSWorkspace.ts | 4 ++-- lana/src/workspace/__tests__/VSWorkspace.test.ts | 12 ++++-------- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts index b8d6f210..bdd5110f 100644 --- a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts +++ b/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { RelativePattern, workspace, type WorkspaceFolder } from 'vscode'; +import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; import { getProjects } from '../codesymbol/SfdxProjectReader'; jest.mock('vscode'); @@ -38,10 +38,18 @@ describe('getProjects', () => { (workspace.openTextDocument as jest.Mock).mockResolvedValue({ getText: () => JSON.stringify(mockProjectContent), }); + (Uri.joinPath as jest.Mock).mockReturnValue({ + path: '/workspace/force-app/sfdx-project.json', + }); const result = await getProjects(mockWorkspaceFolder); - expect(result).toEqual([mockProjectContent]); + const expectedProjectContent = { + name: 'my-project', + namespace: 'myns', + packageDirectories: [{ path: '/workspace/force-app', default: true }], + }; + expect(result).toEqual([expectedProjectContent]); }); it('should parse multiple sfdx-project.json files', async () => { @@ -58,6 +66,7 @@ describe('getProjects', () => { (workspace.openTextDocument as jest.Mock) .mockResolvedValueOnce({ getText: () => JSON.stringify(mockProjects[0]) }) .mockResolvedValueOnce({ getText: () => JSON.stringify(mockProjects[1]) }); + (Uri.joinPath as jest.Mock).mockReturnValue({ path: '/workspace/sfdx-project.json' }); const result = await getProjects(mockWorkspaceFolder); diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts index c3fbfcdb..5229809f 100644 --- a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { RelativePattern, workspace, type WorkspaceFolder } from 'vscode'; +import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; export interface SfdxProject { readonly name: string | null; @@ -24,7 +24,17 @@ export async function getProjects(workspaceFolder: WorkspaceFolder): Promise ({ + ...pkg, + path: Uri.joinPath(uri, pkg.path).path.replace(/\/sfdx-project.json/i, ''), + })), + }; + + projects.push(project); } catch (error) { // eslint-disable-next-line no-console console.warn(`Failed to parse sfdx-project.json at ${uri.fsPath}:`, error); diff --git a/lana/src/workspace/VSWorkspace.ts b/lana/src/workspace/VSWorkspace.ts index 76a38dd5..85289f45 100644 --- a/lana/src/workspace/VSWorkspace.ts +++ b/lana/src/workspace/VSWorkspace.ts @@ -56,9 +56,9 @@ export class VSWorkspace { for (const project of projects) { for (const packageDir of project.packageDirectories) { - const searchPath = Uri.joinPath(this.workspaceFolder.uri, packageDir.path); - const pattern = new RelativePattern(searchPath, `**/${classFileName}`); + const pattern = new RelativePattern(packageDir.path, `**/${classFileName}`); const foundFiles = await workspace.findFiles(pattern); + uris.push(...foundFiles); } } diff --git a/lana/src/workspace/__tests__/VSWorkspace.test.ts b/lana/src/workspace/__tests__/VSWorkspace.test.ts index 8979de99..af1c80ea 100644 --- a/lana/src/workspace/__tests__/VSWorkspace.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspace.test.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; +import { Uri, workspace, type WorkspaceFolder } from 'vscode'; import type { SfdxProject } from '../../salesforce/codesymbol/SfdxProjectReader'; import { VSWorkspace } from '../VSWorkspace'; @@ -100,12 +100,12 @@ describe('VSWorkspace', () => { { name: 'project1', namespace: 'ns1', - packageDirectories: [{ path: 'force-app', default: true }], + packageDirectories: [{ path: '/workspace/force-app', default: true }], }, { name: 'project2', namespace: '', - packageDirectories: [{ path: 'src', default: true }], + packageDirectories: [{ path: '/workspace/src', default: true }], }, ]; @@ -116,7 +116,6 @@ describe('VSWorkspace', () => { it('should search in namespaced projects when namespace provided', async () => { const mockUri = { fsPath: '/workspace/force-app/classes/MyClass.cls' }; (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); - (Uri.joinPath as jest.Mock).mockReturnValue({ fsPath: '/workspace/force-app' }); const result = await vsWorkspace.findClass({ fullSymbol: 'ns1.MyClass.method()', @@ -128,10 +127,7 @@ describe('VSWorkspace', () => { }); expect(result).toEqual([mockUri]); - expect(RelativePattern).toHaveBeenCalledWith( - { fsPath: '/workspace/force-app' }, - '**/MyClass.cls', - ); + expect(workspace.findFiles).toHaveBeenCalled(); }); it('should search in all projects when no namespace provided', async () => { From 0cf95e64fb6b7fbd3543882a7127a82752932a95 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Tue, 25 Nov 2025 16:31:21 +0000 Subject: [PATCH 15/18] feat: cache sfdx projects and class paths --- lana/src/display/OpenFileInPackage.ts | 2 +- .../salesforce/codesymbol/ApexSymbolParser.ts | 2 +- lana/src/salesforce/codesymbol/SfdxProject.ts | 53 +++++++++++++++++++ .../codesymbol/SfdxProjectReader.ts | 19 +++---- .../src/salesforce/codesymbol/SymbolFinder.ts | 52 +++++++++--------- lana/src/workspace/VSWorkspace.ts | 23 +++----- lana/src/workspace/VSWorkspaceManager.ts | 19 ++++--- 7 files changed, 104 insertions(+), 66 deletions(-) create mode 100644 lana/src/salesforce/codesymbol/SfdxProject.ts diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index 1dd296b3..ab5eaa85 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -21,7 +21,7 @@ export class OpenFileInPackage { return; } - await context.workspaceManager.refreshWorkspaceProjectInfo(); + await context.workspaceManager.initialiseWorkspaceProjectInfo(); const apexSymbol = parseSymbol(symbolName, context.workspaceManager.getAllProjects()); const uri = await context.findSymbol(apexSymbol); diff --git a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts index c3bfc7f5..45b1a2f1 100644 --- a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts +++ b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts @@ -1,4 +1,4 @@ -import type { SfdxProject } from './SfdxProjectReader'; +import type { SfdxProject } from './SfdxProject'; export type ApexSymbol = { fullSymbol: string; diff --git a/lana/src/salesforce/codesymbol/SfdxProject.ts b/lana/src/salesforce/codesymbol/SfdxProject.ts new file mode 100644 index 00000000..63106a66 --- /dev/null +++ b/lana/src/salesforce/codesymbol/SfdxProject.ts @@ -0,0 +1,53 @@ +import path from 'path'; +import { RelativePattern, Uri, workspace } from 'vscode'; + +export interface PackageDirectory { + readonly path: string; + readonly default: boolean; +} + +export class SfdxProject { + readonly name: string | null; + readonly namespace: string; + readonly packageDirectories: readonly PackageDirectory[]; + + private classCache?: Map; + + constructor( + name: string | null, + namespace: string, + packageDirectories: readonly PackageDirectory[], + ) { + this.name = name; + this.namespace = namespace; + this.packageDirectories = packageDirectories; + } + + findClass(className: string): Uri[] { + const paths = this.classCache?.get(className) ?? []; + return paths.map((p) => Uri.file(p)); + } + + async buildClassIndex(): Promise { + this.classCache = new Map(); + + const allUris = ( + await Promise.all( + this.packageDirectories.map((packageDir) => this.findClassesInProject(packageDir.path)), + ) + ).flat(); + + for (const uri of allUris) { + const className = path.basename(uri.fsPath, '.cls'); + if (!this.classCache.has(className)) { + this.classCache.set(className, []); + } + this.classCache.get(className)!.push(uri.fsPath); + } + } + + private async findClassesInProject(basePath: string): Promise { + const pattern = new RelativePattern(basePath, '**/*.cls'); + return await workspace.findFiles(pattern); + } +} diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts index 5229809f..0e318550 100644 --- a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -2,18 +2,14 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; +import { type PackageDirectory, SfdxProject } from './SfdxProject'; -export interface SfdxProject { +export interface RawSfdxProject { readonly name: string | null; readonly namespace: string; readonly packageDirectories: readonly PackageDirectory[]; } -export interface PackageDirectory { - readonly path: string; - readonly default: boolean; -} - export async function getProjects(workspaceFolder: WorkspaceFolder): Promise { const projects: SfdxProject[] = []; @@ -24,15 +20,16 @@ export async function getProjects(workspaceFolder: WorkspaceFolder): Promise ({ + const project: SfdxProject = new SfdxProject( + rawProject.name, + rawProject.namespace, + rawProject.packageDirectories.map((pkg) => ({ ...pkg, path: Uri.joinPath(uri, pkg.path).path.replace(/\/sfdx-project.json/i, ''), })), - }; + ); projects.push(project); } catch (error) { diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index dc74143c..f1a0571a 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -18,34 +18,32 @@ class ClassItem extends Item { } } -export class SymbolFinder { - async findSymbol( - workspaceManager: VSWorkspaceManager, - apexSymbol: ApexSymbol, - ): Promise { - const matchingFolders = apexSymbol.namespace - ? workspaceManager.getWorkspaceForNamespacedProjects(apexSymbol.namespace) - : workspaceManager.workspaceFolders; - - const paths = await this.getClassFilepaths(matchingFolders, apexSymbol); - - if (!paths.length) { - return null; - } - - if (paths.length === 1) { - return paths[0]!; - } - - const selected = await QuickPick.pick( - paths.map((uri) => new ClassItem(uri, apexSymbol.outerClass)), - new Options('Select a class:'), - ); - - return selected.length ? selected[0]!.uri : null; +export async function findSymbol( + workspaceManager: VSWorkspaceManager, + apexSymbol: ApexSymbol, +): Promise { + const matchingFolders = apexSymbol.namespace + ? workspaceManager.getWorkspaceForNamespacedProjects(apexSymbol.namespace) + : workspaceManager.workspaceFolders; + + const paths = getClassFilepaths(matchingFolders, apexSymbol); + + if (!paths.length) { + return null; } - private async getClassFilepaths(folders: VSWorkspace[], apexSymbol: ApexSymbol): Promise { - return (await Promise.all(folders.map((folder) => folder.findClass(apexSymbol)))).flat(); + if (paths.length === 1) { + return paths[0]!; } + + const selected = await QuickPick.pick( + paths.map((uri) => new ClassItem(uri, apexSymbol.outerClass)), + new Options('Select a class:'), + ); + + return selected.length ? selected[0]!.uri : null; +} + +function getClassFilepaths(folders: VSWorkspace[], apexSymbol: ApexSymbol): Uri[] { + return folders.map((folder) => folder.findClass(apexSymbol)).flat(); } diff --git a/lana/src/workspace/VSWorkspace.ts b/lana/src/workspace/VSWorkspace.ts index 85289f45..e634acc1 100644 --- a/lana/src/workspace/VSWorkspace.ts +++ b/lana/src/workspace/VSWorkspace.ts @@ -1,9 +1,10 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; +import { Uri, type WorkspaceFolder } from 'vscode'; import type { ApexSymbol } from '../salesforce/codesymbol/ApexSymbolParser'; -import { getProjects, type SfdxProject } from '../salesforce/codesymbol/SfdxProjectReader'; +import type { SfdxProject } from '../salesforce/codesymbol/SfdxProject'; +import { getProjects } from '../salesforce/codesymbol/SfdxProjectReader'; export class VSWorkspace { workspaceFolder: WorkspaceFolder; @@ -23,6 +24,8 @@ export class VSWorkspace { async parseSfdxProjects() { const sfdxProjects = await getProjects(this.workspaceFolder); + await Promise.all(sfdxProjects.map((sfdxProject) => sfdxProject.buildClassIndex())); + this.sfdxProjectsByNamespace = sfdxProjects.reduce( (projectsByNamespace, project) => { const namespace = project.namespace ?? ''; @@ -46,23 +49,11 @@ export class VSWorkspace { return Object.values(this.sfdxProjectsByNamespace).flat(); } - async findClass(apexSymbol: ApexSymbol): Promise { + findClass(apexSymbol: ApexSymbol): Uri[] { const projects = apexSymbol.namespace ? this.getProjectsForNamespace(apexSymbol.namespace) : this.getAllProjects(); - const classFileName = `${apexSymbol.outerClass}.cls`; - const uris: Uri[] = []; - - for (const project of projects) { - for (const packageDir of project.packageDirectories) { - const pattern = new RelativePattern(packageDir.path, `**/${classFileName}`); - const foundFiles = await workspace.findFiles(pattern); - - uris.push(...foundFiles); - } - } - - return uris; + return projects.flatMap((project) => project.findClass(apexSymbol.outerClass)); } } diff --git a/lana/src/workspace/VSWorkspaceManager.ts b/lana/src/workspace/VSWorkspaceManager.ts index 4a136955..969022ae 100644 --- a/lana/src/workspace/VSWorkspaceManager.ts +++ b/lana/src/workspace/VSWorkspaceManager.ts @@ -1,11 +1,10 @@ import { Uri, workspace } from 'vscode'; import type { ApexSymbol } from '../salesforce/codesymbol/ApexSymbolParser'; -import type { SfdxProject } from '../salesforce/codesymbol/SfdxProjectReader'; -import { SymbolFinder } from '../salesforce/codesymbol/SymbolFinder'; +import type { SfdxProject } from '../salesforce/codesymbol/SfdxProject'; +import { findSymbol } from '../salesforce/codesymbol/SymbolFinder'; import { VSWorkspace } from './VSWorkspace'; export class VSWorkspaceManager { - symbolFinder = new SymbolFinder(); workspaceFolders: VSWorkspace[] = []; constructor() { @@ -17,7 +16,7 @@ export class VSWorkspaceManager { } async findSymbol(apexSymbol: ApexSymbol): Promise { - return await this.symbolFinder.findSymbol(this, apexSymbol); + return await findSymbol(this, apexSymbol); } getAllProjects(): SfdxProject[] { @@ -30,11 +29,11 @@ export class VSWorkspaceManager { ); } - getProjectsForNamespace(namespace: string): SfdxProject[] { - return this.workspaceFolders.flatMap((folder) => folder.getProjectsForNamespace(namespace)); - } - - async refreshWorkspaceProjectInfo() { - await Promise.all(this.workspaceFolders.map((folder) => folder.parseSfdxProjects())); + async initialiseWorkspaceProjectInfo(forceRefresh = false) { + await Promise.all( + this.workspaceFolders + .filter((folder) => forceRefresh || !folder.getAllProjects().length) + .map((folder) => folder.parseSfdxProjects()), + ); } } From 34c9c014ae7ba84ad507d1db0f6c94284f1e0271 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Tue, 25 Nov 2025 16:31:41 +0000 Subject: [PATCH 16/18] test: add mocks for new classes --- .../codesymbol/__mocks__/SfdxProject.ts | 23 +++++++++++++++++++ lana/src/workspace/__mocks__/VSWorkspace.ts | 21 +++++++++++++++++ .../workspace/__mocks__/VSWorkspaceManager.ts | 13 +++++++++++ 3 files changed, 57 insertions(+) create mode 100644 lana/src/salesforce/codesymbol/__mocks__/SfdxProject.ts create mode 100644 lana/src/workspace/__mocks__/VSWorkspace.ts create mode 100644 lana/src/workspace/__mocks__/VSWorkspaceManager.ts diff --git a/lana/src/salesforce/codesymbol/__mocks__/SfdxProject.ts b/lana/src/salesforce/codesymbol/__mocks__/SfdxProject.ts new file mode 100644 index 00000000..40189b6a --- /dev/null +++ b/lana/src/salesforce/codesymbol/__mocks__/SfdxProject.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import type { PackageDirectory } from '../SfdxProject'; + +export class SfdxProject { + readonly name: string | null; + readonly namespace: string; + readonly packageDirectories: readonly PackageDirectory[]; + + constructor( + name: string | null, + namespace: string, + packageDirectories: readonly PackageDirectory[], + ) { + this.name = name; + this.namespace = namespace; + this.packageDirectories = packageDirectories; + } + + findClass = jest.fn(); + buildClassIndex = jest.fn(); +} diff --git a/lana/src/workspace/__mocks__/VSWorkspace.ts b/lana/src/workspace/__mocks__/VSWorkspace.ts new file mode 100644 index 00000000..386b50c6 --- /dev/null +++ b/lana/src/workspace/__mocks__/VSWorkspace.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import type { WorkspaceFolder } from 'vscode'; +import type { SfdxProject } from '../../salesforce/codesymbol/SfdxProject'; + +export class VSWorkspace { + workspaceFolder: WorkspaceFolder; + sfdxProjectsByNamespace: Record = {}; + + constructor(workspaceFolder: WorkspaceFolder) { + this.workspaceFolder = workspaceFolder; + } + + path = jest.fn(); + name = jest.fn(); + parseSfdxProjects = jest.fn(); + getProjectsForNamespace = jest.fn(); + getAllProjects = jest.fn(); + findClass = jest.fn(); +} diff --git a/lana/src/workspace/__mocks__/VSWorkspaceManager.ts b/lana/src/workspace/__mocks__/VSWorkspaceManager.ts new file mode 100644 index 00000000..0f97d012 --- /dev/null +++ b/lana/src/workspace/__mocks__/VSWorkspaceManager.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import type { VSWorkspace } from '../VSWorkspace'; + +export class VSWorkspaceManager { + workspaceFolders: VSWorkspace[] = []; + + findSymbol = jest.fn(); + getAllProjects = jest.fn(); + getWorkspaceForNamespacedProjects = jest.fn(); + initialiseWorkspaceProjectInfo = jest.fn(); +} From 9b3e0ae8718206ca0cfa41688812820eeac45d25 Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Tue, 25 Nov 2025 16:31:55 +0000 Subject: [PATCH 17/18] test: update tests --- .../__tests__/ApexSymbolParser.test.ts | 10 +-- .../__tests__/SfdxProjectReader.test.ts | 14 ++-- .../salesforce/__tests__/SymbolFinder.test.ts | 40 ++++----- .../workspace/__tests__/VSWorkspace.test.ts | 84 +++++++++---------- .../__tests__/VSWorkspaceManager.test.ts | 47 ++++------- 5 files changed, 88 insertions(+), 107 deletions(-) diff --git a/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts b/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts index 63c001ee..2738c157 100644 --- a/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts +++ b/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts @@ -2,14 +2,12 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { parseSymbol, type ApexSymbol } from '../codesymbol/ApexSymbolParser'; -import type { SfdxProject } from '../codesymbol/SfdxProjectReader'; +import { SfdxProject } from '../codesymbol/SfdxProject'; + +jest.mock('../codesymbol/SfdxProject'); function createProject(namespace: string): SfdxProject { - return { - name: 'test-project', - namespace, - packageDirectories: [{ path: 'force-app', default: true }], - }; + return new SfdxProject('test-project', namespace, [{ path: 'force-app', default: true }]); } describe('parseSymbol', () => { diff --git a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts index bdd5110f..3a896783 100644 --- a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts +++ b/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts @@ -44,12 +44,12 @@ describe('getProjects', () => { const result = await getProjects(mockWorkspaceFolder); - const expectedProjectContent = { + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ name: 'my-project', namespace: 'myns', packageDirectories: [{ path: '/workspace/force-app', default: true }], - }; - expect(result).toEqual([expectedProjectContent]); + }); }); it('should parse multiple sfdx-project.json files', async () => { @@ -70,7 +70,9 @@ describe('getProjects', () => { const result = await getProjects(mockWorkspaceFolder); - expect(result).toEqual(mockProjects); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject(mockProjects[0]!); + expect(result[1]).toMatchObject(mockProjects[1]!); }); it('should skip invalid JSON files and log warning', async () => { @@ -105,10 +107,12 @@ describe('getProjects', () => { (workspace.openTextDocument as jest.Mock) .mockResolvedValueOnce({ getText: () => 'invalid json' }) .mockResolvedValueOnce({ getText: () => JSON.stringify(validProject) }); + (Uri.joinPath as jest.Mock).mockReturnValue({ path: '/workspace/sfdx-project.json' }); const result = await getProjects(mockWorkspaceFolder); - expect(result).toEqual([validProject]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(validProject); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); diff --git a/lana/src/salesforce/__tests__/SymbolFinder.test.ts b/lana/src/salesforce/__tests__/SymbolFinder.test.ts index 32ae3e90..15a23072 100644 --- a/lana/src/salesforce/__tests__/SymbolFinder.test.ts +++ b/lana/src/salesforce/__tests__/SymbolFinder.test.ts @@ -1,15 +1,17 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import type { Uri } from 'vscode'; +import type { Uri, WorkspaceFolder } from 'vscode'; import { QuickPick } from '../../display/QuickPick'; -import type { VSWorkspace } from '../../workspace/VSWorkspace'; -import type { VSWorkspaceManager } from '../../workspace/VSWorkspaceManager'; +import { VSWorkspace } from '../../workspace/VSWorkspace'; +import { VSWorkspaceManager } from '../../workspace/VSWorkspaceManager'; import type { ApexSymbol } from '../codesymbol/ApexSymbolParser'; -import { SymbolFinder } from '../codesymbol/SymbolFinder'; +import { findSymbol } from '../codesymbol/SymbolFinder'; jest.mock('vscode'); jest.mock('../../display/QuickPick'); +jest.mock('../../workspace/VSWorkspace'); +jest.mock('../../workspace/VSWorkspaceManager'); function createSymbol(opts: { namespace?: string | null; outerClass: string }): ApexSymbol { return { @@ -27,27 +29,25 @@ function createMockUri(path: string): Uri { } function createMockWorkspace(findClassResult: Uri[]): VSWorkspace { - return { - findClass: jest.fn().mockResolvedValue(findClassResult), - } as unknown as VSWorkspace; + const mockWorkspaceFolder = { uri: { fsPath: '/test' }, name: 'test' } as WorkspaceFolder; + const workspace = new VSWorkspace(mockWorkspaceFolder); + (workspace.findClass as jest.Mock).mockReturnValue(findClassResult); + return workspace; } function createMockManager( workspaceFolders: VSWorkspace[], namespacedWorkspaces: VSWorkspace[] = [], ): VSWorkspaceManager { - return { - workspaceFolders, - getWorkspaceForNamespacedProjects: jest.fn().mockReturnValue(namespacedWorkspaces), - } as unknown as VSWorkspaceManager; + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = workspaceFolders; + (manager.getWorkspaceForNamespacedProjects as jest.Mock).mockReturnValue(namespacedWorkspaces); + return manager; } describe('SymbolFinder', () => { - let symbolFinder: SymbolFinder; - beforeEach(() => { jest.clearAllMocks(); - symbolFinder = new SymbolFinder(); }); describe('findSymbol', () => { @@ -56,7 +56,7 @@ describe('SymbolFinder', () => { const manager = createMockManager([mockWorkspace]); const symbol = createSymbol({ outerClass: 'MyClass' }); - const result = await symbolFinder.findSymbol(manager, symbol); + const result = await findSymbol(manager, symbol); expect(result).toBeNull(); }); @@ -67,7 +67,7 @@ describe('SymbolFinder', () => { const manager = createMockManager([mockWorkspace]); const symbol = createSymbol({ outerClass: 'MyClass' }); - const result = await symbolFinder.findSymbol(manager, symbol); + const result = await findSymbol(manager, symbol); expect(result).toBe(mockUri); expect(QuickPick.pick).not.toHaveBeenCalled(); @@ -82,7 +82,7 @@ describe('SymbolFinder', () => { (QuickPick.pick as jest.Mock).mockResolvedValue([{ uri: mockUri1 }]); - const result = await symbolFinder.findSymbol(manager, symbol); + const result = await findSymbol(manager, symbol); expect(result).toBe(mockUri1); expect(QuickPick.pick).toHaveBeenCalledWith( @@ -103,7 +103,7 @@ describe('SymbolFinder', () => { (QuickPick.pick as jest.Mock).mockResolvedValue([]); - const result = await symbolFinder.findSymbol(manager, symbol); + const result = await findSymbol(manager, symbol); expect(result).toBeNull(); }); @@ -115,7 +115,7 @@ describe('SymbolFinder', () => { const manager = createMockManager([regularWorkspace], [namespacedWorkspace]); const symbol = createSymbol({ namespace: 'ns', outerClass: 'MyClass' }); - const result = await symbolFinder.findSymbol(manager, symbol); + const result = await findSymbol(manager, symbol); expect(result).toBe(mockUri); expect(manager.getWorkspaceForNamespacedProjects).toHaveBeenCalledWith('ns'); @@ -130,7 +130,7 @@ describe('SymbolFinder', () => { const manager = createMockManager([mockWorkspace1, mockWorkspace2]); const symbol = createSymbol({ outerClass: 'MyClass' }); - const result = await symbolFinder.findSymbol(manager, symbol); + const result = await findSymbol(manager, symbol); expect(result).toBe(mockUri); expect(manager.getWorkspaceForNamespacedProjects).not.toHaveBeenCalled(); diff --git a/lana/src/workspace/__tests__/VSWorkspace.test.ts b/lana/src/workspace/__tests__/VSWorkspace.test.ts index af1c80ea..ee6254fc 100644 --- a/lana/src/workspace/__tests__/VSWorkspace.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspace.test.ts @@ -1,12 +1,14 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { Uri, workspace, type WorkspaceFolder } from 'vscode'; -import type { SfdxProject } from '../../salesforce/codesymbol/SfdxProjectReader'; +import { Uri, type WorkspaceFolder } from 'vscode'; +import { SfdxProject } from '../../salesforce/codesymbol/SfdxProject'; +import { getProjects } from '../../salesforce/codesymbol/SfdxProjectReader'; import { VSWorkspace } from '../VSWorkspace'; jest.mock('vscode'); jest.mock('../../salesforce/codesymbol/SfdxProjectReader'); +jest.mock('../../salesforce/codesymbol/SfdxProject'); describe('VSWorkspace', () => { const mockWorkspaceFolder = { @@ -36,12 +38,11 @@ describe('VSWorkspace', () => { describe('parseSfdxProjects', () => { it('should group projects by namespace', async () => { - const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); - const mockProjects: SfdxProject[] = [ - { name: 'project1', namespace: 'ns1', packageDirectories: [] }, - { name: 'project2', namespace: 'ns1', packageDirectories: [] }, - { name: 'project3', namespace: 'ns2', packageDirectories: [] }, - { name: 'project4', namespace: '', packageDirectories: [] }, + const mockProjects = [ + new SfdxProject('project1', 'ns1', []), + new SfdxProject('project2', 'ns1', []), + new SfdxProject('project3', 'ns2', []), + new SfdxProject('project4', '', []), ]; (getProjects as jest.Mock).mockResolvedValue(mockProjects); @@ -51,6 +52,7 @@ describe('VSWorkspace', () => { expect(vsWorkspace.getProjectsForNamespace('ns1')).toHaveLength(2); expect(vsWorkspace.getProjectsForNamespace('ns2')).toHaveLength(1); expect(vsWorkspace.getProjectsForNamespace('')).toHaveLength(1); + expect(mockProjects[0]!.buildClassIndex).toHaveBeenCalled(); }); }); @@ -60,15 +62,11 @@ describe('VSWorkspace', () => { }); it('should return projects matching the namespace', async () => { - const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); - const ns1Projects: SfdxProject[] = [ - { name: 'project1', namespace: 'ns1', packageDirectories: [] }, - { name: 'project2', namespace: 'ns1', packageDirectories: [] }, - ]; - const mockProjects: SfdxProject[] = [ - ...ns1Projects, - { name: 'project3', namespace: 'ns2', packageDirectories: [] }, + const ns1Projects = [ + new SfdxProject('project1', 'ns1', []), + new SfdxProject('project2', 'ns1', []), ]; + const mockProjects = [...ns1Projects, new SfdxProject('project3', 'ns2', [])]; (getProjects as jest.Mock).mockResolvedValue(mockProjects); await vsWorkspace.parseSfdxProjects(); @@ -79,10 +77,9 @@ describe('VSWorkspace', () => { describe('getAllProjects', () => { it('should return all projects across namespaces', async () => { - const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); - const mockProjects: SfdxProject[] = [ - { name: 'project1', namespace: 'ns1', packageDirectories: [] }, - { name: 'project2', namespace: 'ns2', packageDirectories: [] }, + const mockProjects = [ + new SfdxProject('project1', 'ns1', []), + new SfdxProject('project2', 'ns2', []), ]; (getProjects as jest.Mock).mockResolvedValue(mockProjects); @@ -94,30 +91,24 @@ describe('VSWorkspace', () => { }); describe('findClass', () => { + let mockProject1: SfdxProject; + let mockProject2: SfdxProject; + beforeEach(async () => { - const { getProjects } = await import('../../salesforce/codesymbol/SfdxProjectReader'); - const mockProjects: SfdxProject[] = [ - { - name: 'project1', - namespace: 'ns1', - packageDirectories: [{ path: '/workspace/force-app', default: true }], - }, - { - name: 'project2', - namespace: '', - packageDirectories: [{ path: '/workspace/src', default: true }], - }, - ]; + mockProject1 = new SfdxProject('project1', 'ns1', [ + { path: '/workspace/force-app', default: true }, + ]); + mockProject2 = new SfdxProject('project2', '', [{ path: '/workspace/src', default: true }]); - (getProjects as jest.Mock).mockResolvedValue(mockProjects); + (getProjects as jest.Mock).mockResolvedValue([mockProject1, mockProject2]); await vsWorkspace.parseSfdxProjects(); }); - it('should search in namespaced projects when namespace provided', async () => { - const mockUri = { fsPath: '/workspace/force-app/classes/MyClass.cls' }; - (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); + it('should search in namespaced projects when namespace provided', () => { + const mockUri = { fsPath: '/workspace/force-app/classes/MyClass.cls' } as Uri; + (mockProject1.findClass as jest.Mock).mockReturnValue([mockUri]); - const result = await vsWorkspace.findClass({ + const result = vsWorkspace.findClass({ fullSymbol: 'ns1.MyClass.method()', namespace: 'ns1', outerClass: 'MyClass', @@ -127,14 +118,17 @@ describe('VSWorkspace', () => { }); expect(result).toEqual([mockUri]); - expect(workspace.findFiles).toHaveBeenCalled(); + expect(mockProject1.findClass).toHaveBeenCalledWith('MyClass'); + expect(mockProject2.findClass).not.toHaveBeenCalled(); }); - it('should search in all projects when no namespace provided', async () => { - (workspace.findFiles as jest.Mock).mockResolvedValue([]); - (Uri.joinPath as jest.Mock).mockReturnValue({ fsPath: '/workspace/src' }); + it('should search in all projects when no namespace provided', () => { + const mockUri1 = { fsPath: '/workspace/force-app/classes/MyClass.cls' } as Uri; + const mockUri2 = { fsPath: '/workspace/src/classes/MyClass.cls' } as Uri; + (mockProject1.findClass as jest.Mock).mockReturnValue([mockUri1]); + (mockProject2.findClass as jest.Mock).mockReturnValue([mockUri2]); - await vsWorkspace.findClass({ + const result = vsWorkspace.findClass({ fullSymbol: 'MyClass.method()', namespace: null, outerClass: 'MyClass', @@ -143,7 +137,9 @@ describe('VSWorkspace', () => { parameters: '', }); - expect(workspace.findFiles).toHaveBeenCalled(); + expect(result).toEqual([mockUri1, mockUri2]); + expect(mockProject1.findClass).toHaveBeenCalledWith('MyClass'); + expect(mockProject2.findClass).toHaveBeenCalledWith('MyClass'); }); }); }); diff --git a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts index 43c7a6b7..2079acae 100644 --- a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts @@ -2,12 +2,15 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { workspace } from 'vscode'; -import type { SfdxProject } from '../../salesforce/codesymbol/SfdxProjectReader'; +import { SfdxProject } from '../../salesforce/codesymbol/SfdxProject'; +import { findSymbol } from '../../salesforce/codesymbol/SymbolFinder'; import { VSWorkspace } from '../VSWorkspace'; import { VSWorkspaceManager } from '../VSWorkspaceManager'; jest.mock('vscode'); jest.mock('../VSWorkspace'); +jest.mock('../../salesforce/codesymbol/SfdxProject'); +jest.mock('../../salesforce/codesymbol/SymbolFinder'); describe('VSWorkspaceManager', () => { beforeEach(() => { @@ -26,7 +29,6 @@ describe('VSWorkspaceManager', () => { const manager = new VSWorkspaceManager(); expect(manager.workspaceFolders).toHaveLength(2); - expect(VSWorkspace).toHaveBeenCalledTimes(2); }); it('should handle no workspace folders', () => { @@ -38,12 +40,8 @@ describe('VSWorkspaceManager', () => { describe('getAllProjects', () => { it('should aggregate projects from all workspaces', () => { - const mockProjects1: SfdxProject[] = [ - { name: 'p1', namespace: 'ns1', packageDirectories: [] }, - ]; - const mockProjects2: SfdxProject[] = [ - { name: 'p2', namespace: 'ns2', packageDirectories: [] }, - ]; + const mockProjects1 = [new SfdxProject('p1', 'ns1', [])]; + const mockProjects2 = [new SfdxProject('p2', 'ns2', [])]; const mockWorkspace1 = { getAllProjects: jest.fn().mockReturnValue(mockProjects1) }; const mockWorkspace2 = { getAllProjects: jest.fn().mockReturnValue(mockProjects2) }; @@ -76,36 +74,21 @@ describe('VSWorkspaceManager', () => { }); }); - describe('getProjectsForNamespace', () => { - it('should aggregate namespaced projects from all workspaces', () => { - const mockProjects1 = [{ name: 'p1', namespace: 'ns1' }]; - const mockProjects2 = [{ name: 'p2', namespace: 'ns1' }]; - + describe('initialiseWorkspaceProjectInfo', () => { + it('should call parseSfdxProjects on all workspaces', async () => { const mockWorkspace1 = { - getProjectsForNamespace: jest.fn().mockReturnValue(mockProjects1), + getAllProjects: jest.fn().mockReturnValue([]), + parseSfdxProjects: jest.fn().mockResolvedValue(undefined), }; const mockWorkspace2 = { - getProjectsForNamespace: jest.fn().mockReturnValue(mockProjects2), + getAllProjects: jest.fn().mockReturnValue([]), + parseSfdxProjects: jest.fn().mockResolvedValue(undefined), }; const manager = new VSWorkspaceManager(); manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; - const result = manager.getProjectsForNamespace('ns1'); - - expect(result).toEqual([...mockProjects1, ...mockProjects2]); - }); - }); - - describe('refreshWorkspaceProjectInfo', () => { - it('should call parseSfdxProjects on all workspaces', async () => { - const mockWorkspace1 = { parseSfdxProjects: jest.fn().mockResolvedValue(undefined) }; - const mockWorkspace2 = { parseSfdxProjects: jest.fn().mockResolvedValue(undefined) }; - - const manager = new VSWorkspaceManager(); - manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; - - await manager.refreshWorkspaceProjectInfo(); + await manager.initialiseWorkspaceProjectInfo(); expect(mockWorkspace1.parseSfdxProjects).toHaveBeenCalled(); expect(mockWorkspace2.parseSfdxProjects).toHaveBeenCalled(); @@ -123,14 +106,14 @@ describe('VSWorkspaceManager', () => { method: 'method', parameters: '', }; + (findSymbol as jest.Mock).mockResolvedValueOnce(mockUri); const manager = new VSWorkspaceManager(); - manager.symbolFinder.findSymbol = jest.fn().mockResolvedValue(mockUri); const result = await manager.findSymbol(mockSymbol); + expect(findSymbol).toHaveBeenCalledWith(manager, mockSymbol); expect(result).toEqual(mockUri); - expect(manager.symbolFinder.findSymbol).toHaveBeenCalledWith(manager, mockSymbol); }); }); }); From 994d71ffdab7079759282187df478509879dc91d Mon Sep 17 00:00:00 2001 From: Josh Coulter Date: Tue, 25 Nov 2025 16:41:58 +0000 Subject: [PATCH 18/18] test: add tests for SfdxProject --- .../salesforce/codesymbol/ApexSymbolParser.ts | 3 + lana/src/salesforce/codesymbol/SfdxProject.ts | 3 + .../__tests__/ApexSymbolParser.test.ts | 6 +- .../codesymbol/__tests__/SfdxProject.test.ts | 228 ++++++++++++++++++ .../__tests__/SfdxProjectReader.test.ts | 2 +- .../__tests__/SymbolFinder.test.ts | 16 +- lana/src/workspace/VSWorkspaceManager.ts | 3 + 7 files changed, 249 insertions(+), 12 deletions(-) rename lana/src/salesforce/{ => codesymbol}/__tests__/ApexSymbolParser.test.ts (96%) create mode 100644 lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts rename lana/src/salesforce/{ => codesymbol}/__tests__/SfdxProjectReader.test.ts (98%) rename lana/src/salesforce/{ => codesymbol}/__tests__/SymbolFinder.test.ts (91%) diff --git a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts index 45b1a2f1..0db153b5 100644 --- a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts +++ b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts @@ -1,3 +1,6 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ import type { SfdxProject } from './SfdxProject'; export type ApexSymbol = { diff --git a/lana/src/salesforce/codesymbol/SfdxProject.ts b/lana/src/salesforce/codesymbol/SfdxProject.ts index 63106a66..163fd5eb 100644 --- a/lana/src/salesforce/codesymbol/SfdxProject.ts +++ b/lana/src/salesforce/codesymbol/SfdxProject.ts @@ -1,3 +1,6 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ import path from 'path'; import { RelativePattern, Uri, workspace } from 'vscode'; diff --git a/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts b/lana/src/salesforce/codesymbol/__tests__/ApexSymbolParser.test.ts similarity index 96% rename from lana/src/salesforce/__tests__/ApexSymbolParser.test.ts rename to lana/src/salesforce/codesymbol/__tests__/ApexSymbolParser.test.ts index 2738c157..21d1c4af 100644 --- a/lana/src/salesforce/__tests__/ApexSymbolParser.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/ApexSymbolParser.test.ts @@ -1,10 +1,10 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { parseSymbol, type ApexSymbol } from '../codesymbol/ApexSymbolParser'; -import { SfdxProject } from '../codesymbol/SfdxProject'; +import { parseSymbol, type ApexSymbol } from '../ApexSymbolParser'; +import { SfdxProject } from '../SfdxProject'; -jest.mock('../codesymbol/SfdxProject'); +jest.mock('../SfdxProject'); function createProject(namespace: string): SfdxProject { return new SfdxProject('test-project', namespace, [{ path: 'force-app', default: true }]); diff --git a/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts b/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts new file mode 100644 index 00000000..768e69bd --- /dev/null +++ b/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { RelativePattern, Uri, workspace } from 'vscode'; +import { SfdxProject } from '../SfdxProject'; + +jest.mock('vscode'); + +describe('SfdxProject', () => { + let project: SfdxProject; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findClass', () => { + beforeEach(() => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + ]); + }); + + it('should return empty array when class not in cache', () => { + const result = project.findClass('NonExistentClass'); + + expect(result).toEqual([]); + }); + + it('should return empty array before buildClassIndex is called', () => { + const result = project.findClass('MyClass'); + + expect(result).toEqual([]); + }); + + it('should return single Uri when class has one match', async () => { + const mockUri = { fsPath: '/workspace/force-app/classes/MyClass.cls' } as Uri; + (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + const result = project.findClass('MyClass'); + + expect(result).toHaveLength(1); + expect(Uri.file).toHaveBeenCalledWith('/workspace/force-app/classes/MyClass.cls'); + }); + + it('should return multiple Uris when class has multiple matches', async () => { + const mockUris = [ + { fsPath: '/workspace/force-app/classes/MyClass.cls' } as Uri, + { fsPath: '/workspace/another-app/classes/MyClass.cls' } as Uri, + ]; + (workspace.findFiles as jest.Mock).mockResolvedValue(mockUris); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + const result = project.findClass('MyClass'); + + expect(result).toHaveLength(2); + expect(Uri.file).toHaveBeenCalledWith('/workspace/force-app/classes/MyClass.cls'); + expect(Uri.file).toHaveBeenCalledWith('/workspace/another-app/classes/MyClass.cls'); + }); + + it('should properly convert file paths to Uri objects', async () => { + const mockUri = { fsPath: '/workspace/force-app/classes/TestClass.cls' } as Uri; + const expectedUri = { fsPath: '/workspace/force-app/classes/TestClass.cls' } as Uri; + (workspace.findFiles as jest.Mock).mockResolvedValue([mockUri]); + (Uri.file as jest.Mock).mockReturnValue(expectedUri); + + await project.buildClassIndex(); + const result = project.findClass('TestClass'); + + expect(result[0]).toBe(expectedUri); + }); + }); + + describe('buildClassIndex', () => { + it('should build index from single package directory', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + ]); + + const mockUris = [ + { fsPath: '/workspace/force-app/classes/Class1.cls' } as Uri, + { fsPath: '/workspace/force-app/classes/Class2.cls' } as Uri, + ]; + (workspace.findFiles as jest.Mock).mockResolvedValue(mockUris); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + + expect(workspace.findFiles).toHaveBeenCalledTimes(1); + expect(RelativePattern).toHaveBeenCalledWith('/workspace/force-app', '**/*.cls'); + + const class1Result = project.findClass('Class1'); + const class2Result = project.findClass('Class2'); + + expect(class1Result).toHaveLength(1); + expect(class2Result).toHaveLength(1); + }); + + it('should build index from multiple package directories', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + { path: '/workspace/another-app', default: false }, + ]); + + (workspace.findFiles as jest.Mock) + .mockResolvedValueOnce([{ fsPath: '/workspace/force-app/classes/Class1.cls' } as Uri]) + .mockResolvedValueOnce([{ fsPath: '/workspace/another-app/classes/Class2.cls' } as Uri]); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + + expect(workspace.findFiles).toHaveBeenCalledTimes(2); + expect(RelativePattern).toHaveBeenCalledWith('/workspace/force-app', '**/*.cls'); + expect(RelativePattern).toHaveBeenCalledWith('/workspace/another-app', '**/*.cls'); + + expect(project.findClass('Class1')).toHaveLength(1); + expect(project.findClass('Class2')).toHaveLength(1); + }); + + it('should handle multiple classes with the same name', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + { path: '/workspace/another-app', default: false }, + ]); + + (workspace.findFiles as jest.Mock) + .mockResolvedValueOnce([ + { fsPath: '/workspace/force-app/classes/DuplicateClass.cls' } as Uri, + ]) + .mockResolvedValueOnce([ + { fsPath: '/workspace/another-app/classes/DuplicateClass.cls' } as Uri, + ]); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + + const result = project.findClass('DuplicateClass'); + + expect(result).toHaveLength(2); + }); + + it('should handle empty package directories', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/empty-app', default: true }, + ]); + + (workspace.findFiles as jest.Mock).mockResolvedValue([]); + + await project.buildClassIndex(); + + const result = project.findClass('AnyClass'); + + expect(result).toEqual([]); + }); + + it('should properly extract class name from .cls file paths', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + ]); + + const mockUris = [ + { fsPath: '/workspace/force-app/classes/MyController.cls' } as Uri, + { fsPath: '/workspace/force-app/classes/utils/StringUtil.cls' } as Uri, + ]; + (workspace.findFiles as jest.Mock).mockResolvedValue(mockUris); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + + expect(project.findClass('MyController')).toHaveLength(1); + expect(project.findClass('StringUtil')).toHaveLength(1); + expect(project.findClass('MyController.cls')).toHaveLength(0); + }); + + it('should clear previous cache when re-indexing', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + ]); + + (workspace.findFiles as jest.Mock) + .mockResolvedValueOnce([{ fsPath: '/workspace/force-app/classes/OldClass.cls' } as Uri]) + .mockResolvedValueOnce([{ fsPath: '/workspace/force-app/classes/NewClass.cls' } as Uri]); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + expect(project.findClass('OldClass')).toHaveLength(1); + + await project.buildClassIndex(); + const oldClassResult = project.findClass('OldClass'); + const newClassResult = project.findClass('NewClass'); + + expect(oldClassResult).toHaveLength(0); + expect(newClassResult).toHaveLength(1); + }); + + it('should use correct glob pattern for finding classes', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + ]); + + (workspace.findFiles as jest.Mock).mockResolvedValue([]); + + await project.buildClassIndex(); + + expect(RelativePattern).toHaveBeenCalledWith('/workspace/force-app', '**/*.cls'); + }); + + it('should handle classes in nested directories', async () => { + project = new SfdxProject('test-project', 'ns', [ + { path: '/workspace/force-app', default: true }, + ]); + + const mockUris = [ + { fsPath: '/workspace/force-app/classes/controllers/MyController.cls' } as Uri, + { fsPath: '/workspace/force-app/classes/utils/helpers/StringHelper.cls' } as Uri, + ]; + (workspace.findFiles as jest.Mock).mockResolvedValue(mockUris); + (Uri.file as jest.Mock).mockImplementation((path) => ({ fsPath: path }) as Uri); + + await project.buildClassIndex(); + + expect(project.findClass('MyController')).toHaveLength(1); + expect(project.findClass('StringHelper')).toHaveLength(1); + }); + }); +}); diff --git a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts similarity index 98% rename from lana/src/salesforce/__tests__/SfdxProjectReader.test.ts rename to lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts index 3a896783..d50bcde3 100644 --- a/lana/src/salesforce/__tests__/SfdxProjectReader.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts @@ -2,7 +2,7 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; -import { getProjects } from '../codesymbol/SfdxProjectReader'; +import { getProjects } from '../SfdxProjectReader'; jest.mock('vscode'); diff --git a/lana/src/salesforce/__tests__/SymbolFinder.test.ts b/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts similarity index 91% rename from lana/src/salesforce/__tests__/SymbolFinder.test.ts rename to lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts index 15a23072..5822c3bf 100644 --- a/lana/src/salesforce/__tests__/SymbolFinder.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts @@ -2,16 +2,16 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import type { Uri, WorkspaceFolder } from 'vscode'; -import { QuickPick } from '../../display/QuickPick'; -import { VSWorkspace } from '../../workspace/VSWorkspace'; -import { VSWorkspaceManager } from '../../workspace/VSWorkspaceManager'; -import type { ApexSymbol } from '../codesymbol/ApexSymbolParser'; -import { findSymbol } from '../codesymbol/SymbolFinder'; +import { QuickPick } from '../../../display/QuickPick'; +import { VSWorkspace } from '../../../workspace/VSWorkspace'; +import { VSWorkspaceManager } from '../../../workspace/VSWorkspaceManager'; +import type { ApexSymbol } from '../ApexSymbolParser'; +import { findSymbol } from '../SymbolFinder'; jest.mock('vscode'); -jest.mock('../../display/QuickPick'); -jest.mock('../../workspace/VSWorkspace'); -jest.mock('../../workspace/VSWorkspaceManager'); +jest.mock('../../../display/QuickPick'); +jest.mock('../../../workspace/VSWorkspace'); +jest.mock('../../../workspace/VSWorkspaceManager'); function createSymbol(opts: { namespace?: string | null; outerClass: string }): ApexSymbol { return { diff --git a/lana/src/workspace/VSWorkspaceManager.ts b/lana/src/workspace/VSWorkspaceManager.ts index 969022ae..f449adcb 100644 --- a/lana/src/workspace/VSWorkspaceManager.ts +++ b/lana/src/workspace/VSWorkspaceManager.ts @@ -1,3 +1,6 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ import { Uri, workspace } from 'vscode'; import type { ApexSymbol } from '../salesforce/codesymbol/ApexSymbolParser'; import type { SfdxProject } from '../salesforce/codesymbol/SfdxProject';